package web import ( "bytes" "encoding/json" "net/http/httptest" "strings" "testing" "time" "pete/internal/storage" ) func postSiege(t *testing.T, s *Server, token string, push siegePush) *httptest.ResponseRecorder { t.Helper() body, _ := json.Marshal(push) req := httptest.NewRequest("POST", "/api/ingest/siege", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() s.handleSiegeIngest(w, req) return w } func liveSiege(now int64, hpCurrent int, defenders ...storage.SiegeDefender) siegePush { return siegePush{SnapshotAt: now, Siege: storage.Siege{ Active: true, BossID: 7, BossName: "Gorloth the Sunderer", Tier: 4, HPCurrent: hpCurrent, HPMax: 1000, StartsAt: now - 3600, EndsAt: now + 68*3600, Defenders: defenders, }} } // TestSiegeReplacesNeverMerges is the war room's core contract, and it is the // same one the board has: gogobee sends the whole thing and Pete's copy becomes // it. A defender who dropped out of a later snapshot has to leave the board, and // a Siege that resolved has to stop showing a live bar. An upsert would leave // both standing forever. func TestSiegeReplacesNeverMerges(t *testing.T) { s, _ := newAdvServer(t, "tok") now := time.Now().Unix() if w := postSiege(t, s, "tok", liveSiege(now, 800, storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 2, Damage: 150, FoughtToday: true}, storage.SiegeDefender{Token: "t2", Name: "Quack", Fights: 1, Damage: 50}, )); w.Code != 200 { t.Fatalf("first push = %d, want 200", w.Code) } // Quack opts out; gogobee stops sending her under a name. if w := postSiege(t, s, "tok", liveSiege(now+120, 700, storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 3, Damage: 250, FoughtToday: true}, )); w.Code != 200 { t.Fatalf("second push = %d, want 200", w.Code) } v := s.siege() if got := len(v.Fought) + len(v.Waiting); got != 1 { t.Fatalf("muster has %d rows, want 1 — a dropped defender survived the swap", got) } if v.HPCurrent != 700 { t.Errorf("hp_current = %d, want 700 — the pool didn't follow the snapshot", v.HPCurrent) } // And a snapshot saying the Siege ended must clear the live bar entirely. if w := postSiege(t, s, "tok", siegePush{SnapshotAt: now + 240, Siege: storage.Siege{Active: false}}); w.Code != 200 { t.Fatalf("resolution push = %d, want 200", w.Code) } if v := s.siege(); v.Active { t.Error("war room still reads active after a snapshot said the Siege was over") } } // TestSiegeSplitsFoughtFromWaiting is the mechanic made visible. One bout per // person per day means a defender who hasn't swung today is damage the pool has // not seen — the page's whole nudge — so the split has to come off FoughtToday // and not off "has any fights at all". A veteran of nine bouts who hasn't been // out today belongs in the waiting column. func TestSiegeSplitsFoughtFromWaiting(t *testing.T) { s, _ := newAdvServer(t, "tok") now := time.Now().Unix() postSiege(t, s, "tok", liveSiege(now, 500, storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 9, Damage: 400, FoughtToday: false}, storage.SiegeDefender{Token: "t2", Name: "Quack", Fights: 1, Damage: 100, FoughtToday: true}, storage.SiegeDefender{Token: "t3", Name: "Newbie", Fights: 0, Damage: 0}, )) v := s.siege() if len(v.Fought) != 1 || v.Fought[0].Name != "Quack" { t.Errorf("fought-today = %+v, want just Quack", v.Fought) } if len(v.Waiting) != 2 { t.Fatalf("waiting = %d rows, want 2 (Josie has bouts but not today, Newbie has none)", len(v.Waiting)) } if v.Mustered != 2 { t.Errorf("mustered = %d, want 2 — that counts anyone who has ever fought, not today's turnout", v.Mustered) } } // TestSiegeAnonDefenderKeepsRankLosesLink is the opt-out rule, and it is // deliberately NOT the board's rule. The board omits an opted-out player // outright, because a row showing class + level + zone re-identifies them. Here // the damage is part of what the town did to the boss: dropping it would // understate the shared effort and stop the numbers adding up. So the row stays, // anonymous, with no token — and therefore no link to a page that would name them. func TestSiegeAnonDefenderKeepsRankLosesLink(t *testing.T) { s, _ := newAdvServer(t, "tok") now := time.Now().Unix() postSiege(t, s, "tok", liveSiege(now, 100, storage.SiegeDefender{Name: "an adventurer", Fights: 5, Damage: 700, FoughtToday: true}, storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 1, Damage: 200, FoughtToday: true}, )) v := s.siege() if len(v.Fought) != 2 { t.Fatalf("fought = %d rows, want 2 — the anonymous contributor was dropped", len(v.Fought)) } // Push order is gogobee's ranking and Pete must preserve it: the anonymous // defender out-damaged Josie and holds the top of the board. if v.Fought[0].Name != "an adventurer" { t.Errorf("top of the board is %q, want the anonymous defender — rank was lost", v.Fought[0].Name) } if v.Fought[0].Token != "" { t.Error("anonymous defender carries a token — that is a link back to a page that names them") } } // TestSiegeGoesStale: if gogobee stops talking, the bar must stop claiming to be // live. A health bar that confidently shows a pool level from an hour ago is // worse than one that admits it lost the wire, because the whole promise of the // page is that the number is true *right now*. func TestSiegeGoesStale(t *testing.T) { s, _ := newAdvServer(t, "tok") old := time.Now().Add(-30 * time.Minute).Unix() postSiege(t, s, "tok", liveSiege(old, 900)) v := s.siege() if !v.Stale { t.Error("a 30-minute-old snapshot reads as live") } if v.HPCurrent != 900 { t.Errorf("hp_current = %d, want 900 — a stale war room must still show the last known pool", v.HPCurrent) } } // TestSiegeNeverPushedIsNotAnEmptySiege distinguishes the two states that look // alike from the outside: gogobee has never told us about a Siege, versus it has // told us there isn't one. Only the second can honestly say "quiet month". func TestSiegeNeverPushedIsNotAnEmptySiege(t *testing.T) { s, _ := newAdvServer(t, "tok") if v := s.siege(); v.Known { t.Error("war room claims to know the Siege state before gogobee ever pushed one") } postSiege(t, s, "tok", siegePush{SnapshotAt: time.Now().Unix(), Siege: storage.Siege{Active: false}}) v := s.siege() if !v.Known { t.Error("war room still reads unknown after a snapshot said no Siege is camped") } if v.Active { t.Error("no-siege snapshot rendered as an active Siege") } } // TestSiegeRejectsUnrenderableSnapshots. Two things a public page cannot draw: // a defender with no name (a blank row), and an active Siege with no pool (a // divide-by-zero on the bar). Both are 400s — unlike an unknown *event type*, // which W0 deliberately made a 200, because that one is a styling gap where // these are malformed state. func TestSiegeRejectsUnrenderableSnapshots(t *testing.T) { s, _ := newAdvServer(t, "tok") now := time.Now().Unix() w := postSiege(t, s, "tok", liveSiege(now, 500, storage.SiegeDefender{Token: "t1", Fights: 1})) if w.Code != 400 { t.Errorf("nameless defender = %d, want 400", w.Code) } bad := liveSiege(now, 500) bad.HPMax = 0 if w := postSiege(t, s, "tok", bad); w.Code != 400 { t.Errorf("active siege with no pool = %d, want 400", w.Code) } req := httptest.NewRequest("POST", "/api/ingest/siege", strings.NewReader("{}")) req.Header.Set("Authorization", "Bearer wrong") rec := httptest.NewRecorder() s.handleSiegeIngest(rec, req) if rec.Code != 401 { t.Errorf("bad bearer = %d, want 401", rec.Code) } } // TestSiegeHistoryRendersEndedBar. The history is what makes the live bar mean // anything, so the numbers behind it have to survive the round trip: a won Siege // ended at zero (an empty track), a lost one shows what was still standing. func TestSiegeHistoryRendersEndedBar(t *testing.T) { s, _ := newAdvServer(t, "tok") now := time.Now().Unix() push := siegePush{SnapshotAt: now, Siege: storage.Siege{ Active: false, History: []storage.SiegePast{ {BossID: 2, BossName: "The Ashen Wyrm", Tier: 5, Outcome: "survived", HPRemaining: 300, HPMax: 1200, Defenders: 3, MVP: "Josie", MVPFights: 4, EndedAt: now - 86400}, {BossID: 1, BossName: "The Iron Colossus", Tier: 4, Outcome: "defeated", HPRemaining: 0, HPMax: 800, Defenders: 5, MVP: "Quack", MVPFights: 6, EndedAt: now - 172800}, }, }} if w := postSiege(t, s, "tok", push); w.Code != 200 { t.Fatalf("history push = %d, want 200", w.Code) } v := s.siege() if len(v.History) != 2 { t.Fatalf("history = %d rows, want 2", len(v.History)) } // Newest first: the Wyrm ended a day ago, the Colossus two. if v.History[0].BossName != "The Ashen Wyrm" { t.Errorf("history[0] = %q, want the most recent Siege first", v.History[0].BossName) } if v.History[0].Won { t.Error("a survived Siege reads as a win") } if v.History[0].HPPercent != 25 { t.Errorf("survived bar = %d%%, want 25 (300 of 1200 still standing)", v.History[0].HPPercent) } if !v.History[1].Won || v.History[1].HPPercent != 0 { t.Errorf("defeated Siege = won %v at %d%%, want won at 0%%", v.History[1].Won, v.History[1].HPPercent) } } // TestSiegeAPIFeedsTheBar. The bar only moves because this endpoint answers, so // the field names it emits are load-bearing: the page's JS reads hp_percent to // set the width and active to decide whether to keep polling at all. func TestSiegeAPIFeedsTheBar(t *testing.T) { s, _ := newAdvServer(t, "tok") now := time.Now().Unix() postSiege(t, s, "tok", liveSiege(now, 250, storage.SiegeDefender{Token: "t1", Name: "Josie", Fights: 1, Damage: 750, FoughtToday: true})) rec := httptest.NewRecorder() s.handleSiegeAPI(rec, httptest.NewRequest("GET", "/api/adventure/siege", nil)) if rec.Code != 200 { t.Fatalf("api = %d, want 200", rec.Code) } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatal(err) } if got["active"] != true { t.Error("api says the Siege isn't active") } if got["hp_percent"].(float64) != 25 { t.Errorf("hp_percent = %v, want 25 — the bar would draw at the wrong width", got["hp_percent"]) } if got["bouts_today"] == nil || got["mustered"].(float64) != 1 { t.Errorf("api dropped the turnout counters: %v", got) } } // TestSiegeTemplateExecutes renders all three states the page has to survive — // a live Siege, a quiet realm with history, and a realm that has never had one. // Template execution errors are silent in production (render logs and returns a // half-written body), so a parse-clean template that blows up on a nil field is // exactly the class of bug that reaches a visitor before it reaches a log. func TestSiegeTemplateExecutes(t *testing.T) { s, _ := newAdvServer(t, "tok") now := time.Now().Unix() render := func(v SiegeView) string { t.Helper() var b strings.Builder if err := s.tpls["siege"].ExecuteTemplate(&b, "layout", siegePage{pageData: pageData{SiteTitle: "Pete", Channels: channels}, Siege: v}); err != nil { t.Fatal(err) } return b.String() } postSiege(t, s, "tok", liveSiege(now, 250, storage.SiegeDefender{Token: "t1", Name: "Josie", Level: 12, Fights: 3, Damage: 600, FoughtToday: true}, storage.SiegeDefender{Name: "an adventurer", Fights: 1, Damage: 150}, storage.SiegeDefender{Token: "t3", Name: "Camcast", Level: 4}, )) live := render(s.siege()) if !strings.Contains(live, "Gorloth the Sunderer") { t.Error("live page doesn't name the boss") } if !strings.Contains(live, `style="width: 25%"`) { t.Error("live page didn't draw the bar at the pool's width") } if !strings.Contains(live, `/adventure/who/t1`) { t.Error("live page doesn't link a named defender to their page") } if strings.Contains(live, `/adventure/who/"`) { t.Error("live page emitted an empty who link — the anonymous defender got a link anyway") } // Quiet realm with history, and a realm that has never seen one. quiet := render(SiegeView{Known: true, History: []SiegePastView{{ SiegePast: storage.SiegePast{BossName: "The Iron Colossus", Tier: 4, Outcome: "defeated", HPMax: 800, Defenders: 5, MVP: "Quack", MVPFights: 6}, Won: true, When: "Jun 1, 2026"}}}) if !strings.Contains(quiet, "Nothing's camped outside town") || !strings.Contains(quiet, "The Iron Colossus") { t.Error("quiet page lost either the empty state or the history") } if fresh := render(SiegeView{}); !strings.Contains(fresh, "haven't heard from the field") { t.Error("never-pushed page doesn't say it hasn't heard from the field") } }