Files
Pete/internal/web/roster_test.go
prosolis 99574db3e9 news: the adventure page gets something that's actually happening
Every dispatch Pete publishes is an accomplishment — a death, a clear, a
milestone — and an accomplishment is a newspaper clipping the moment it lands.
No refresh interval fixes that. So the page never felt alive, and it never was
going to.

The board is the other kind of thing: state that is currently true. gogobee
pushes the whole roster, we replace ours with it, and it renders above the
clippings. An open tab re-polls so it keeps telling the truth.

Replace, never merge: anyone gogobee omits (opted out, no character) drops off
the public page. That omission IS the opt-out — a standing row showing class,
level and zone names the player anyway, so "an adventurer" would have been a fig
leaf.

The snapshot time lives in its own row, because an empty board is ambiguous:
nobody playing, or gogobee stopped talking to us. The page has to tell those
apart — one is a quiet realm, the other is a board that lies confidently, which
is worse than one that admits it lost the wire.

Also teaches Pete "departure", so a bored adventurer letting itself out is news.
2026-07-13 18:05:38 -07:00

194 lines
6.7 KiB
Go

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)
}
}
}