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.
223 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|