Files
Pete/internal/web/orders_undo_test.go
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

270 lines
11 KiB
Go

package web
import (
"testing"
"time"
"pete/internal/storage"
)
// W9: the three verbs that undo something. Two halves are worth pinning.
//
// The first is offersToUndo, which is the only place on this site where Pete
// decides what a player MAY do from facts rather than from a list gogobee handed
// it. Getting it wrong in the generous direction puts "Call the whole thing off"
// — a button that throws away four people's day — in front of somebody who is not
// the leader, so the interesting cases are the ones where it must stay quiet.
//
// The second is that the two new verdict names round-trip. gogobee 400s on an
// unknown verdict and parks the order, so a name that exists on one side and not
// the other is a player watching "asked for…" forever.
func seat(kind, name, token string, level int) partySeat {
return partySeat{Kind: kind, Name: name, Token: token, Level: level}
}
// TestOffersToUndoReadsTheViewersOwnSeat is the core of the phase. A shared
// expedition publishes a seat per body, so which button this page offers is
// decided by finding the viewer among them — never by "there is a party, so
// somebody can abandon it".
func TestOffersToUndoReadsTheViewersOwnSeat(t *testing.T) {
party := []partySeat{
seat("leader", "Josie", "tok-josie", 14),
seat("member", "Camcast", "tok-cam", 11),
seat("companion", "Pete", "", 9),
}
cases := []struct {
name string
token, status string
partyKnown bool
party []partySeat
self storage.PlayerDetail
abandon, leave, cancel bool
}{
{
name: "leader of a party is offered the abandon",
token: "tok-josie", status: "expedition", partyKnown: true, party: party,
abandon: true,
},
{
name: "member of a party is offered the exit, never the abandon",
token: "tok-cam", status: "expedition", partyKnown: true, party: party,
leave: true,
},
{
// A solo run publishes no party at all (partySeatViews returns nil below
// two seats), so an empty list on a live run means "nobody else", not
// "we don't know" — and the one body down there is the leader.
name: "solo run is offered the abandon",
token: "tok-josie", status: "expedition", partyKnown: true,
abandon: true,
},
{
// The fail-closed case. A party we cannot find ourselves in is a
// snapshot we do not understand, and the safe answer is to offer
// nothing rather than guess which of the two buttons applies.
name: "a party with no seat for the viewer offers nothing",
token: "tok-nobody", status: "expedition", partyKnown: true, party: party,
},
{
// The one that came out of running it. An undecodable public sheet
// gives the same empty slice as a solo run, and treating the two alike
// offered a party MEMBER the abandon — convincingly, with the rest of
// the page looking fine.
name: "a run whose sheet did not decode offers nothing",
token: "tok-cam", status: "expedition", partyKnown: false,
},
{
// The same empty slice again, this time from a gogobee too old to push
// seats at all. It decodes fine, so only the sender's own flag tells it
// apart from the solo case two rows up.
name: "an empty party from a sender that never pushes seats offers nothing",
token: "tok-cam", status: "expedition", partyKnown: false, party: nil,
},
{
// The asymmetry: the seat list is self-evidencing, so it keeps working
// against a sender whose capability we cannot confirm. A seat saying
// "member" is not a guess, and refusing the exit here would strand
// somebody in a party for the length of the rollout.
name: "a seated member is offered the exit even without the flag",
token: "tok-cam", status: "expedition", partyKnown: false, party: party,
leave: true,
},
{
name: "standing in town with nothing open offers nothing",
token: "tok-josie", status: "idle", partyKnown: true, party: nil,
},
{
// The case the roster status cannot see: an extracted expedition is
// still its owner's to close, and its owner reads as idle in town with
// no party. The resume offer is the only sign the run is still open.
name: "an extracted run is abandonable from town",
token: "tok-josie", status: "idle",
self: storage.PlayerDetail{Resume: &storage.ResumeOffer{ZoneID: "holymachina", Day: 3}},
abandon: true,
},
{
name: "an engaged sitter can be sent home",
token: "tok-josie", status: "idle",
self: storage.PlayerDetail{Babysit: &storage.BabysitOffer{Active: true}},
cancel: true,
},
{
name: "an unengaged sitter cannot",
token: "tok-josie", status: "idle",
self: storage.PlayerDetail{Babysit: &storage.BabysitOffer{Active: false, WeekCost: 700}},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
abandon, leave, cancel := offersToUndo(tc.token, tc.status, tc.partyKnown, tc.party, tc.self)
if abandon != tc.abandon || leave != tc.leave || cancel != tc.cancel {
t.Fatalf("offers = abandon:%v leave:%v cancel:%v, want abandon:%v leave:%v cancel:%v",
abandon, leave, cancel, tc.abandon, tc.leave, tc.cancel)
}
})
}
}
// TestAbandonAndLeaveAreNeverBothOffered. They are opposite claims about the
// same person, and a page showing both would be asking the reader to work out
// which one they are. No input may produce the pair.
//
// This test found a real one: a member seated in somebody else's live run who
// ALSO has their own extracted run waiting has both facts true at once, about two
// different expeditions. offersToUndo suppresses the abandon in that case; see
// the comment on the Resume clause.
func TestAbandonAndLeaveAreNeverBothOffered(t *testing.T) {
for _, kind := range []string{"leader", "member", "companion", "", "nonsense"} {
party := []partySeat{seat("leader", "Josie", "tok-josie", 14), seat(kind, "Me", "tok-me", 8)}
for _, status := range []string{"expedition", "idle"} {
for _, resume := range []*storage.ResumeOffer{nil, {ZoneID: "z", Day: 2}} {
abandon, leave, _ := offersToUndo("tok-me", status, true, party, storage.PlayerDetail{Resume: resume})
if abandon && leave {
t.Fatalf("kind=%q status=%q resume=%v offered both ways out at once", kind, status, resume != nil)
}
}
}
}
}
// TestUndoOrdersAreAccepted: the three verbs must survive the action allow-list
// and land as pending orders. A verb Pete does not know is a 400 at the door,
// which is a dead button rather than a refusal anybody can read.
func TestUndoOrdersAreAccepted(t *testing.T) {
s := seedActions(t, "holymachina", "expedition")
for _, action := range []string{storage.AdvActionAbandon, storage.AdvActionLeave} {
if w := placeAction(t, s, "holymachina", action); w.Code != 200 {
t.Fatalf("%s = %d (%s)", action, w.Code, w.Body.String())
}
}
// Per verb, so the two do not block each other or anything already queued.
pending, err := storage.PendingAdvOrders(0)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 2 {
t.Fatalf("pending = %d orders, want 2", len(pending))
}
}
// TestAbandonIsOfferedToAMarkStandingInTown is W5a's asymmetry restated for the
// two expedition verbs, and it is the reason neither has a snapshot pre-check.
// The board is up to two minutes stale, and an EXTRACTED run is abandonable
// while its owner reads as idle — so refusing on "the mark is in town" would
// refuse the case the verb exists for. gogobee answers rejected_not_running if
// the run really has gone.
func TestAbandonIsOfferedToAMarkStandingInTown(t *testing.T) {
s := seedActions(t, "holymachina", "idle")
if w := placeAction(t, s, "holymachina", storage.AdvActionAbandon); w.Code != 200 {
t.Fatalf("abandon from town = %d (%s), want it queued and answered by the game box",
w.Code, w.Body.String())
}
if w := placeAction(t, s, "holymachina", storage.AdvActionLeave); w.Code != 200 {
t.Fatalf("leave from town = %d (%s)", w.Code, w.Body.String())
}
}
// TestBabysitCancelRefusesWhenThereIsNoSitter is the one W9 pre-check that IS
// allowed to be the last word locally, and the comment in resolveAdvOrderParams
// says why: an engagement is a fact about the character rather than about where
// they are standing, so it does not go stale the way "on an expedition" does.
//
// A MISSING offer is still not a refusal — that is a gogobee too old to push one,
// not a player without a sitter — and the second half here pins that.
func TestBabysitCancelRefusesWhenThereIsNoSitter(t *testing.T) {
s := seedActions(t, "holymachina", "idle")
now := time.Now().Unix()
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
Localpart: "holymachina", Token: "tok-josie",
Babysit: &storage.BabysitOffer{Active: false, WeekCost: 700, MonthCost: 2400},
}}}); w.Code != 200 {
t.Fatalf("detail push = %d", w.Code)
}
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 409 {
t.Fatalf("cancel with no sitter = %d, want 409", w.Code)
}
// Sitter engaged: allowed.
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
Localpart: "holymachina", Token: "tok-josie",
Babysit: &storage.BabysitOffer{Active: true, WeekCost: 700, MonthCost: 2400},
}}}); w.Code != 200 {
t.Fatalf("detail push = %d", w.Code)
}
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 200 {
t.Fatalf("cancel with a sitter = %d (%s)", w.Code, w.Body.String())
}
// No babysit offer at all: a gogobee that predates the offer push. Queue it
// and let the game box answer, rather than making the button dead.
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
Localpart: "holymachina", Token: "tok-josie",
}}}); w.Code != 200 {
t.Fatalf("detail push = %d", w.Code)
}
storage.Get().Exec(`DELETE FROM adventure_orders`)
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 200 {
t.Fatalf("cancel with no offer pushed = %d (%s), want it deferred to gogobee",
w.Code, w.Body.String())
}
}
// TestNewVerdictsRoundTrip: gogobee 400s on a verdict Pete will not take, and
// that parks the order — the player watches "asked for…" and nothing ever
// answers. So every status the game box can file has to be accepted here.
func TestNewVerdictsRoundTrip(t *testing.T) {
s := seedActions(t, "holymachina", "expedition")
for _, tc := range []struct{ action, verdict string }{
{storage.AdvActionLeave, storage.AdvRejectedIsLeader},
{storage.AdvActionBabysitCancel, storage.AdvRejectedNothingToCancel},
{storage.AdvActionAbandon, storage.AdvRejectedNotLeader},
} {
storage.Get().Exec(`DELETE FROM adventure_orders`)
w := placeAction(t, s, "holymachina", tc.action)
if w.Code != 200 {
t.Fatalf("place %s = %d (%s)", tc.action, w.Code, w.Body.String())
}
pending, err := storage.PendingAdvOrders(0)
if err != nil || len(pending) != 1 {
t.Fatalf("pending = %v (%v)", pending, err)
}
rec := postVerdict(t, s, "tok", advOrderVerdict{
GUID: pending[0].GUID, Status: tc.verdict, Detail: "because.",
})
if rec.Code != 200 {
t.Fatalf("verdict %s = %d (%s) — an unknown status parks the order forever",
tc.verdict, rec.Code, rec.Body.String())
}
got, err := storage.AdvOrderByGUID(pending[0].GUID)
if err != nil {
t.Fatalf("read back: %v", err)
}
if got.Status != tc.verdict {
t.Fatalf("stored status = %q, want %q", got.Status, tc.verdict)
}
}
}