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) } } }