adventure: let a player act from the web, not just read about it
The equip queue proved the reverse pipe works. This gives it verbs that play the game: pull out of a run from the adventurer page, take today's bout from the war room. Its own table and its own poll, not more actions on equip_orders. Every column of that table is equip vocabulary (item, slot, tier) and these verbs act on the character rather than on something it is carrying. Nothing in a request names an adventurer. The session maps to one localpart and a localpart to one adventurer, so Pete resolves the character itself and there is no id on the wire to forge. The panel's copy is kept honest by the verdict: an applied action hides the offer it has just spent, a refusal puts the button back. Watching it run is what put that there, along with the strip's layout — gogobee answers a bout with a whole sentence of damage, which the equip strip's two-column row squeezed into a column and wrapped the verb. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionPreChecksAreCourtesyOnly pins both halves of a deliberate asymmetry.
|
||||
// Pete refuses what its own snapshot says is impossible — but the snapshot is up
|
||||
// to two minutes old, so the refusal must be cheap and local (a 409 the button
|
||||
// shows immediately), never a queued order gogobee has to answer.
|
||||
func TestActionPreChecksAreCourtesyOnly(t *testing.T) {
|
||||
// Idle mark: extract refused up front.
|
||||
s := seedActions(t, "holymachina", "idle")
|
||||
if w := placeAction(t, s, "holymachina", "extract"); w.Code != 409 {
|
||||
t.Fatalf("extract while idle = %d, want 409", w.Code)
|
||||
}
|
||||
|
||||
// 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); 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
|
||||
}
|
||||
Reference in New Issue
Block a user