Files
Pete/internal/web/siege_test.go
T
prosolis aac6c3e127 adventure: work the five review findings the last pass left open
The extract pre-check is gone. It read a snapshot up to two minutes behind and
still got the last word, so somebody who set out over Matrix during a lagging
roster push was told they weren't on an expedition for a run gogobee would
happily have ended. Same call abandon and leave already made: let it through and
let rejected_not_running be the answer.

The siege_join check stays, because whether a boss is camped outside town is
town-wide and runs on a day-or-longer clock, but it now reads one column through
SiegeIsCamped instead of loading every defender row and the whole history to
look at one flag.

The war-room history insert is OR REPLACE. boss_id is the primary key and it was
never settled whether gogobee means the siege instance or the boss type by it, so
a duplicate pair used to fail the transaction carrying the live boss and the
muster too and freeze the war room on the last good snapshot. A dropped history
row is the smaller failure; the open question is noted in the schema.

offersToUndo's guard didn't cover the case its comment claimed. A gogobee too old
to push seats sends a valid blob with no party key, which decodes to the same
empty slice as a solo run, and a party member got shown the button that throws
away everyone's day. That needs a new field, so whoDetail gains party_known and
the flag gates the empty-list branch alone; the branch that reads the viewer's
own seat is self-evidencing and keeps working against any sender. gogobee's half
is written up in adventure_party_known_flag.md.

And an empty offer list no longer claims "you're already out there", which Pete
can't actually know from a game box too old to push offers at all.
2026-07-24 22:45:26 -07:00

348 lines
14 KiB
Go

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)
}
}
// TestSiegeHistoryCollisionDoesNotFreezeTheWarRoom. boss_id is the history's
// primary key and it is not settled whether gogobee means the siege instance or
// the boss type by it, so two rows can arrive sharing one. Under a bare INSERT
// that failed the transaction carrying the live boss and the muster too, and the
// war room stopped moving on the last good snapshot with nothing to say why. The
// ingest has to degrade to a lost history row instead.
func TestSiegeHistoryCollisionDoesNotFreezeTheWarRoom(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
push := liveSiege(now, 650)
push.Siege.History = []storage.SiegePast{
{BossID: 3, BossName: "The Ashen Wyrm", Tier: 5, Outcome: "survived",
HPRemaining: 300, HPMax: 1200, Defenders: 3, EndedAt: now - 86400},
{BossID: 3, BossName: "The Ashen Wyrm", Tier: 5, Outcome: "defeated",
HPRemaining: 0, HPMax: 1200, Defenders: 6, EndedAt: now - 30*86400},
}
if w := postSiege(t, s, "tok", push); w.Code != 200 {
t.Fatalf("push with a duplicated boss_id = %d, want 200 (%s)", w.Code, w.Body.String())
}
v := s.siege()
if !v.Active || v.HPCurrent != 650 {
t.Fatalf("war room = active %v at %d hp, want the pushed live boss — the collision took the whole push down",
v.Active, v.HPCurrent)
}
if len(v.History) != 1 {
t.Errorf("history = %d rows, want 1 (the last of the colliding pair)", len(v.History))
}
}
// 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")
}
}