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.
429 lines
17 KiB
Go
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)
|
|
}
|
|
}
|