Files
prosolis b0aeffd218 adventure: ask 7 — full equipment management from the web
Extends equip-from-the-web (ask 5, magic-only) to all five standard gear
slots. Owners get an Equipment panel on their own who page with:
  - Take off for worn masterwork/arena pieces (round-trippable to pack)
  - Upgrade to the next shop tier (spends euros, confirm-gated)
  - Repair a damaged slot (spends euros, confirm-gated)
The public Gear panel is hidden for the owner since this supersedes it.

Wire: equip_orders gains a tier column; new actions upgrade/repair; new
verdicts rejected_downgrade / rejected_insufficient_funds / rejected_max_tier.
PlayerDetail carries Slots (EquipSlotView x5) + Balance for the confirm
dialogs. handleEquipOrder resolves take-off/upgrade/repair from pd.Slots
server-side and rejects a client-forged tier (409), same as ask 5 trusts
only Pete's own record.

Verified: full suite green, headless render of the panel + confirm dialog
in both day and night phases. gogobee ships the poll-apply half separately;
Pete deploys first so its ingest accepts the new verdicts before gogobee
emits them.
2026-07-17 20:34:24 -07:00

315 lines
12 KiB
Go

package web
import (
"encoding/json"
"net/http/httptest"
"strings"
"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},
},
// The 5 standard slots (ask 7). weapon is a worn masterwork (round-trippable,
// at max tier, damaged → repairable); boots is plain shop-tier at T3 with a
// T4 upgrade offered and nothing to take off or repair.
Slots: []storage.EquipSlotView{
{Slot: "weapon", Name: "Deepforged Blade", Tier: 5, Condition: 80,
Masterwork: true, CanTakeOff: true, RepairCost: 40},
{Slot: "boots", Name: "Leather Boots", Tier: 3, Condition: 100,
NextTier: 4, NextName: "Sturdy Boots", NextPrice: 25000},
},
Balance: 100000,
}}}); 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)
}
}
// TestEquipTakeOffMasterwork: a worn masterwork piece in a standard slot rides the
// unequip action, resolved from Slots (CanTakeOff), keyed on the slot with no item id.
func TestEquipTakeOffMasterwork(t *testing.T) {
s := seedEquip(t, "reala")
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "unequip", Slot: "weapon"})
if w.Code != 200 {
t.Fatalf("take off = %d body=%s", w.Code, w.Body.String())
}
var o storage.EquipOrder
_ = json.Unmarshal(w.Body.Bytes(), &o)
if o.Action != "unequip" || o.Slot != "weapon" || o.ItemName != "Deepforged Blade" || o.ItemID != 0 {
t.Fatalf("take-off order = %+v", o)
}
}
// TestEquipUpgradeHappy: upgrading the boots to their next tier queues an upgrade
// order carrying the target tier and the tier's name — both resolved from the
// pushed slot view, not the request.
func TestEquipUpgradeHappy(t *testing.T) {
s := seedEquip(t, "reala")
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "upgrade", Slot: "boots", Tier: 4})
if w.Code != 200 {
t.Fatalf("upgrade = %d body=%s", w.Code, w.Body.String())
}
var o storage.EquipOrder
_ = json.Unmarshal(w.Body.Bytes(), &o)
if o.Action != "upgrade" || o.Slot != "boots" || o.Tier != 4 || o.ItemName != "Sturdy Boots" || o.ItemID != 0 {
t.Fatalf("upgrade order = %+v", o)
}
}
// TestEquipRepairHappy: repairing a damaged slot queues a repair order keyed on the
// slot, no money in the request — the cost is gogobee's at apply time.
func TestEquipRepairHappy(t *testing.T) {
s := seedEquip(t, "reala")
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "repair", Slot: "weapon"})
if w.Code != 200 {
t.Fatalf("repair = %d body=%s", w.Code, w.Body.String())
}
var o storage.EquipOrder
_ = json.Unmarshal(w.Body.Bytes(), &o)
if o.Action != "repair" || o.Slot != "weapon" || o.ItemName != "Deepforged Blade" {
t.Fatalf("repair order = %+v", o)
}
}
// TestEquipUpgradeRepairRejections: the money-spending actions trust only the
// pushed slot view. A forged tier, a slot with no upgrade offered, a repair of a
// full-condition slot, and a non-owner all bounce before any order is placed.
func TestEquipUpgradeRepairRejections(t *testing.T) {
s := seedEquip(t, "reala")
// A tier that isn't the slot's pushed NextTier: a stale page or a forged jump.
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "upgrade", Slot: "boots", Tier: 5}); w.Code != 409 {
t.Errorf("forged upgrade tier = %d, want 409", w.Code)
}
// weapon is at max tier (NextTier 0): no upgrade to offer.
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "upgrade", Slot: "weapon", Tier: 6}); w.Code != 400 {
t.Errorf("upgrade with no offer = %d, want 400", w.Code)
}
// boots are at full condition: nothing to repair.
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "repair", Slot: "boots"}); w.Code != 400 {
t.Errorf("repair of full-condition slot = %d, want 400", w.Code)
}
// A non-owner can't spend someone else's euros.
if w := placeEquip(t, s, "mallory", equipOrderReq{Token: "tok-josie", Action: "upgrade", Slot: "boots", Tier: 4}); w.Code != 403 {
t.Errorf("non-owner upgrade = %d, want 403", w.Code)
}
// No order should have survived any of those.
if pending, _ := storage.PendingEquipOrders(10); len(pending) != 0 {
t.Fatalf("a rejected money action still queued an order: %+v", pending)
}
}
// TestEquipPanelRenders: the owner's who page renders the Equipment panel with the
// three controls and the confirm data (balance) the money actions need.
func TestEquipPanelRenders(t *testing.T) {
s := seedEquip(t, "reala")
body := getWho(t, s, "tok-josie", "reala").Body.String()
for _, want := range []string{
"Equipment",
"Deepforged Blade",
"Take off", // the masterwork weapon is round-trippable
"Upgrade to Sturdy Boots", // the boots offer the next tier
"Repair", // the damaged weapon can be mended
"data-balance=\"100000.00\"", // the confirm dialog needs the balance
} {
if !strings.Contains(body, want) {
t.Errorf("equipment panel missing %q", want)
}
}
// The public Gear panel is suppressed for the owner (the Equipment panel
// supersedes it), so its heading must not appear on the owner render.
// A non-owner still sees the public sheet unchanged.
anon := getWho(t, s, "tok-josie", "").Body.String()
if strings.Contains(anon, "Deepforged Blade") {
t.Error("the owner-only equipment panel leaked onto the public page")
}
}
// 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
}