Files
Pete/internal/web/mischief_test.go
prosolis 2ac6ec6b91 mischief: a storefront where money buys a stranger some trouble
The web half of Mischief Makers M3. A signed-in buyer picks a mark off the
anonymous roster board and pays for a monster to find them; gogobee does the
real work and hands back a verdict Pete files against the order.

- mischief_orders: intent in, verdict out, idempotent on a guid that is the
  end-to-end key gogobee passes to DebitIdem and stamps on the contract
- user_euro + mischief_tiers: advisory balance and the live price list, pushed
  on the roster tick so the storefront never hardcodes a number that can drift
- OIDC-gated buy API (target + tier + signed), bearer-authed poll/claim wire
- roster board grows a 'send trouble' button, a tier picker, and a status panel

Pete never touches money and never runs a game rule. It records what a buyer
wants and what gogobee said happened.
2026-07-14 20:55:15 -07:00

223 lines
6.9 KiB
Go

package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"pete/internal/storage"
)
// newStore is a server with the adventure seam on, a signed-in buyer, and a
// board + catalog already pushed. Auth is built by hand for the same reason the
// casino tests do it: the OIDC handshake is a network call and none of what's
// under test is about it.
func newStore(t *testing.T) *Server {
t.Helper()
s, _ := newAdvServer(t, "tok")
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
now := time.Now().Unix()
push := rosterPush{
SnapshotAt: now,
Adventurers: []storage.RosterEntry{entry("tok-josie", "Josie", "expedition", "holymachina")},
Tiers: []storage.MischiefTier{
{Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50},
{Key: "boss", Display: "Boss", Fee: 1200, SignedFee: 1500},
},
Balances: []storage.MischiefBalance{{Username: "reala", Euro: 500}},
}
if w := postRoster(t, s, "tok", push); w.Code != 200 {
t.Fatalf("seed roster = %d", w.Code)
}
return s
}
// jsonReq builds a bearer-authed (or unauthed, empty token) machine request.
func jsonReq(t *testing.T, method, path, token string, body any) *http.Request {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatal(err)
}
}
r := httptest.NewRequest(method, path, &buf)
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
return r
}
func placeOrder(t *testing.T, s *Server, username string, req mischiefOrderReq) *httptest.ResponseRecorder {
t.Helper()
r := as(t, s, username, "POST", "/api/mischief/order", req)
w := httptest.NewRecorder()
s.handleMischiefOrder(w, r)
return w
}
// TestMischiefOrderHappyPath: a signed-in buyer names a mark on the board and a
// tier gogobee offers, and gets a pending order back.
func TestMischiefOrderHappyPath(t *testing.T) {
s := newStore(t)
w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt", Signed: false})
if w.Code != 200 {
t.Fatalf("order = %d body=%s", w.Code, w.Body.String())
}
var o storage.MischiefOrder
if err := json.Unmarshal(w.Body.Bytes(), &o); err != nil {
t.Fatal(err)
}
if o.Status != storage.MischiefPending || o.BuyerUsername != "reala" || o.TargetName != "Josie" {
t.Fatalf("order = %+v", o)
}
if pending, _ := storage.PendingMischiefOrders(10); len(pending) != 1 {
t.Fatalf("order didn't land in the pending set")
}
}
func TestMischiefOrderRejections(t *testing.T) {
s := newStore(t)
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "dragon"}); w.Code != 400 {
t.Errorf("unknown tier = %d, want 400", w.Code)
}
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "ghost", Tier: "grunt"}); w.Code != 404 {
t.Errorf("off-board target = %d, want 404", w.Code)
}
if w := placeOrder(t, s, "reala", mischiefOrderReq{Tier: "grunt"}); w.Code != 400 {
t.Errorf("empty target = %d, want 400", w.Code)
}
// Signed in, but no username the ledger can name (a pre-storefront session).
if w := placeOrder(t, s, "", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 409 {
t.Errorf("usernameless session = %d, want 409", w.Code)
}
}
func TestMischiefOrderBurstGuard(t *testing.T) {
s := newStore(t)
for i := 0; i < mischiefBurstMax; i++ {
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 200 {
t.Fatalf("order %d = %d, want 200", i, w.Code)
}
}
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 429 {
t.Fatalf("over-cap order = %d, want 429", w.Code)
}
}
func TestMischiefOrderRequiresAuth(t *testing.T) {
s := newStore(t)
r := httptest.NewRequest("POST", "/api/mischief/order", nil)
w := httptest.NewRecorder()
s.handleMischiefOrder(w, r)
if w.Code != 401 {
t.Fatalf("anonymous order = %d, want 401", w.Code)
}
}
func TestMischiefCatalogCarriesBalance(t *testing.T) {
s := newStore(t)
r := as(t, s, "reala", "GET", "/api/mischief/catalog", nil)
w := httptest.NewRecorder()
s.handleMischiefCatalog(w, r)
if w.Code != 200 {
t.Fatalf("catalog = %d", w.Code)
}
var resp mischiefCatalogResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Tiers) != 2 || !resp.HasBalance || resp.Euro != 500 {
t.Fatalf("catalog resp = %+v", resp)
}
}
// ---- the bearer wire -----------------------------------------------------------
func TestMischiefPendingAndClaimBearer(t *testing.T) {
s := newStore(t)
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "boss", Signed: true}); w.Code != 200 {
t.Fatalf("seed order = %d", w.Code)
}
// Missing bearer is rejected.
{
w := httptest.NewRecorder()
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "", nil))
if w.Code != 401 {
t.Fatalf("no-bearer pending = %d, want 401", w.Code)
}
}
// Poll returns the order.
var pending []storage.MischiefOrder
{
w := httptest.NewRecorder()
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "tok", nil))
if w.Code != 200 {
t.Fatalf("pending = %d", w.Code)
}
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
t.Fatal(err)
}
}
if len(pending) != 1 {
t.Fatalf("pending count = %d, want 1", len(pending))
}
guid := pending[0].GUID
// Claim it placed.
{
w := httptest.NewRecorder()
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
mischiefVerdict{GUID: guid, Status: storage.MischiefPlaced, Detail: "out for delivery"}))
if w.Code != 200 {
t.Fatalf("claim = %d body=%s", w.Code, w.Body.String())
}
}
if got, _ := storage.MischiefOrderByGUID(guid); got.Status != storage.MischiefPlaced {
t.Fatalf("order status after claim = %q", got.Status)
}
// And it's gone from the pending set.
{
w := httptest.NewRecorder()
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "tok", nil))
var again []storage.MischiefOrder
_ = json.Unmarshal(w.Body.Bytes(), &again)
if len(again) != 0 {
t.Fatalf("placed order still polled: %d", len(again))
}
}
}
func TestMischiefClaimUnknownGUIDis400(t *testing.T) {
s := newStore(t)
w := httptest.NewRecorder()
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
mischiefVerdict{GUID: "nope", Status: storage.MischiefPlaced}))
if w.Code != 400 {
t.Fatalf("unknown-guid claim = %d, want 400 (so gogobee parks it)", w.Code)
}
}
func TestMischiefClaimBadVerdictIs400(t *testing.T) {
s := newStore(t)
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 200 {
t.Fatal("seed")
}
pending, _ := storage.PendingMischiefOrders(10)
w := httptest.NewRecorder()
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
mischiefVerdict{GUID: pending[0].GUID, Status: "exploded"}))
if w.Code != 400 {
t.Fatalf("bad-verdict claim = %d, want 400", w.Code)
}
}