package web import ( "net/http/httptest" "strings" "testing" "time" "pete/internal/storage" ) // onBoard puts the run's owner on the roster. Every report test needs it: the // report's visibility gate is the adventurer page's, and with no board at all // every token reads as opted-out. func onBoard(t *testing.T, s *Server, ingest, token, name string) { t.Helper() if w := postRoster(t, s, ingest, rosterPush{ SnapshotAt: time.Now().Unix(), Adventurers: []storage.RosterEntry{entry(token, name, "idle", "")}, }); w.Code != 200 { t.Fatalf("roster push failed: %d %s", w.Code, w.Body.String()) } } // aFinishedRun is a small but realistic expedition: two fights, a trap that hurt // more than either of them, a find, and a clean ending. func aFinishedRun(now int64) []storage.RunBeat { return []storage.RunBeat{ startBeat(now), {RunID: "run-1", Seq: 2, Kind: "combat", OccurredAt: now + 60, Room: 2, TotalRooms: 9, Target: "Bone Chanter", Outcome: "won", Amount: 7, HP: 61, HPMax: 68, Crits: 1}, {RunID: "run-1", Seq: 3, Kind: "trap", OccurredAt: now + 120, Room: 3, TotalRooms: 9, RoomKind: "trap", Outcome: "sprung", Amount: 22, HP: 39, HPMax: 68}, {RunID: "run-1", Seq: 4, Kind: "treasure", OccurredAt: now + 200, Room: 4, TotalRooms: 9, Target: "Ashlight Pendant", Outcome: "cache"}, {RunID: "run-1", Seq: 5, Kind: "combat", OccurredAt: now + 300, Room: 5, TotalRooms: 9, RoomKind: "boss", Target: "Valdris", Outcome: "won", Amount: 12, HP: 27, HPMax: 68}, {RunID: "run-1", Seq: 6, Kind: "end", OccurredAt: now + 360, Room: 5, TotalRooms: 9, Outcome: "cleared"}, } } func getReport(t *testing.T, s *Server, runID string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest("GET", "/adventure/run/"+runID, nil) req.SetPathValue("run_id", runID) w := httptest.NewRecorder() s.handleRunReport(w, req) return w } // TestRunReportRendersTheWholeRun. The liveblog is capped and expires; the // report is the artefact, so what it has to get right is that everything is // there — every beat, the rollup, and the moment it turned. func TestRunReportRendersTheWholeRun(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) now := time.Now().Unix() onBoard(t, s, token, "tok-abc", "Josie") postBeats(t, s, token, aFinishedRun(now)...) w := getReport(t, s, "run-1") if w.Code != 200 { t.Fatalf("report: %d %s", w.Code, w.Body.String()) } body := w.Body.String() for _, want := range []string{ "Josie in Crypt of Valdris", "Cleared it", "Bone Chanter down", "Trap sprung — 22 damage", "Found Ashlight Pendant", "Valdris down", "Run complete", "2/2", // fights won, as one tile rather than two "41", // damage taken: 7 + 22 + 12 "Where it turned", // the trap, being the biggest single hit } { if !strings.Contains(body, want) { t.Errorf("report is missing %q", want) } } } // TestTurningPointIsTheBiggestHit. The plan's word for it is "turning point" and // the temptation is to pick the boss, because a boss is the most *important* // thing in a run. It isn't the thing that turned it: a boss killed without a // scratch turned nothing, and the trap two rooms earlier that took a third of // the party's health is the beat the reader is looking for. func TestTurningPointIsTheBiggestHit(t *testing.T) { now := time.Now().Unix() run := storage.Run{RunID: "r", Token: "t", Name: "Josie", Zone: "Crypt", TotalRooms: 9, StartedAt: now, EndedAt: now + 360, Outcome: "cleared"} v := buildRunReport(run, aFinishedRun(now)) if v.Turning == nil { t.Fatal("no turning point on a run with a 22-damage trap in it") } if !strings.Contains(v.Turning.Text, "Trap sprung") { t.Errorf("turning point = %q, want the trap (22) over the boss (12)", v.Turning.Text) } // A run where nothing landed has no turning point rather than a made-up one. quiet := []storage.RunBeat{ {RunID: "r", Seq: 1, Kind: "start", OccurredAt: now, Zone: "Crypt", TotalRooms: 3}, {RunID: "r", Seq: 2, Kind: "combat", OccurredAt: now + 10, Target: "Rat", Outcome: "won"}, {RunID: "r", Seq: 3, Kind: "end", OccurredAt: now + 20, Outcome: "cleared"}, } if q := buildRunReport(run, quiet); q.Turning != nil { t.Errorf("invented a turning point on an untouched run: %q", q.Turning.Text) } } // TestHowFarTheyGotOnlyMattersWhenTheyFellShort. A dungeon graph forks, so a run // that cleared it never walks every room — "room 7 / 9" printed under the words // "Cleared it" says they came up two short of something they in fact finished. // On a run that ended badly the same number is the whole story. func TestHowFarTheyGotOnlyMattersWhenTheyFellShort(t *testing.T) { now := time.Now().Unix() beats := aFinishedRun(now) base := storage.Run{RunID: "r", Token: "t", Name: "Josie", Zone: "Crypt", TotalRooms: 9, StartedAt: now, EndedAt: now + 360} cleared := base cleared.Outcome = "cleared" if v := buildRunReport(cleared, beats); v.Rooms != "" { t.Errorf("a cleared run advertised how far it got: %q", v.Rooms) } died := base died.Outcome = "died" if v := buildRunReport(died, beats); v.Rooms != "got as far as room 5 of 9" { t.Errorf("Rooms = %q, want the depth on a run that ended badly", v.Rooms) } } // TestRunStatsSkipTheZeroes. The tile row is meant to say what THIS run was. A // run that sprung no traps and found no treasure rendering two confident zeroes // makes every run look identical, which is the exact failure the report exists // to fix. func TestRunStatsSkipTheZeroes(t *testing.T) { now := time.Now().Unix() stats := runStats([]storage.RunBeat{ {Kind: "combat", Outcome: "won", Target: "Rat", Amount: 3, OccurredAt: now}, {Kind: "trap", Outcome: "avoided", Amount: 0, OccurredAt: now + 1}, // stepped over it }) for _, s := range stats { if strings.Contains(s.Label, "trap") { t.Errorf("a trap that was avoided produced a tile: %+v", s) } if strings.Contains(s.Label, "treasure") { t.Errorf("a run with no finds produced a treasure tile: %+v", s) } if strings.Contains(s.Label, "critical") { t.Errorf("a run with no crits produced a crit tile: %+v", s) } } if len(stats) != 2 { // fights won + damage taken t.Fatalf("want 2 tiles, got %d: %+v", len(stats), stats) } } // TestRunReportIsGatedOnTheBoard is the report's half of TestOffTheBoardShipsNoLog. // // The report outlives the liveblog by a fortnight and is linked from a public // dispatch, so it is the surface most likely to still be reachable after somebody // opts out. Coming off the board is what an opt-out looks like from Pete's side, // and from that moment a room-by-room account of where they went has to stop // resolving — including through the link a dispatch minted days earlier. func TestRunReportIsGatedOnTheBoard(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) now := time.Now().Unix() onBoard(t, s, token, "tok-abc", "Josie") postBeats(t, s, token, aFinishedRun(now)...) if w := getReport(t, s, "run-1"); w.Code != 200 { t.Fatalf("report should serve while its owner is on the board: %d", w.Code) } ev := &storage.AdvEvent{GUID: "zone_clear:x:1", EventType: "zone_clear", Subject: "Josie", RunID: "run-1", OccurredAt: now} if err := storage.InsertAdventureEvent(ev); err != nil { t.Fatal(err) } if link := runReportLinkFor(ev); link != "/adventure/run/run-1" { t.Fatalf("dispatch link = %q, want the report path", link) } // They opt out: gogobee stops sending them, so the next board has no such // token. The beats Pete already holds are append-only and can't be recalled — // what has to happen is that they become unreachable. if w := postRoster(t, s, token, rosterPush{ SnapshotAt: now + 1, Adventurers: []storage.RosterEntry{entry("someone-else", "Quack", "idle", "")}, }); w.Code != 200 { t.Fatalf("roster push failed: %d", w.Code) } if w := getReport(t, s, "run-1"); w.Code != 404 { t.Errorf("report still served after its owner left the board: %d", w.Code) } if link := runReportLinkFor(ev); link != "" { t.Errorf("dispatch still offers a link to an opted-out player's run: %q", link) } } // TestUnattributedRunHasNoReport. A run whose `start` beat never arrived still // gets a readable log — that is deliberate, and W2a pinned it. But it has no // token, so Pete cannot establish whose run it is, and "don't know" is not a // basis on which to publish where somebody went. func TestUnattributedRunHasNoReport(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) now := time.Now().Unix() onBoard(t, s, token, "tok-abc", "Josie") postBeats(t, s, token, storage.RunBeat{RunID: "orphan", Seq: 2, Kind: "combat", OccurredAt: now, Room: 2, TotalRooms: 9, Target: "Bone Chanter", Outcome: "won"}, storage.RunBeat{RunID: "orphan", Seq: 3, Kind: "end", OccurredAt: now + 5, Outcome: "cleared"}, ) if w := getReport(t, s, "orphan"); w.Code != 404 { t.Errorf("served a report for a run with no owner: %d", w.Code) } } // TestRunSummaryIsGuardedLikeADispatch. The summary is the only prose on the // beat channel and it is LLM output over player-chosen names, so the field // checks that make a *fact* safe are worth nothing here — the words are the // thing being rendered. A summary that names a different adventurer on the board // is either a hallucination or an injection, and both get the same answer: keep // the report, drop the prose. func TestRunSummaryIsGuardedLikeADispatch(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) now := time.Now().Unix() if w := postRoster(t, s, token, rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{ entry("tok-abc", "Josie", "idle", ""), entry("tok-def", "Quack", "idle", ""), }}); w.Code != 200 { t.Fatalf("roster push failed: %d", w.Code) } postBeats(t, s, token, aFinishedRun(now)...) // Names a bystander who was never on this expedition. if w := postBeats(t, s, token, storage.RunBeat{ RunID: "run-1", Seq: 7, Kind: "summary", OccurredAt: now + 400, Name: "Josie", Prose: "Josie and Quack went down into the crypt together and only one came back.", }); w.Code != 200 { t.Fatalf("a rejected summary should still be a 200: %d %s", w.Code, w.Body.String()) } run, _, err := storage.RunByID("run-1") if err != nil { t.Fatal(err) } if run.Summary != "" { t.Errorf("guard let through a summary naming a bystander: %q", run.Summary) } // The same beat, about the right person only. The seq differs because the // rejected row is still stored — that is what stops gogobee re-authoring it // forever — so a retry has to be a new beat. good := "Josie took a bad trap on the way in and finished the boss on a quarter of her health." if w := postBeats(t, s, token, storage.RunBeat{ RunID: "run-1", Seq: 8, Kind: "summary", OccurredAt: now + 401, Name: "Josie", Prose: good, }); w.Code != 200 { t.Fatalf("summary rejected: %d %s", w.Code, w.Body.String()) } run, _, err = storage.RunByID("run-1") if err != nil { t.Fatal(err) } if run.Summary != good { t.Errorf("summary = %q, want it stored", run.Summary) } // And it renders on the report, above the log rather than inside it. w := getReport(t, s, "run-1") if !strings.Contains(w.Body.String(), good) { t.Error("the summary didn't reach the report page") } v := runLogFor("tok-abc") for _, ln := range v.Lines { if strings.Contains(ln.Text, "bad trap on the way in") { t.Errorf("the summary rendered as a log line: %q", ln.Text) } } } // TestOnlyTheSummaryBeatCarriesProse. The guard at ingest only inspects the kind // it knows about, so any other kind arriving with prose would put unguarded text // onto the header. Both halves have to hold — the beat must be scrubbed, and the // header fold must ignore it even if it weren't. func TestOnlyTheSummaryBeatCarriesProse(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) now := time.Now().Unix() onBoard(t, s, token, "tok-abc", "Josie") postBeats(t, s, token, startBeat(now), storage.RunBeat{ RunID: "run-1", Seq: 2, Kind: "combat", OccurredAt: now + 10, Target: "Bone Chanter", Outcome: "won", Prose: "and then Quack showed up out of nowhere", }) run, _, err := storage.RunByID("run-1") if err != nil { t.Fatal(err) } if run.Summary != "" { t.Errorf("a combat beat wrote the run summary: %q", run.Summary) } beats, err := storage.RunBeats("run-1", 0) if err != nil { t.Fatal(err) } for _, b := range beats { if b.Prose != "" { t.Errorf("beat %d (%s) kept prose it isn't allowed to carry: %q", b.Seq, b.Kind, b.Prose) } } } // TestFinishedRunOffersItsReport / a live one doesn't. While a run is still // walking, the column on the adventurer page IS the report; a link to a second // copy of what somebody is already reading is only a way to lose them. func TestFinishedRunOffersItsReport(t *testing.T) { const token = "tok" s, _ := newAdvServer(t, token) now := time.Now().Unix() onBoard(t, s, token, "tok-abc", "Josie") postBeats(t, s, token, startBeat(now)) if v := runLogFor("tok-abc"); v.ReportURL != "" { t.Errorf("a live run offered a report link: %q", v.ReportURL) } postBeats(t, s, token, storage.RunBeat{ RunID: "run-1", Seq: 9, Kind: "end", OccurredAt: now + 60, Outcome: "cleared"}) if v := runLogFor("tok-abc"); v.ReportURL != "/adventure/run/run-1" { t.Errorf("ReportURL = %q, want the report path once the run is over", v.ReportURL) } } // TestRunElapsedFallsBackToTheBeats. started_at comes off the `start` beat, so a // run that lost it has a zero clock on the header and would otherwise report no // duration at all — on precisely the run where the log is the only record there is. func TestRunElapsedFallsBackToTheBeats(t *testing.T) { now := time.Now().Unix() beats := []storage.RunBeat{ {RunID: "r", Seq: 2, Kind: "combat", OccurredAt: now, Target: "Rat", Outcome: "won"}, {RunID: "r", Seq: 3, Kind: "end", OccurredAt: now + 5400, Outcome: "cleared"}, } // No StartedAt: the beat that would have set it never arrived. got := runElapsed(storage.Run{RunID: "r", EndedAt: now + 5400}, beats) if got != "1h 30m" { t.Errorf("elapsed = %q, want 1h 30m off the beats", got) } }