package web import ( "bytes" "encoding/json" "net/http/httptest" "testing" "time" "pete/internal/storage" ) func postBeats(t *testing.T, s *Server, token string, beats ...storage.RunBeat) *httptest.ResponseRecorder { t.Helper() body, _ := json.Marshal(runBeatsPush{Beats: beats}) req := httptest.NewRequest("POST", "/api/ingest/run", bytes.NewReader(body)) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } w := httptest.NewRecorder() s.handleRunIngest(w, req) return w } // startBeat is the beat that names a run. Everything downstream keys on run_id // alone, so this is the only one that has to carry identity. func startBeat(now int64) storage.RunBeat { return storage.RunBeat{ RunID: "run-1", Seq: 1, Kind: "start", OccurredAt: now, Token: "tok-abc", Name: "Josie", Level: 14, Zone: "Crypt of Valdris", TotalRooms: 9, } } // TestUnknownBeatKindIsStoredAndRendered is the regression for a whole class of // bug, not for one beat kind. // // The dispatch channel learned this the hard way: an unknown event_type used to // 400, which parked the queue row upstream and silently deleted a game event // that had actually happened. The beat channel is a second chance to make the // same mistake, and this is the test that stops it — gogobee must be able to // invent a beat kind on any Tuesday and have it show up as a plain line rather // than as a 400 and a hole in the log. func TestUnknownBeatKindIsStoredAndRendered(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) now := time.Now().Unix() if w := postBeats(t, s, token, startBeat(now), storage.RunBeat{RunID: "run-1", Seq: 2, Kind: "seance", OccurredAt: now + 5, Room: 2, TotalRooms: 9, Target: "a cold draught"}, ); w.Code != 200 { t.Fatalf("unknown beat kind rejected: %d %s", w.Code, w.Body.String()) } v := runLogFor("tok-abc") if !v.Has { t.Fatal("no log built for a run that has two beats") } if len(v.Lines) != 2 { t.Fatalf("want 2 lines, got %d: %+v", len(v.Lines), v.Lines) } last := v.Lines[1] if last.Text != "seance — a cold draught" { t.Errorf("unknown kind rendered as %q; it should degrade to its own noun", last.Text) } } // TestBeatIngestRequiresIdentity — run_id and seq ARE the row. Without both // there is nothing for the re-send to collapse onto, so this is the one thing // the ingest is strict about. func TestBeatIngestRequiresIdentity(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) now := time.Now().Unix() if w := postBeats(t, s, token, storage.RunBeat{Seq: 1, Kind: "room", OccurredAt: now}); w.Code != 400 { t.Errorf("beat with no run_id: want 400, got %d", w.Code) } if w := postBeats(t, s, token, storage.RunBeat{RunID: "run-1", Kind: "room", OccurredAt: now}); w.Code != 400 { t.Errorf("beat with no seq: want 400, got %d", w.Code) } if w := postBeats(t, s, "wrong-token", startBeat(now)); w.Code != 401 { t.Errorf("unauthed beat: want 401, got %d", w.Code) } } // TestBeatsAreIdempotentOnRunAndSeq. gogobee re-sends a batch whenever it // delivered it but failed to mark it locally, which is a normal outcome of a // crash between two writes — so a duplicate batch has to be free. func TestBeatsAreIdempotentOnRunAndSeq(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) now := time.Now().Unix() beats := []storage.RunBeat{ startBeat(now), {RunID: "run-1", Seq: 2, Kind: "room", OccurredAt: now + 10, Room: 2, TotalRooms: 9, RoomKind: "exploration"}, } for i := 0; i < 3; i++ { if w := postBeats(t, s, token, beats...); w.Code != 200 { t.Fatalf("push %d: %d %s", i, w.Code, w.Body.String()) } } stored, err := storage.RunBeats("run-1", 0) if err != nil { t.Fatal(err) } if len(stored) != 2 { t.Fatalf("three identical pushes produced %d beats, want 2", len(stored)) } } // TestRunHeaderIsDerivedAndSticky. The header is not pushed as its own object — // it is folded out of the beats. The forty beats after `start` carry no name and // no zone, and none of them may erase the one that did. func TestRunHeaderIsDerivedAndSticky(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) now := time.Now().Unix() postBeats(t, s, token, startBeat(now)) postBeats(t, s, token, storage.RunBeat{RunID: "run-1", Seq: 2, Kind: "room", OccurredAt: now + 10, Room: 2, TotalRooms: 9}, storage.RunBeat{RunID: "run-1", Seq: 3, Kind: "combat", OccurredAt: now + 20, Room: 2, TotalRooms: 9, Target: "Bone Chanter", Outcome: "won", Amount: 7, HP: 61, HPMax: 68}, ) run, ok, err := storage.RunByID("run-1") if err != nil || !ok { t.Fatalf("run header missing: ok=%v err=%v", ok, err) } if run.Name != "Josie" || run.Zone != "Crypt of Valdris" || run.Level != 14 { t.Errorf("later beats clobbered the start beat's identity: %+v", run) } if !run.Live() { t.Error("run with no end beat should still be live") } // Now close it, then try to reopen it with a second, less specific end. postBeats(t, s, token, storage.RunBeat{RunID: "run-1", Seq: 4, Kind: "end", OccurredAt: now + 30, Outcome: "died"}, storage.RunBeat{RunID: "run-1", Seq: 5, Kind: "end", OccurredAt: now + 31, Outcome: "abandoned"}, ) run, _, _ = storage.RunByID("run-1") if run.Live() { t.Error("run with an end beat should not be live") } if run.Outcome != "died" { t.Errorf("outcome = %q, want %q — the first, specific close must win", run.Outcome, "died") } } // TestRunWithNoStartBeatStillHasALog. A start beat can be lost (retention on the // game box, an opt-out flipped mid-run, a batch that never made it). The run // that follows is unattributed, which is a reason not to hang it off an // adventurer page — not a reason to throw the log away. func TestRunWithNoStartBeatStillHasALog(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) now := time.Now().Unix() if w := postBeats(t, s, token, storage.RunBeat{RunID: "orphan", Seq: 7, Kind: "combat", OccurredAt: now, Room: 3, TotalRooms: 9, Target: "Gravewright", Outcome: "won"}, ); w.Code != 200 { t.Fatalf("orphan beat rejected: %d", w.Code) } run, ok, err := storage.RunByID("orphan") if err != nil || !ok { t.Fatalf("orphan run has no header: ok=%v err=%v", ok, err) } if run.Token != "" { t.Errorf("orphan run claimed token %q", run.Token) } // ...and it is unreachable from any adventurer page, which is the point. if v := runLogFor(""); v.Has { t.Error("empty token resolved to a log") } } // TestFinishedRunAgesOffThePage. The adventurer page is about now. A run that // ended days ago sitting under a live map reads as the live one, which is worse // than showing nothing — the rows stay in the database for the dispatch that // links to them. func TestFinishedRunAgesOffThePage(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) old := time.Now().Add(-24 * time.Hour).Unix() postBeats(t, s, token, storage.RunBeat{RunID: "run-old", Seq: 1, Kind: "start", OccurredAt: old, Token: "tok-abc", Name: "Josie", Zone: "Underforge", TotalRooms: 8}, storage.RunBeat{RunID: "run-old", Seq: 2, Kind: "end", OccurredAt: old + 600, Outcome: "cleared"}, ) if v := runLogFor("tok-abc"); v.Has { t.Error("a run that ended a day ago is still on the page") } if beats, _ := storage.RunBeats("run-old", 0); len(beats) != 2 { t.Errorf("aged-off run lost its stored beats: %d", len(beats)) } } // TestLiveRunBeatsAFinishedOne is the border-crossing case, and it is the reason // the page picks a run by liveness before recency. // // A multi-region expedition closes one run and opens the next in the same // breath: the outgoing `end` beat and the incoming `start` beat carry the same // second, and which one has the later updated_at is a coin flip. Losing it means // the page shows the log of a region the party has already walked out of, with a // "cleared" chip on it, while they are three rooms into the next one. func TestLiveRunBeatsAFinishedOne(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) now := time.Now().Unix() postBeats(t, s, token, storage.RunBeat{RunID: "region-1", Seq: 1, Kind: "start", OccurredAt: now - 60, Token: "tok-abc", Name: "Josie", Zone: "The Slagworks", TotalRooms: 6}, // The crossing and the next region's opening land on the same clock tick. storage.RunBeat{RunID: "region-1", Seq: 2, Kind: "end", OccurredAt: now, Outcome: "cleared"}, storage.RunBeat{RunID: "region-2", Seq: 1, Kind: "start", OccurredAt: now, Token: "tok-abc", Name: "Josie", Zone: "The Deep Bellows", TotalRooms: 7}, ) run, ok, err := storage.LatestRunForToken("tok-abc") if err != nil || !ok { t.Fatalf("no run resolved: ok=%v err=%v", ok, err) } if run.RunID != "region-2" { t.Fatalf("page picked %q (%s); the live run must win over the finished one", run.RunID, run.Outcome) } if v := runLogFor("tok-abc"); !v.Live || v.Zone != "The Deep Bellows" { t.Errorf("log = %q live:%v, want the region they are actually in", v.Zone, v.Live) } } // TestRunLogShowsTheTail. A log is read for what just happened. A party deep // into a long expedition must not be showing its first morning. func TestRunLogShowsTheTail(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) now := time.Now().Unix() beats := []storage.RunBeat{startBeat(now)} for i := 2; i <= runLogCap+20; i++ { beats = append(beats, storage.RunBeat{ RunID: "run-1", Seq: int64(i), Kind: "room", OccurredAt: now + int64(i), Room: i, TotalRooms: 400, RoomKind: "exploration", }) } if w := postBeats(t, s, token, beats...); w.Code != 200 { t.Fatalf("push: %d %s", w.Code, w.Body.String()) } v := runLogFor("tok-abc") if len(v.Lines) != runLogCap { t.Fatalf("want %d lines, got %d", runLogCap, len(v.Lines)) } // Oldest-first within the tail, and the tail ends at the newest beat. if got := v.Lines[len(v.Lines)-1].Room; got != "80/400" { t.Errorf("last line room = %q, want the newest beat", got) } if v.Rooms != "80 / 400" { t.Errorf("header room = %q", v.Rooms) } } // TestOffTheBoardShipsNoLog. Coming off the board means opted out or removed — // finishing a run leaves an adventurer on it as idle. So the branch that answers // "this token is no longer listed" must not hand back a room-by-room account of // where its owner is; the page 404s in the same situation, and an API that is // more forthcoming than the page it backs is a leak with extra steps. func TestOffTheBoardShipsNoLog(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) now := time.Now().Unix() postBeats(t, s, token, startBeat(now), storage.RunBeat{RunID: "run-1", Seq: 2, Kind: "combat", OccurredAt: now + 10, Room: 2, TotalRooms: 9, Target: "Bone Chanter", Outcome: "won"}) // Never pushed a roster, so no token is on the board — the opted-out case. req := httptest.NewRequest("GET", "/api/adventure/who/tok-abc", nil) req.SetPathValue("token", "tok-abc") w := httptest.NewRecorder() s.handleAdventureWhoAPI(w, req) var got map[string]any if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v (%s)", err, w.Body.String()) } if got["live"] != false { t.Errorf("live = %v, want false", got["live"]) } if _, leaked := got["run_log"]; leaked { t.Errorf("an off-the-board token was handed its run log: %s", w.Body.String()) } } // TestRenderRunBeatCarriesTheNouns pins the shape of the lines: they are built // out of the beat and nothing else. A line that reads better than the facts // support is a line lying about a run somebody actually walked. func TestRenderRunBeatCarriesTheNouns(t *testing.T) { cases := []struct { name string beat storage.RunBeat want string hurt bool good bool }{ {"kill", storage.RunBeat{Kind: "combat", Outcome: "won", Target: "Bone Chanter", Amount: 7, HP: 61, HPMax: 68}, "Bone Chanter down — took 7 · 61/68 HP", false, true}, {"clean kill", storage.RunBeat{Kind: "combat", Outcome: "won", Target: "Rat", HP: 68, HPMax: 68}, "Rat down — untouched · 68/68 HP", false, true}, {"death", storage.RunBeat{Kind: "combat", Outcome: "down", Target: "The Rotmother", HP: 0, HPMax: 68}, "Fell to The Rotmother · 0/68 HP", true, false}, {"timeout", storage.RunBeat{Kind: "combat", Outcome: "retreat", Target: "Aldric"}, "Outlasted by Aldric — withdrew", true, false}, {"trap", storage.RunBeat{Kind: "trap", Amount: 12, HP: 40, HPMax: 68}, "Trap sprung — 12 damage · 40/68 HP", true, false}, {"trap avoided", storage.RunBeat{Kind: "trap"}, "Trap — stepped over it", false, true}, {"treasure", storage.RunBeat{Kind: "treasure", Target: "Coin Pouch", Outcome: "cache"}, "Found Coin Pouch in a cache", false, true}, {"haul", storage.RunBeat{Kind: "haul", Amount: 6, Target: "Ironcap", Count: 3}, "Gathered 6 — mostly Ironcap (3 kinds)", false, false}, {"region", storage.RunBeat{Kind: "region", Region: "The Shallows", Target: "The Deep"}, "Left The Shallows for The Deep", false, false}, {"cleared", storage.RunBeat{Kind: "end", Outcome: "cleared"}, "Run complete", false, true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { l, ok := renderRunBeat(c.beat) if !ok { t.Fatal("beat produced no line") } if l.Text != c.want { t.Errorf("text = %q, want %q", l.Text, c.want) } if l.Hurt != c.hurt || l.Good != c.good { t.Errorf("tint = hurt:%v good:%v, want hurt:%v good:%v", l.Hurt, l.Good, c.hurt, c.good) } }) } // A haul of nothing is not a beat. gogobee already skips it, but the renderer // is the second line of defence against a column of "Gathered 0". if _, ok := renderRunBeat(storage.RunBeat{Kind: "haul"}); ok { t.Error("empty haul produced a line") } } // TestHPTailOnlyWhenReal. A zero max means gogobee didn't send a pair, not that // the adventurer has no health — and "0/0 HP" on a winning line reads as a death. func TestHPTailOnlyWhenReal(t *testing.T) { l, _ := renderRunBeat(storage.RunBeat{Kind: "combat", Outcome: "won", Target: "Rat"}) if got := l.Text; got != "Rat down — untouched" { t.Errorf("text = %q; a missing HP pair must not be drawn", got) } }