The one adventure ask that carries intent back to the game box, built the mischief way: no new network route, Pete records the equip/unequip and gogobee polls it and files a verdict. The who page grows Equip / Take off buttons on the owner's own worn and backpack panels, and a pending-changes strip that shows 'queued' until the verdict lands, never claiming a change it can't see. The item handle is the inventory row id, sent only on wearable magic items, so a non-zero id is also what gates the button. gogobee resolves the owner by localpart, never a name.
200 lines
7.5 KiB
Go
200 lines
7.5 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// The equip queue's web seam. Two contracts: the owner half proves ownership and
|
|
// resolves the item from Pete's own record (never the client), and the gogobee
|
|
// half is a bearer-authed, idempotent pending/verdict pair.
|
|
|
|
// seedEquip stands up a board + a private detail set owned by `owner`, with a
|
|
// wearable backpack magic item (ID != 0, the equip handle) and a worn item in a
|
|
// slot. Mirrors seedWho but pins the fields the equip path keys on.
|
|
func seedEquip(t *testing.T, owner 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", "expedition", "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",
|
|
Inventory: []storage.ItemView{
|
|
// A wearable magic item: carries an ID, so it can be equipped.
|
|
{ID: 501, Name: "Ring of Protection", Type: "ring", Tier: 4, Value: 900,
|
|
Slot: "ring_1", Attunement: true, Effect: "-8% damage taken"},
|
|
// Mundane gear: no ID, so no equip handle even though it has a slot.
|
|
{Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300,
|
|
Slot: "weapon", SkillSource: "mining"},
|
|
},
|
|
Equipped: []storage.ItemView{
|
|
{Name: "Cloak of Elvenkind", Type: "wondrous", Value: 2000, Slot: "cloak",
|
|
Effect: "faster to act", Attunement: true, Attuned: true},
|
|
},
|
|
}}}); w.Code != 200 {
|
|
t.Fatalf("seed detail = %d", w.Code)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func placeEquip(t *testing.T, s *Server, username string, req equipOrderReq) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
r := as(t, s, username, "POST", "/api/equip/order", req)
|
|
w := httptest.NewRecorder()
|
|
s.handleEquipOrder(w, r)
|
|
return w
|
|
}
|
|
|
|
// TestEquipOrderHappyEquip: the owner equips a backpack magic item; Pete resolves
|
|
// the item name and slot from its own record and queues a pending order.
|
|
func TestEquipOrderHappyEquip(t *testing.T) {
|
|
s := seedEquip(t, "reala")
|
|
|
|
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501})
|
|
if w.Code != 200 {
|
|
t.Fatalf("equip = %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var o storage.EquipOrder
|
|
if err := json.Unmarshal(w.Body.Bytes(), &o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if o.Status != storage.EquipPending || o.Action != "equip" || o.ItemID != 501 {
|
|
t.Fatalf("order = %+v", o)
|
|
}
|
|
// Name and slot come from Pete's own detail, not the request.
|
|
if o.ItemName != "Ring of Protection" || o.Slot != "ring_1" || o.CharacterName != "Josie" {
|
|
t.Fatalf("order didn't resolve from the owner's record: %+v", o)
|
|
}
|
|
if pending, _ := storage.PendingEquipOrders(10); len(pending) != 1 {
|
|
t.Fatal("order didn't land in the pending set")
|
|
}
|
|
}
|
|
|
|
// TestEquipOrderHappyUnequip: taking off a worn item rides on the slot; item_id 0.
|
|
func TestEquipOrderHappyUnequip(t *testing.T) {
|
|
s := seedEquip(t, "reala")
|
|
|
|
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "unequip", Slot: "cloak"})
|
|
if w.Code != 200 {
|
|
t.Fatalf("unequip = %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var o storage.EquipOrder
|
|
_ = json.Unmarshal(w.Body.Bytes(), &o)
|
|
if o.Action != "unequip" || o.Slot != "cloak" || o.ItemName != "Cloak of Elvenkind" || o.ItemID != 0 {
|
|
t.Fatalf("unequip order = %+v", o)
|
|
}
|
|
}
|
|
|
|
// TestEquipOrderRejections: the honest failure surface — not your page, item not
|
|
// in the pack, empty slot, unequippable mundane gear, bad action.
|
|
func TestEquipOrderRejections(t *testing.T) {
|
|
s := seedEquip(t, "reala")
|
|
|
|
// A different signed-in user does not own Josie's page.
|
|
if w := placeEquip(t, s, "mallory", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501}); w.Code != 403 {
|
|
t.Errorf("non-owner equip = %d, want 403", w.Code)
|
|
}
|
|
// An item id that isn't in the pack.
|
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 999}); w.Code != 400 {
|
|
t.Errorf("unknown item = %d, want 400", w.Code)
|
|
}
|
|
// Mundane gear has a slot but no id, so it can never be named for equip.
|
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 0}); w.Code != 400 {
|
|
t.Errorf("no-id equip = %d, want 400", w.Code)
|
|
}
|
|
// Unequip of a slot nothing is in.
|
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "unequip", Slot: "boots"}); w.Code != 400 {
|
|
t.Errorf("empty-slot unequip = %d, want 400", w.Code)
|
|
}
|
|
// A bogus action.
|
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "wield", ItemID: 501}); w.Code != 400 {
|
|
t.Errorf("bad action = %d, want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestEquipWireIdempotentAndAuthed: gogobee's pending/verdict pair is bearer-only,
|
|
// never nulls, and files a verdict once.
|
|
func TestEquipWireIdempotentAndAuthed(t *testing.T) {
|
|
s := seedEquip(t, "reala")
|
|
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501}); w.Code != 200 {
|
|
t.Fatalf("seed order = %d", w.Code)
|
|
}
|
|
// The owner sees their own order in the "my orders" strip.
|
|
if rows := mustEquipOrders(t, s, "reala"); len(rows) != 1 {
|
|
t.Fatalf("owner sees %d orders, want 1", len(rows))
|
|
}
|
|
|
|
// No bearer → 401 on both machine endpoints.
|
|
if w := httptest.NewRecorder(); func() bool {
|
|
s.handleEquipPending(w, jsonReq(t, "GET", "/api/equip/pending", "", nil))
|
|
return w.Code == 401
|
|
}() == false {
|
|
t.Error("pending without bearer should be 401")
|
|
}
|
|
|
|
// Pending returns the order (bearer-authed), never null.
|
|
w := httptest.NewRecorder()
|
|
s.handleEquipPending(w, jsonReq(t, "GET", "/api/equip/pending", "tok", nil))
|
|
if w.Code != 200 {
|
|
t.Fatalf("pending = %d", w.Code)
|
|
}
|
|
var pending []storage.EquipOrder
|
|
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil || len(pending) != 1 {
|
|
t.Fatalf("pending body = %s err=%v", w.Body.String(), err)
|
|
}
|
|
guid := pending[0].GUID
|
|
|
|
// A verdict resolves it; a replay is a no-op.
|
|
verdict := func(status, detail string) *httptest.ResponseRecorder {
|
|
rw := httptest.NewRecorder()
|
|
s.handleEquipVerdict(rw, jsonReq(t, "POST", "/api/equip/verdict", "tok",
|
|
equipVerdict{GUID: guid, Status: status, Detail: detail}))
|
|
return rw
|
|
}
|
|
if w := verdict("applied", "worn"); w.Code != 200 {
|
|
t.Fatalf("verdict = %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if w := verdict("rejected_not_owned", "too late"); w.Code != 200 {
|
|
t.Fatalf("replay verdict = %d", w.Code)
|
|
}
|
|
got, _ := storage.EquipOrderByGUID(guid)
|
|
if got.Status != storage.EquipApplied || got.Detail != "worn" {
|
|
t.Fatalf("replay overwrote the first verdict: %+v", got)
|
|
}
|
|
|
|
// Unknown guid parks with a 400, not a silent retry.
|
|
rw := httptest.NewRecorder()
|
|
s.handleEquipVerdict(rw, jsonReq(t, "POST", "/api/equip/verdict", "tok",
|
|
equipVerdict{GUID: "ghost", Status: "applied"}))
|
|
if rw.Code != 400 {
|
|
t.Errorf("unknown guid = %d, want 400", rw.Code)
|
|
}
|
|
}
|
|
|
|
// mustEquipOrders returns the raw "my orders" JSON rows for a user. Small helper
|
|
// so the wire test can pull the guid it just created without reaching into storage.
|
|
func mustEquipOrders(t *testing.T, s *Server, username string) []json.RawMessage {
|
|
t.Helper()
|
|
r := as(t, s, username, "GET", "/api/equip/orders", nil)
|
|
w := httptest.NewRecorder()
|
|
s.handleEquipOrders(w, r)
|
|
if w.Code != 200 {
|
|
t.Fatalf("orders = %d", w.Code)
|
|
}
|
|
var rows []json.RawMessage
|
|
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return rows
|
|
}
|