package web import ( "bytes" "encoding/json" "net/http/httptest" "testing" "time" "pete/internal/storage" ) func postRealm(t *testing.T, s *Server, token string, push realmPush) *httptest.ResponseRecorder { t.Helper() body, _ := json.Marshal(push) req := httptest.NewRequest("POST", "/api/ingest/realm", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() s.handleRealmIngest(w, req) return w } func zone(id, display string, tier, clears, clearers int) storage.RealmZone { return storage.RealmZone{ ID: id, Display: display, Tier: tier, LevelMin: tier * 3, LevelMax: tier*3 + 3, Clears: clears, Clearers: clearers, } } // TestRealmReplacesNeverMerges is the realm's core contract and it is the same // one the board and the war room have: gogobee sends the whole thing, Pete's // copy becomes it. Everything on these pages is a *derived* answer recomputed // upstream — a clear count, a first-clearer, who is inside — so a merge would // let a correction upstream leave a wrong number here permanently, and an // occupant who came home would never leave the map. func TestRealmReplacesNeverMerges(t *testing.T) { s, _ := newAdvServer(t, "tok") now := time.Now().Unix() first := zone("warren", "Goblin Warren", 1, 4, 2) first.Occupants = []storage.RealmOccupant{{Token: "t1", Name: "Josie", Level: 9, Day: 2}} if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{ Zones: []storage.RealmZone{first, zone("vault", "Sunken Vault", 2, 0, 0)}, Standings: []storage.RealmStanding{{Token: "t1", Name: "Josie", Level: 9, Clears: 4}}, Firsts: []storage.RealmFirst{{Kind: "zone", Target: "warren", Display: "Goblin Warren", AtUnix: now - 86400}}, }}); w.Code != 200 { t.Fatalf("first push = %d, want 200", w.Code) } // Josie comes home, the Vault gets beaten, and the second zone drops out of // the push entirely (say it was retired upstream). if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now + 600, Realm: storage.Realm{ Zones: []storage.RealmZone{zone("warren", "Goblin Warren", 1, 5, 2)}, Standings: []storage.RealmStanding{{Token: "t1", Name: "Josie", Level: 10, Clears: 5}}, }}); w.Code != 200 { t.Fatalf("second push = %d, want 200", w.Code) } v := s.realm() if v.ZoneCount != 1 { t.Fatalf("zone count = %d, want 1 — a dropped zone survived the swap", v.ZoneCount) } if v.OutThere != 0 { t.Errorf("out-there = %d, want 0 — an occupant who came home is still on the map", v.OutThere) } if got := v.Tiers[0].Zones[0].Clears; got != 5 { t.Errorf("clears = %d, want 5 — the count didn't follow the snapshot", got) } if fv := s.firsts(); fv.Total != 0 { t.Errorf("firsts total = %d, want 0 — the ledger didn't follow the snapshot", fv.Total) } if sv := s.standings(); len(sv.Rows) != 1 || sv.Rows[0].Level != 10 { t.Errorf("standings didn't follow the snapshot: %+v", sv.Rows) } } // TestAnonymisedFirstClearIsNotAnUnbeatenZone is the one that matters most on // this page. // // gogobee anonymises an opted-out first-clearer rather than deleting the claim: // the zone HAS been beaten and the clear counts still add up, there is just no // name on it. If Pete keyed the ominous never-beaten styling off "is there a // name" instead of off "are there any clears", an opt-out would silently rewrite // the history of the realm — a place somebody conquered would be drawn as a // place nobody has ever come out of. func TestAnonymisedFirstClearIsNotAnUnbeatenZone(t *testing.T) { s, _ := newAdvServer(t, "tok") now := time.Now().Unix() beaten := zone("vault", "Sunken Vault", 2, 3, 1) // cleared, but no name on it beaten.FirstClearAt = now - 86400 untouched := zone("abyss", "Abyss Portal", 5, 0, 0) named := zone("warren", "Goblin Warren", 1, 2, 1) named.FirstClearBy, named.FirstClearToken = "Josie", "t1" if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{ Zones: []storage.RealmZone{named, beaten, untouched}, }}); w.Code != 200 { t.Fatalf("push = %d, want 200", w.Code) } byID := map[string]RealmZoneView{} for _, tier := range s.realm().Tiers { for _, z := range tier.Zones { byID[z.ID] = z } } if byID["vault"].Unbeaten { t.Error("an anonymised clear drew as never-beaten — an opt-out rewrote the realm's history") } if !byID["vault"].Cleared { t.Error("a zone with clears > 0 did not read as cleared") } if !byID["abyss"].Unbeaten { t.Error("a zone with no clears at all did not read as unbeaten — the ominous state is the point") } if byID["warren"].Unbeaten { t.Error("a named clear drew as never-beaten") } v := s.realm() if v.ClearedZones != 2 || v.Unbeaten != 1 { t.Errorf("header totals = %d cleared / %d unbeaten, want 2/1", v.ClearedZones, v.Unbeaten) } } // TestRealmStaleWhenTheWireGoesQuiet. The realm is pushed every ten minutes // rather than every two, so its staleness window is proportionally wider — but // it still has to exist. An occupant list that stopped updating an hour ago must // not keep claiming somebody is standing in a dungeon. func TestRealmStaleWhenTheWireGoesQuiet(t *testing.T) { s, _ := newAdvServer(t, "tok") old := time.Now().Add(-2 * time.Hour).Unix() if w := postRealm(t, s, "tok", realmPush{SnapshotAt: old, Realm: storage.Realm{ Zones: []storage.RealmZone{zone("warren", "Goblin Warren", 1, 1, 1)}, }}); w.Code != 200 { t.Fatalf("push = %d, want 200", w.Code) } v := s.realm() if !v.Known { t.Fatal("a pushed realm reads as never-pushed") } if !v.Stale { t.Error("a two-hour-old realm claims to be live") } // All three pages share one snapshot read, so they must agree about its age. if !s.standings().Stale || !s.firsts().Stale { t.Error("the three realm pages disagree about how old the realm is") } } // TestUnpushedRealmIsNotAnEmptyRealm. "gogobee has never pushed" and "gogobee // pushed a realm with nothing in it" are different states and the pages say // different things about them — the first is Pete admitting he has no survey, // the second is a real answer about a quiet realm. func TestUnpushedRealmIsNotAnEmptyRealm(t *testing.T) { s, _ := newAdvServer(t, "tok") if v := s.realm(); v.Known { t.Error("an unpushed realm claims to be known") } if v := s.standings(); v.Known { t.Error("unpushed standings claim to be known") } if w := postRealm(t, s, "tok", realmPush{SnapshotAt: time.Now().Unix(), Realm: storage.Realm{}}); w.Code != 200 { t.Fatalf("empty push = %d, want 200", w.Code) } v := s.realm() if !v.Known { t.Error("an empty-but-pushed realm reads as never-pushed") } if v.ZoneCount != 0 { t.Errorf("zone count = %d, want 0", v.ZoneCount) } } // TestRealmIngestRejectsNamelessRows. A nameless row renders as a blank line on // a public page. gogobee already refuses to send one (it skips a character with // no name rather than falling back to a Matrix handle), so this is the wire // refusing to be the thing that puts a hole in the page. func TestRealmIngestRejectsNamelessRows(t *testing.T) { s, _ := newAdvServer(t, "tok") now := time.Now().Unix() nameless := zone("warren", "Goblin Warren", 1, 1, 1) nameless.Occupants = []storage.RealmOccupant{{Token: "t1", Name: ""}} if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{ Zones: []storage.RealmZone{nameless}, }}); w.Code != 400 { t.Errorf("nameless occupant = %d, want 400", w.Code) } if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{ Standings: []storage.RealmStanding{{Token: "t1", Name: ""}}, }}); w.Code != 400 { t.Errorf("nameless standing = %d, want 400", w.Code) } if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{ Zones: []storage.RealmZone{{ID: "", Display: "Nowhere"}}, }}); w.Code != 400 { t.Errorf("idless zone = %d, want 400", w.Code) } } // TestUnknownFirstKindStillGetsIntoTheHall. The ledger is open-ended: gogobee // claims a realm-first on (kind, target) and nothing stops a third kind shipping // later. A first Pete has no words for is still a thing that happened exactly // once, so it renders with a generic label and its raw target as its name — the // same degrade-don't-drop rule W0 settled on for an untemplated event_type. A // missing display is repaired at ingest rather than rejected. func TestUnknownFirstKindStillGetsIntoTheHall(t *testing.T) { s, _ := newAdvServer(t, "tok") now := time.Now().Unix() if w := postRealm(t, s, "tok", realmPush{SnapshotAt: now, Realm: storage.Realm{ Firsts: []storage.RealmFirst{ {Kind: "zone", Target: "warren", Display: "Goblin Warren", Holder: "Josie", Token: "t1", AtUnix: now - 86400}, {Kind: "hat", Target: "very_big_hat", AtUnix: now - 3600}, // no display: repaired, not rejected }, }}); w.Code != 200 { t.Fatalf("push = %d, want 200", w.Code) } v := s.firsts() if v.Total != 2 { t.Fatalf("hall has %d entries, want 2 — an unknown kind was dropped", v.Total) } var hat *RealmFirstView for i := range v.Years { for j := range v.Years[i].Firsts { if v.Years[i].Firsts[j].Kind == "hat" { hat = &v.Years[i].Firsts[j] } } } if hat == nil { t.Fatal("the unknown-kind first is not in any year") } if hat.Display != "very_big_hat" { t.Errorf("display = %q, want the raw target — a blank row is worse than an ugly one", hat.Display) } if hat.Label == "" { t.Error("an unknown kind got no label at all") } } // TestFirstsAreNewestYearFirstButChronologicalWithinAYear. A history book that // opens on the oldest page is an archive; this is meant to read as "look what // has been happening". Within a year it stays forward-ordered, the way a year // did. An undated entry sorts to the bottom — it is real, but it is not news. func TestFirstsAreNewestYearFirstButChronologicalWithinAYear(t *testing.T) { s, _ := newAdvServer(t, "tok") y2025 := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).Unix() y2026a := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC).Unix() y2026b := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC).Unix() // Pushed oldest-first, which is how gogobee sends it. if w := postRealm(t, s, "tok", realmPush{SnapshotAt: time.Now().Unix(), Realm: storage.Realm{ Firsts: []storage.RealmFirst{ {Kind: "zone", Target: "undated", Display: "Somewhere", AtUnix: 0}, {Kind: "zone", Target: "a", Display: "First Place", AtUnix: y2025}, {Kind: "zone", Target: "b", Display: "Second Place", AtUnix: y2026a}, {Kind: "zone", Target: "c", Display: "Third Place", AtUnix: y2026b}, }, }}); w.Code != 200 { t.Fatalf("push = %d, want 200", w.Code) } v := s.firsts() if len(v.Years) != 3 { t.Fatalf("got %d year groups, want 3 (2026, 2025, undated)", len(v.Years)) } if v.Years[0].Year != 2026 || v.Years[1].Year != 2025 || v.Years[2].Year != 0 { t.Fatalf("year order = %d, %d, %d; want 2026, 2025, 0", v.Years[0].Year, v.Years[1].Year, v.Years[2].Year) } if got := v.Years[0].Firsts; got[0].Display != "Second Place" || got[1].Display != "Third Place" { t.Errorf("within 2026 the order is %q then %q; want chronological", got[0].Display, got[1].Display) } } // TestStandingsKeepGogobeesRank. The ordering is a statement about what the game // values ("deepest tier beaten, then how much of the realm you have beaten") and // the game is entitled to make it. Pete renumbers nothing — a board that // re-sorted on a column Pete happened to find interesting would disagree with // the game about who is ahead. func TestStandingsKeepGogobeesRank(t *testing.T) { s, _ := newAdvServer(t, "tok") if w := postRealm(t, s, "tok", realmPush{SnapshotAt: time.Now().Unix(), Realm: storage.Realm{ Standings: []storage.RealmStanding{ {Token: "t1", Name: "Josie", Level: 14, DeepestTier: 5, Zones: 3, Clears: 9}, // Higher level and more clears, but shallower — and gogobee put them // second, so second is where they render. {Token: "t2", Name: "Quack", Level: 20, DeepestTier: 3, Zones: 8, Clears: 40}, }, }}); w.Code != 200 { t.Fatalf("push = %d, want 200", w.Code) } rows := s.standings().Rows if len(rows) != 2 { t.Fatalf("got %d rows, want 2", len(rows)) } if rows[0].Name != "Josie" || rows[0].Rank != 1 { t.Errorf("rank 1 = %q (rank field %d), want Josie/1 — Pete re-sorted the game's board", rows[0].Name, rows[0].Rank) } if rows[1].Rank != 2 { t.Errorf("second row has rank %d, want 2", rows[1].Rank) } } // TestPeteHasNoRecordUntilHeFilesOne. The pete_duel_win/loss templates have // existed in the renderer since before anything emitted them, so the record // reads zero-zero today. Zero-zero has to render as "no bouts yet" and not as a // 0% win rate, which would be a claim about bouts that never happened. func TestPeteHasNoRecordUntilHeFilesOne(t *testing.T) { s, _ := newAdvServer(t, "tok") if w := postRealm(t, s, "tok", realmPush{SnapshotAt: time.Now().Unix(), Realm: storage.Realm{}}); w.Code != 200 { t.Fatalf("push = %d, want 200", w.Code) } v := s.standings() if v.PeteFought { t.Error("Pete claims a duel record with no duel dispatches filed") } if v.PeteWins != 0 || v.PeteLosses != 0 { t.Errorf("record = %d-%d, want 0-0", v.PeteWins, v.PeteLosses) } } // TestLevelBandReadsLikeWords. "levels 5–5" reads as a typo and "levels 0–0" as // a bug; neither is a thing to print on a page about a place. func TestLevelBandReadsLikeWords(t *testing.T) { cases := []struct { min, max int want string }{ {5, 8, "levels 5–8"}, {5, 5, "level 5"}, {0, 0, ""}, {0, 4, "up to level 4"}, {18, 0, "level 18 and up"}, } for _, c := range cases { if got := levelBand(c.min, c.max); got != c.want { t.Errorf("levelBand(%d, %d) = %q, want %q", c.min, c.max, got, c.want) } } } // TestRealmIngestNeedsTheBearer. Same gate as every other ingest: the realm is // public to read and authenticated to write. func TestRealmIngestNeedsTheBearer(t *testing.T) { s, _ := newAdvServer(t, "tok") if w := postRealm(t, s, "wrong", realmPush{SnapshotAt: time.Now().Unix()}); w.Code != 401 { t.Errorf("bad bearer = %d, want 401", w.Code) } }