package web import ( "bytes" "encoding/json" "net/http/httptest" "strings" "testing" "time" "pete/internal/storage" ) func postRoster(t *testing.T, s *Server, token string, push rosterPush) *httptest.ResponseRecorder { t.Helper() body, _ := json.Marshal(push) req := httptest.NewRequest("POST", "/api/ingest/roster", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() s.handleRosterIngest(w, req) return w } func entry(token, name, status, zone string) storage.RosterEntry { return storage.RosterEntry{Token: token, Name: name, Level: 5, ClassRace: "elf ranger", Status: status, Zone: zone} } // TestRosterReplacesNeverMerges is the whole contract of the board. gogobee // sends the *complete* set of adventurers it is willing to show; anyone missing // from a later snapshot has left the board — they deleted their character, or // (the case that actually matters) they just ran `!news optout` and must // disappear from a public page. An upsert would leave them standing there // forever, which is precisely the exposure the opt-out exists to prevent. func TestRosterReplacesNeverMerges(t *testing.T) { s, _ := newAdvServer(t, "tok") now := time.Now().Unix() if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{ entry("t1", "Josie", "expedition", "holymachina"), entry("t2", "Quack", "idle", ""), }}); w.Code != 200 { t.Fatalf("first push = %d, want 200", w.Code) } // Quack opts out; gogobee stops including her. if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now + 60, Adventurers: []storage.RosterEntry{ entry("t1", "Josie", "expedition", "holymachina"), }}); w.Code != 200 { t.Fatalf("second push = %d, want 200", w.Code) } views, stale, _ := s.roster() if stale { t.Error("fresh snapshot read as stale") } if len(views) != 1 { t.Fatalf("board has %d rows, want 1 — a dropped adventurer survived the swap", len(views)) } if views[0].Name != "Josie" { t.Errorf("board kept %q, want Josie", views[0].Name) } } // TestRosterGoesStale: if gogobee dies mid-expedition, the last snapshot says // "Josie is in holymachina" and would say so forever. The board has to admit it // lost the wire rather than keep asserting a stale fact as live. func TestRosterGoesStale(t *testing.T) { s, _ := newAdvServer(t, "tok") old := time.Now().Add(-2 * rosterStaleAfter).Unix() postRoster(t, s, "tok", rosterPush{SnapshotAt: old, Adventurers: []storage.RosterEntry{ entry("t1", "Josie", "expedition", "holymachina"), }}) views, stale, _ := s.roster() if !stale { t.Error("snapshot older than rosterStaleAfter still reported live") } // Stale, but still shown: "who was out when we lost contact" beats a blank page. if len(views) != 1 { t.Errorf("stale board dropped its rows (%d), want them kept and dimmed", len(views)) } } // TestRosterEmptySnapshotIsNotStale: a realm where nobody is playing is a // legitimate state and must not be confused with gogobee having gone away. This // is why the snapshot time lives in its own row instead of MAX() over entries. func TestRosterEmptySnapshotIsNotStale(t *testing.T) { s, _ := newAdvServer(t, "tok") if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: time.Now().Unix()}); w.Code != 200 { t.Fatalf("empty push = %d, want 200", w.Code) } views, stale, _ := s.roster() if len(views) != 0 { t.Errorf("empty snapshot produced %d rows", len(views)) } if stale { t.Error("a quiet realm read as a dead wire — the two must not collapse") } } func TestRosterIngestRejects(t *testing.T) { s, _ := newAdvServer(t, "tok") now := time.Now().Unix() if w := postRoster(t, s, "wrong", rosterPush{SnapshotAt: now}); w.Code != 401 { t.Errorf("bad bearer = %d, want 401", w.Code) } // An unknown status would render as neither "out there" nor "in town". bad := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{ {Token: "t1", Name: "Josie", Status: "vibing"}, }} if w := postRoster(t, s, "tok", bad); w.Code != 400 { t.Errorf("unknown status = %d, want 400", w.Code) } // A nameless row would render an empty slot on a public page. nameless := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{ {Token: "t1", Status: "idle"}, }} if w := postRoster(t, s, "tok", nameless); w.Code != 400 { t.Errorf("nameless adventurer = %d, want 400", w.Code) } } // TestDepartureRenders guards the cross-repo contract: gogobee emits this // event_type for a bored adventurer, and an event_type Pete doesn't know is a // 400 that retries and parks the bulletin forever. func TestDepartureRenders(t *testing.T) { headline, lede, ok := renderAdventure(AdvFact{ EventType: "departure", Subject: "Camcast", Zone: "crypt_valdris", Level: 1, Actors: []string{"Camcast"}, }) if !ok { t.Fatal("departure event_type unknown to Pete — gogobee's bulletin would 400 and park") } if headline == "" || lede == "" { t.Error("departure rendered empty") } } func TestRosterViewWhere(t *testing.T) { out := toRosterView(storage.RosterEntry{ Name: "Josie", Status: "expedition", Zone: "holymachina", Region: "the deep", Day: 3, }) if !out.OnRun || out.Where != "holymachina, the deep — day 3" { t.Errorf("expedition row = %+v", out) } in := toRosterView(storage.RosterEntry{Name: "Quack", Status: "idle", IdleHours: 50}) if in.OnRun || in.Where != "in town" || in.Idle != "quiet for 2 days" { t.Errorf("idle row = %+v", in) } } // TestAdventurePageRendersBoard drives the real page through the real template. // The handler tests above prove the data is right; this proves it reaches a // reader. A template slip (bad field, unclosed block) doesn't fail a build — it // 500s the whole adventure section at request time, board and clippings alike. func TestAdventurePageRendersBoard(t *testing.T) { s, _ := newAdvServer(t, "tok") postRoster(t, s, "tok", rosterPush{ SnapshotAt: time.Now().Unix(), Adventurers: []storage.RosterEntry{ {Token: "t1", Name: "Josie", Level: 14, ClassRace: "human cleric", Status: "expedition", Zone: "holymachina", Day: 3}, {Token: "t2", Name: "Quack", Level: 4, ClassRace: "elf ranger", Status: "idle", IdleHours: 50}, }, }) req := httptest.NewRequest("GET", "/adventure", nil) w := httptest.NewRecorder() s.handleChannel(w, req, Channel{Slug: "adventure", Title: "Adventure", Theme: "adventure"}) if w.Code != 200 { t.Fatalf("GET /adventure = %d, want 200", w.Code) } body := w.Body.String() for _, want := range []string{ "Out there right now", // the board's headline "Josie", "holymachina", "day 3", // live zone, shown while she's still in it "Quack", "in town", "quiet for 2 days", "/api/roster", // the client re-poll, so an open tab stays true } { if !strings.Contains(body, want) { t.Errorf("adventure page missing %q", want) } } }