Files
Pete/internal/web/orders_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

429 lines
17 KiB
Go

package web
import (
"bytes"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
"pete/internal/storage"
)
// W5: the action queue's web seam. Two contracts, same shape as the equip queue's
// tests — the owner half must be unable to act for anybody but itself, and the
// gogobee half is a bearer-authed, idempotent pending/verdict pair.
// seedActions stands up a board and a private detail row owned by `owner`, which
// together are gogobee's proof that this account has an adventurer. `status` is
// the roster status the mark carries ("expedition" or "idle"), because the
// extract pre-check reads it.
func seedActions(t *testing.T, owner, status string) *Server {
t.Helper()
s, _ := newAdvServer(t, "tok")
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
now := time.Now().Unix()
e := entry("tok-josie", "Josie", status, "holymachina")
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
t.Fatalf("seed roster = %d", w.Code)
}
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
Localpart: owner, Token: "tok-josie",
}}}); w.Code != 200 {
t.Fatalf("seed detail = %d", w.Code)
}
return s
}
func placeAction(t *testing.T, s *Server, username, action string) *httptest.ResponseRecorder {
t.Helper()
r := as(t, s, username, "POST", "/api/adventure/order", advOrderReq{Action: action})
w := httptest.NewRecorder()
s.handleAdvOrder(w, r)
return w
}
// TestActionOrderNamesNoCharacter is the reason this seam has a smaller attack
// surface than the equip queue's: nothing in the request identifies an
// adventurer, so there is no id to forge. The order that lands must be attributed
// to the session's own localpart and its own token, whatever the body said.
func TestActionOrderNamesNoCharacter(t *testing.T) {
s := seedActions(t, "holymachina", "expedition")
// A body carrying extra fields — a token, a localpart — must change nothing:
// the handler reads only Action off it.
r := as(t, s, "holymachina", "POST", "/api/adventure/order", map[string]any{
"action": "extract", "token": "tok-somebody-else", "owner_localpart": "someone",
})
w := httptest.NewRecorder()
s.handleAdvOrder(w, r)
if w.Code != 200 {
t.Fatalf("order = %d (%s)", w.Code, w.Body.String())
}
var got storage.AdvOrder
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.OwnerLocalpart != "holymachina" {
t.Fatalf("owner = %q, want the session's localpart", got.OwnerLocalpart)
}
if got.Token != "tok-josie" {
t.Fatalf("token = %q, want the token resolved from the session, not the body", got.Token)
}
if got.Status != storage.AdvOrderPending {
t.Fatalf("status = %q, want pending — Pete never claims an action landed", got.Status)
}
}
// TestActionOrderNeedsAnAdventurer: a signed-in visitor with no self-detail row
// has no adventurer for gogobee to act on. Queuing the order anyway would file
// something gogobee can only answer with a rejection.
func TestActionOrderNeedsAnAdventurer(t *testing.T) {
s, _ := newAdvServer(t, "tok")
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
if w := placeAction(t, s, "stranger", "extract"); w.Code != 403 {
t.Fatalf("order without an adventurer = %d, want 403", w.Code)
}
}
// TestOnlyOneOutstandingOrderPerVerb. Two queued extracts apply in sequence and
// the second answers "you weren't on an expedition" — a rejection for something
// that worked, which is the worst thing the strip could say. The guard is per
// verb, so a pending extract must not block a Siege bout.
func TestOnlyOneOutstandingOrderPerVerb(t *testing.T) {
s := seedActions(t, "holymachina", "expedition")
postSiege(t, s, "tok", liveSiege(time.Now().Unix(), 800))
if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 {
t.Fatalf("first extract = %d (%s)", w.Code, w.Body.String())
}
w := placeAction(t, s, "holymachina", "extract")
if w.Code != 409 {
t.Fatalf("second extract = %d, want 409", w.Code)
}
if w := placeAction(t, s, "holymachina", "siege_join"); w.Code != 200 {
t.Fatalf("bout blocked by a pending extract = %d (%s); the guard is per verb", w.Code, w.Body.String())
}
}
// TestOnlyTownWideFactsArePreChecked. Pete's copy of the board is up to two
// minutes behind the game box, so what it may refuse locally turns on whether
// being two minutes late could make the answer wrong. A personal status can:
// somebody who set out over Matrix still reads as idle here, and refusing their
// extract would deny a run gogobee would have ended. A boss camped outside town
// cannot: that is town-wide and runs on a day-or-longer clock.
func TestOnlyTownWideFactsArePreChecked(t *testing.T) {
// Idle mark: extract goes through anyway, and rejected_not_running is the
// answer if the mark really was standing in town.
s := seedActions(t, "holymachina", "idle")
if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 {
t.Fatalf("extract while idle = %d, want it queued (%s)", w.Code, w.Body.String())
}
// No Siege pushed at all: unknown, not "inactive". Pete has never heard from
// gogobee about a boss, and refusing on that would make the button dead on a
// fresh deploy. It must go through and let gogobee answer.
if w := placeAction(t, s, "holymachina", "siege_join"); w.Code != 200 {
t.Fatalf("bout with no siege snapshot at all = %d, want it queued", w.Code)
}
// A snapshot that positively says no boss is camped: refuse.
s2 := seedActions(t, "holymachina", "idle")
now := time.Now().Unix()
postSiege(t, s2, "tok", siegePush{SnapshotAt: now, Siege: storage.Siege{Active: false}})
if w := placeAction(t, s2, "holymachina", "siege_join"); w.Code != 409 {
t.Fatalf("bout with no boss camped = %d, want 409", w.Code)
}
}
func TestActionOrderRejectsAnUnknownVerb(t *testing.T) {
s := seedActions(t, "holymachina", "expedition")
if w := placeAction(t, s, "holymachina", "sell_house"); w.Code != 400 {
t.Fatalf("unknown action = %d, want 400", w.Code)
}
}
// TestActionOrdersAreScopedToTheirOwner: the strip is read back by OIDC subject.
// `as` signs every session as sub-1, so this drives the storage layer directly to
// prove the scoping rather than pretending two sessions exist.
func TestActionOrdersAreScopedToTheirOwner(t *testing.T) {
s := seedActions(t, "holymachina", "expedition")
if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 {
t.Fatalf("place = %d", w.Code)
}
if _, err := storage.InsertAdvOrder("sub-2", "someone", "tok-other", "Other", storage.AdvActionExtract, nil); err != nil {
t.Fatalf("insert other: %v", err)
}
r := as(t, s, "holymachina", "GET", "/api/adventure/orders", nil)
w := httptest.NewRecorder()
s.handleAdvOrders(w, r)
var got []storage.AdvOrder
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got) != 1 || got[0].OwnerLocalpart != "holymachina" {
t.Fatalf("orders = %+v, want only the signed-in owner's", got)
}
}
// TestActionVerdictIsIdempotent: gogobee's poll loop retries, so the same verdict
// arrives more than once and only the first may move the order. A second verdict
// overwriting the first would let a re-offer's "no expedition to leave" replace
// the "done" that was true.
func TestActionVerdictIsIdempotent(t *testing.T) {
s := seedActions(t, "holymachina", "expedition")
w := placeAction(t, s, "holymachina", "extract")
var order storage.AdvOrder
_ = json.Unmarshal(w.Body.Bytes(), &order)
first := postVerdict(t, s, "tok", advOrderVerdict{
GUID: order.GUID, Status: storage.AdvOrderApplied, Detail: "Out on day 3.",
})
if first.Code != 200 {
t.Fatalf("verdict = %d (%s)", first.Code, first.Body.String())
}
second := postVerdict(t, s, "tok", advOrderVerdict{
GUID: order.GUID, Status: storage.AdvRejectedNotRunning, Detail: "no run",
})
if second.Code != 200 {
t.Fatalf("retried verdict = %d, want a quiet 200", second.Code)
}
got, err := storage.AdvOrderByGUID(order.GUID)
if err != nil {
t.Fatalf("read back: %v", err)
}
if got.Status != storage.AdvOrderApplied || !strings.Contains(got.Detail, "day 3") {
t.Fatalf("order = %q/%q, want the first verdict to stand", got.Status, got.Detail)
}
}
// TestActionWireNeedsTheBearerToken: the poll and the verdict are gogobee's, and
// the pending list names every player who has asked for something.
func TestActionWireNeedsTheBearerToken(t *testing.T) {
s := seedActions(t, "holymachina", "expedition")
placeAction(t, s, "holymachina", "extract")
req := httptest.NewRequest("GET", "/api/adventure/orders/pending", nil)
w := httptest.NewRecorder()
s.handleAdvOrdersPending(w, req)
if w.Code != 401 {
t.Fatalf("unauthed poll = %d, want 401", w.Code)
}
req = httptest.NewRequest("GET", "/api/adventure/orders/pending", nil)
req.Header.Set("Authorization", "Bearer tok")
w = httptest.NewRecorder()
s.handleAdvOrdersPending(w, req)
if w.Code != 200 {
t.Fatalf("authed poll = %d", w.Code)
}
var pending []storage.AdvOrder
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
t.Fatalf("decode: %v", err)
}
if len(pending) != 1 || pending[0].Action != storage.AdvActionExtract {
t.Fatalf("pending = %+v, want the one queued extract", pending)
}
}
// TestVerdictForAnUnknownOrderIs400: under this seam's contract that parks the
// row for a human rather than retrying forever against a row that can never
// exist.
func TestVerdictForAnUnknownOrderIs400(t *testing.T) {
s := seedActions(t, "holymachina", "expedition")
if w := postVerdict(t, s, "tok", advOrderVerdict{GUID: "nope", Status: storage.AdvOrderApplied}); w.Code != 400 {
t.Fatalf("verdict for an unknown guid = %d, want 400", w.Code)
}
}
func postVerdict(t *testing.T, s *Server, token string, v advOrderVerdict) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(v)
req := httptest.NewRequest("POST", "/api/adventure/orders/verdict", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleAdvOrderVerdict(w, req)
return w
}
// ── W5b: the three verbs that take arguments ─────────────────────────────────
// seedOffers is seedActions with an offer list on the private detail row — which
// is what gogobee pushes, and what every W5b param is resolved against.
func seedOffers(t *testing.T, owner, status string, pd storage.PlayerDetail) *Server {
t.Helper()
s, _ := newAdvServer(t, "tok")
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
now := time.Now().Unix()
e := entry("tok-josie", "Josie", status, owner)
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
t.Fatalf("seed roster = %d", w.Code)
}
pd.Localpart = owner
pd.Token = "tok-josie"
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{pd}}); w.Code != 200 {
t.Fatalf("seed detail = %d", w.Code)
}
return s
}
func offeredZones() []storage.ZoneOffer {
return []storage.ZoneOffer{{
ID: "goblin_warrens", Display: "Goblin Warrens", Tier: 1,
Loadouts: []storage.LoadoutOffer{
{Key: "lean", Name: "lean", Cost: 40, Days: 3},
{Key: "balanced", Name: "balanced", Cost: 80, Days: 5},
},
}}
}
func placeParams(t *testing.T, s *Server, username string, req advOrderReq) *httptest.ResponseRecorder {
t.Helper()
r := as(t, s, username, "POST", "/api/adventure/order", req)
w := httptest.NewRecorder()
s.handleAdvOrder(w, r)
return w
}
// The whole point of resolving params against the owner's own offer list: a
// forged zone, or a loadout that zone does not sell, must never reach an order
// row. gogobee would refuse them anyway — this is the cheap answer, thirty
// seconds earlier, and it keeps the queue clean.
func TestExpeditionParamsAreResolvedAgainstTheOwnersOffers(t *testing.T) {
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{Zones: offeredZones()})
if w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionExpedition, Zone: "dragons_lair", Loadout: "lean",
}); w.Code != 409 {
t.Fatalf("forged zone = %d, want 409 (%s)", w.Code, w.Body.String())
}
if w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "enormous",
}); w.Code != 409 {
t.Fatalf("forged loadout = %d, want 409 (%s)", w.Code, w.Body.String())
}
w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "balanced",
})
if w.Code != 200 {
t.Fatalf("offered zone = %d, want 200 (%s)", w.Code, w.Body.String())
}
var got storage.AdvOrder
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Params == nil || got.Params.Zone != "goblin_warrens" || got.Params.Loadout != "balanced" {
t.Fatalf("params = %+v, want the resolved zone and loadout", got.Params)
}
// And they survive the round trip to gogobee's poll, which is the only reason
// they are stored at all.
pending, err := storage.PendingAdvOrders(10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0].Params == nil || pending[0].Params.Zone != "goblin_warrens" {
t.Fatalf("pending params lost in the round trip: %+v", pending)
}
}
// An empty zone list is a refusal, and the message says only what Pete saw. It
// usually means the adventurer is already out — gogobee omits the offers
// entirely while they are down there — but a game box too old to push offers
// sends the same empty list, so the copy claims nothing about which. Refusing
// cheaply here beats a verdict thirty seconds later saying the same thing.
func TestNoZoneOffersMeansNothingOnOffer(t *testing.T) {
s := seedOffers(t, "holymachina", "expedition", storage.PlayerDetail{})
w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "lean",
})
if w.Code != 409 {
t.Fatalf("departure with no offers = %d, want 409 (%s)", w.Code, w.Body.String())
}
}
// The sitter sells two durations and nothing else, and is not sold twice.
func TestBabysitParamsAreTheTwoDurationsOnly(t *testing.T) {
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{
Babysit: &storage.BabysitOffer{WeekCost: 700, MonthCost: 3000},
})
for _, days := range []int{0, 3, 365} {
if w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionBabysit, Days: days,
}); w.Code != 409 {
t.Fatalf("%d-day sitter = %d, want 409", days, w.Code)
}
}
if w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionBabysit, Days: 30,
}); w.Code != 200 {
t.Fatalf("month = %d, want 200 (%s)", w.Code, w.Body.String())
}
// Already engaged: the page should not be offering this at all, but a stale
// tab can still post it.
s2 := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{
Babysit: &storage.BabysitOffer{Active: true, WeekCost: 700, MonthCost: 3000},
})
if w := placeParams(t, s2, "holymachina", advOrderReq{
Action: storage.AdvActionBabysit, Days: 7,
}); w.Code != 409 {
t.Fatalf("second sitter = %d, want 409", w.Code)
}
}
// Resume is refused when the snapshot positively says there is nothing waiting,
// and accepted with a loadout the offer actually lists.
func TestResumeParamsNeedAnOfferedLoadout(t *testing.T) {
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{})
if w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionResume, Loadout: "lean",
}); w.Code != 409 {
t.Fatalf("resume with nothing waiting = %d, want 409", w.Code)
}
s2 := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{
Resume: &storage.ResumeOffer{ZoneID: "goblin_warrens", Display: "Goblin Warrens", Day: 3,
Loadouts: []storage.LoadoutOffer{{Key: "lean", Name: "lean", Cost: 40, Days: 3}}},
})
if w := placeParams(t, s2, "holymachina", advOrderReq{
Action: storage.AdvActionResume, Loadout: "heavy",
}); w.Code != 409 {
t.Fatalf("unoffered loadout = %d, want 409", w.Code)
}
if w := placeParams(t, s2, "holymachina", advOrderReq{
Action: storage.AdvActionResume, Loadout: "lean",
}); w.Code != 200 {
t.Fatalf("offered loadout = %d, want 200 (%s)", w.Code, w.Body.String())
}
}
// The offer list is the whole gate, so it is worth pinning that an empty one is
// a refusal rather than a pass-through: gogobee omits the zones while the
// adventurer is out, and a pass-through there would queue a departure that is
// certain to come back "you're already on expedition".
//
// There is deliberately no "Pete has never heard of this player" case to test:
// the detail row this resolves against is the same row the ownership check
// already found, so it always exists by then. A gogobee too old to push offers
// yields an empty list and the page renders no picker at all.
func TestParamsResolveOnlyAgainstAPushedOffer(t *testing.T) {
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{Zones: offeredZones()})
if w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "lean",
}); w.Code != 200 {
t.Fatalf("offered zone = %d, want 200 (%s)", w.Code, w.Body.String())
}
// Resume is not on offer for this player at all, so it is refused even though
// the loadout key is a real one from the zone list above.
if w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionResume, Loadout: "lean",
}); w.Code != 409 {
t.Fatalf("resume with no offer = %d, want 409", w.Code)
}
}