On the owner's own page, each wearable backpack item now carries a compare card: a verdict chip (upgrade / downgrade / sidegrade / new / inert / same), the per-stat deltas, and the name of the worn item it's measured against. gogobee computes all of it — the power math needs tempering and bond state, which live in the engine — so Pete only colours the result and does no arithmetic. It rides the owner-private inventory, so it never reaches the public page; the item names are game-authored, so unlike the LLM dispatch prose there's no injection surface. The verdict chip is always visible (a phone has no hover to lean on) with the deltas beside it. Chip colours mix a fixed hue into --ink for text and --card for fill, so they land on the readable side of the card in all four phases — the same by-construction contrast trick the dungeon map uses, not a Tailwind dark: variant. Screenshot-verified day and night, all six verdict states, including the purple 'new' chip that the theme-contrast history warned about. output.css rebuilt and committed.
371 lines
14 KiB
Go
371 lines
14 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// The adventurer detail page. Two contracts under test: the public sheet reaches
|
|
// any visitor (stats + gear, never a handle), and the private self-view unlocks
|
|
// only for the signed-in owner of that exact page — anon and other users see the
|
|
// public page and nothing more.
|
|
|
|
func postDetail(t *testing.T, s *Server, token string, push detailPush) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
body, _ := json.Marshal(push)
|
|
req := httptest.NewRequest("POST", "/api/ingest/detail", bytes.NewReader(body))
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.handleDetailIngest(w, req)
|
|
return w
|
|
}
|
|
|
|
// publicDetail is the sheet gogobee hangs on a roster entry, marshalled the way
|
|
// the roster push carries it.
|
|
func publicDetail(t *testing.T) json.RawMessage {
|
|
t.Helper()
|
|
raw, err := json.Marshal(map[string]any{
|
|
"hp_current": 30,
|
|
"hp_max": 42,
|
|
"temp_hp": 5,
|
|
"armor_class": 17,
|
|
"abilities": [6]int{16, 14, 15, 10, 12, 8},
|
|
"modifiers": [6]int{3, 2, 2, 0, 1, -1},
|
|
"gear": []map[string]any{
|
|
{"slot": "weapon", "name": "Rusty Sword", "tier": 0, "condition": 100},
|
|
{"slot": "armor", "name": "Padded Vest", "tier": 0, "condition": 100},
|
|
},
|
|
"supplies": 8,
|
|
"threat_level": 2,
|
|
"room": "3 / 7",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// seedWho puts one adventurer on the board with a public detail sheet, and
|
|
// pushes a private self-detail set owned by `owner` for that same token.
|
|
func seedWho(t *testing.T, owner string) *Server {
|
|
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")
|
|
e.Level = 14
|
|
e.ClassRace = "human cleric"
|
|
e.Detail = publicDetail(t)
|
|
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{
|
|
{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10},
|
|
{Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300,
|
|
Slot: "weapon", SkillSource: "mining", Desc: "A pick balanced for a long seam."},
|
|
// Wants a bond, but it's in the backpack: not worn, so not inert.
|
|
{Name: "Ring of Protection", Type: "ring", Tier: 4, Value: 900,
|
|
Slot: "ring", Attunement: true, Effect: "-8% damage taken"},
|
|
// A wearable magic item (carries an equip id) with gogobee's compare
|
|
// card attached: a sidegrade against the worn Flame Tongue.
|
|
{Name: "Blazing Brand", Type: "weapon", Tier: 5, Value: 4000, ID: 77,
|
|
Slot: "main_hand", Desc: "A blade that hums.", Effect: "+20% damage",
|
|
Compare: &storage.ItemCompare{
|
|
Verdict: "sidegrade", VsName: "Flame Tongue", VsSlot: "main_hand",
|
|
Deltas: []storage.ItemDelta{
|
|
{Label: "damage", Better: true, Text: "+5% damage"},
|
|
{Label: "hp", Better: false, Text: "-4 HP"},
|
|
},
|
|
}},
|
|
},
|
|
Equipped: []storage.ItemView{
|
|
{Name: "Flame Tongue", Type: "weapon", Value: 5000, Temper: 1, Slot: "weapon",
|
|
Desc: "A sword that ignites on command.", Effect: "+15% damage",
|
|
Attunement: true, Attuned: true},
|
|
// Worn while the bond cap is full: on her, doing nothing.
|
|
{Name: "Cloak of Elvenkind", Type: "wondrous", Value: 2000, Slot: "cloak",
|
|
Effect: "faster to act, +4 HP", Attunement: true, Attuned: false},
|
|
},
|
|
Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
|
|
House: storage.HouseView{Tier: 2, LoanBalance: 1500},
|
|
Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
|
|
}}}); w.Code != 200 {
|
|
t.Fatalf("seed detail = %d", w.Code)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// seedHistory pushes facts about Josie through the real ingest path, so the
|
|
// history panels are driven by rows that arrived the way gogobee sends them
|
|
// rather than by a hand-built struct that could drift from the contract.
|
|
func seedHistory(t *testing.T, s *Server) {
|
|
t.Helper()
|
|
facts := []AdvFact{
|
|
{GUID: "boss_first:a:1", EventType: "boss_first", Tier: "priority", Actors: []string{"Josie"},
|
|
Subject: "Josie", Boss: "The Rotmother", Zone: "holymachina", Region: "the Reach", OccurredAt: 100},
|
|
{GUID: "boss_kill:b:2", EventType: "boss_kill", Tier: "bulletin", Actors: []string{"Josie"},
|
|
Subject: "Josie", Boss: "The Rotmother", Zone: "holymachina", OccurredAt: 200},
|
|
{GUID: "death:c:3", EventType: "death", Tier: "priority", Actors: []string{"Josie"},
|
|
Subject: "Josie", Zone: "holymachina", Level: 14, OccurredAt: 300},
|
|
{GUID: "milestone:d:4", EventType: "milestone", Tier: "bulletin", Actors: []string{"Josie"},
|
|
Subject: "Josie", Milestone: "level 14", OccurredAt: 400},
|
|
{GUID: "treasure_found:e:5", EventType: "treasure_found", Tier: "priority", Actors: []string{"Josie"},
|
|
Subject: "Josie", Zone: "The Ossuary", Stakes: "Crown of the Drowned King", Outcome: "legendary", OccurredAt: 500},
|
|
}
|
|
for _, f := range facts {
|
|
body, _ := json.Marshal(f)
|
|
req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer tok")
|
|
w := httptest.NewRecorder()
|
|
s.handleAdventureIngest(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("seed fact %s = %d: %s", f.GUID, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func getWho(t *testing.T, s *Server, token, asUser string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var req = httptest.NewRequest("GET", "/adventure/who/"+token, nil)
|
|
req.SetPathValue("token", token)
|
|
if asUser != "" {
|
|
payload, _ := json.Marshal(SessionUser{Sub: "sub-1", Username: asUser, Exp: time.Now().Add(time.Hour).Unix()})
|
|
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.handleAdventureWho(w, req)
|
|
return w
|
|
}
|
|
|
|
// TestWhoPublicSheet: any visitor sees the mark's stats and gear — driven
|
|
// through the real template, so a field slip 500s here rather than in prod.
|
|
func TestWhoPublicSheet(t *testing.T) {
|
|
s := seedWho(t, "josie")
|
|
|
|
w := getWho(t, s, "tok-josie", "") // anonymous
|
|
if w.Code != 200 {
|
|
t.Fatalf("GET who = %d, want 200", w.Code)
|
|
}
|
|
body := w.Body.String()
|
|
for _, want := range []string{"Josie", "human cleric", "Rusty Sword", "Padded Vest"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("public page missing %q", want)
|
|
}
|
|
}
|
|
// The private goods must NOT be on an anonymous render.
|
|
for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
|
|
if strings.Contains(body, leak) {
|
|
t.Errorf("anonymous page leaked private detail %q", leak)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWhoSelfUnlock: the owner, signed in on their own page, sees the private
|
|
// inventory/vault/house/pets inline.
|
|
func TestWhoSelfUnlock(t *testing.T) {
|
|
s := seedWho(t, "josie")
|
|
|
|
w := getWho(t, s, "tok-josie", "josie")
|
|
if w.Code != 200 {
|
|
t.Fatalf("owner GET who = %d, want 200", w.Code)
|
|
}
|
|
body := w.Body.String()
|
|
for _, want := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("owner page missing private detail %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWhoItemDetail: the item panels carry the facts a player decides on —
|
|
// descriptions, the engine's effect summary, slot and skill tags — and the worn
|
|
// set renders with its bond count.
|
|
func TestWhoItemDetail(t *testing.T) {
|
|
s := seedWho(t, "josie")
|
|
|
|
body := getWho(t, s, "tok-josie", "josie").Body.String()
|
|
for _, want := range []string{
|
|
// "15% damage", not "+15%": html/template escapes a leading + to +.
|
|
"Worn", "Flame Tongue", "A sword that ignites on command.", "15% damage",
|
|
"1 of 3 bonds in use", // Flame Tongue is bonded; the Cloak is not
|
|
"A pick balanced for a long seam.", "mining",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("owner page missing item detail %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWhoInertOnlyWhenWorn pins the one distinction the panels can get wrong.
|
|
// "Inert" is a real problem state — the item is on you and doing nothing because
|
|
// every bond slot is full. An unworn attunement item is not inert, it's just not
|
|
// worn; gogobee tracks bonds only on equipped rows, so its Attuned is undefined
|
|
// rather than false. Rendering both the same would invent a problem the player
|
|
// does not have, on the exact panel they'd use to fix it.
|
|
func TestWhoInertOnlyWhenWorn(t *testing.T) {
|
|
s := seedWho(t, "josie")
|
|
|
|
body := getWho(t, s, "tok-josie", "josie").Body.String()
|
|
// The Cloak is worn with no bond free.
|
|
if !strings.Contains(body, "inert") {
|
|
t.Error("a worn item with no bond should be called out as inert")
|
|
}
|
|
if strings.Count(body, "inert") != 1 {
|
|
t.Errorf("only the worn unbonded item is inert, got %d mentions", strings.Count(body, "inert"))
|
|
}
|
|
// The backpack Ring of Protection wants a bond but isn't worn.
|
|
if !strings.Contains(body, "needs a bond") {
|
|
t.Error("an unworn attunement item should say it needs a bond, not that it is inert")
|
|
}
|
|
}
|
|
|
|
// TestWhoItemCompare: the compare card that gogobee attaches to a backpack magic
|
|
// item renders on the owner's page — verdict chip, per-stat deltas, and the name
|
|
// of the worn item it's measured against — and stays off the public page, since
|
|
// it rides the owner-private inventory.
|
|
func TestWhoItemCompare(t *testing.T) {
|
|
s := seedWho(t, "josie")
|
|
|
|
owner := getWho(t, s, "tok-josie", "josie").Body.String()
|
|
for _, want := range []string{
|
|
"sidegrade", // the verdict chip
|
|
"vs worn Flame Tongue", // what it's measured against
|
|
"5% damage", // the better delta ("+5%" — leading + is escaped to +)
|
|
"-4 HP", // the worse delta (minus is not escaped)
|
|
"cmp-delta-up", // the gain is coloured as a gain
|
|
"cmp-delta-down", // the loss as a loss
|
|
} {
|
|
if !strings.Contains(owner, want) {
|
|
t.Errorf("owner compare card missing %q", want)
|
|
}
|
|
}
|
|
|
|
// The compare rides the private inventory, so an anonymous visitor never sees it.
|
|
anon := getWho(t, s, "tok-josie", "").Body.String()
|
|
if strings.Contains(anon, "sidegrade") || strings.Contains(anon, "Blazing Brand") {
|
|
t.Error("compare card leaked onto the public page")
|
|
}
|
|
}
|
|
|
|
// TestWhoSelfUnlockCaseInsensitive: Authentik may hand back a mixed-case
|
|
// username; the localpart it maps to is lowercase. The owner must still unlock.
|
|
func TestWhoSelfUnlockCaseInsensitive(t *testing.T) {
|
|
s := seedWho(t, "josie")
|
|
|
|
w := getWho(t, s, "tok-josie", "Josie") // capital-J session
|
|
if !strings.Contains(w.Body.String(), "Iron Ore") {
|
|
t.Error("a mixed-case owner username failed to unlock their own self-view")
|
|
}
|
|
}
|
|
|
|
// TestWhoOtherUserNoUnlock: a different signed-in player sees only the public
|
|
// sheet on someone else's page — the ownership join must not leak across users.
|
|
func TestWhoOtherUserNoUnlock(t *testing.T) {
|
|
s := seedWho(t, "josie")
|
|
|
|
w := getWho(t, s, "tok-josie", "quack") // signed in, but not the owner
|
|
if w.Code != 200 {
|
|
t.Fatalf("other-user GET who = %d, want 200", w.Code)
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "Josie") {
|
|
t.Error("public sheet missing for a signed-in non-owner")
|
|
}
|
|
for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
|
|
if strings.Contains(body, leak) {
|
|
t.Errorf("a non-owner unlocked private detail %q", leak)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWhoOffBoardIs404: a token that isn't on the current board resolves to
|
|
// nothing — the same liveness gate the storefront uses, so a stale or guessed
|
|
// token never renders a page.
|
|
func TestWhoOffBoardIs404(t *testing.T) {
|
|
s := seedWho(t, "josie")
|
|
if w := getWho(t, s, "tok-ghost", ""); w.Code != 404 {
|
|
t.Errorf("off-board token = %d, want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestDetailIngestReplacesAndAuth: the detail push is bearer-gated and swaps the
|
|
// set whole, so a player dropped from a later push loses their self-view.
|
|
func TestDetailIngestReplacesAndAuth(t *testing.T) {
|
|
s := seedWho(t, "josie")
|
|
|
|
if w := postDetail(t, s, "wrong", detailPush{SnapshotAt: time.Now().Unix()}); w.Code != 401 {
|
|
t.Errorf("bad bearer = %d, want 401", w.Code)
|
|
}
|
|
|
|
// A later push without Josie: her self-view must be gone, but the public
|
|
// board (a separate keyspace) is untouched, so her page still renders public.
|
|
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: time.Now().Unix() + 60}); w.Code != 200 {
|
|
t.Fatalf("empty detail push = %d, want 200", w.Code)
|
|
}
|
|
body := getWho(t, s, "tok-josie", "josie").Body.String()
|
|
if strings.Contains(body, "Iron Ore") {
|
|
t.Error("owner still unlocked after her detail was dropped from the push")
|
|
}
|
|
if !strings.Contains(body, "Josie") {
|
|
t.Error("public page vanished when only the private detail was dropped")
|
|
}
|
|
}
|
|
|
|
// TestWhoHistoryPanels: the record and the trail render for an adventurer with
|
|
// facts on file. Driven through the real template and the real ingest path, so a
|
|
// field slip in either 500s here rather than in prod.
|
|
func TestWhoHistoryPanels(t *testing.T) {
|
|
s := seedWho(t, "josie")
|
|
seedHistory(t, s)
|
|
|
|
w := getWho(t, s, "tok-josie", "")
|
|
if w.Code != 200 {
|
|
t.Fatalf("who = %d: %s", w.Code, w.Body.String())
|
|
}
|
|
body := w.Body.String()
|
|
for _, want := range []string{
|
|
"The record",
|
|
"The Rotmother", // the boss tally
|
|
"bosses down",
|
|
"realm-first", // the boss_first got flagged
|
|
"level 14", // the milestone chip
|
|
"treasure", // the treasure stat tile
|
|
"Treasures found", // the showcase panel
|
|
"Crown of the Drowned King", // the named hoard
|
|
"first hoard", // the realm-first callout picked it up
|
|
"The trail",
|
|
"/adventure/treasure_found:e:5", // the trail links back to the treasure dispatch
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("history render missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWhoHistoryAbsent: an adventurer with no facts on file gets no empty
|
|
// scaffolding. Every veteran looks like this the day this ships — their past is
|
|
// prose in the feed and was never counted — so the blank state has to be a clean
|
|
// absence, not a wall of zeroes.
|
|
func TestWhoHistoryAbsent(t *testing.T) {
|
|
s := seedWho(t, "josie")
|
|
|
|
body := getWho(t, s, "tok-josie", "").Body.String()
|
|
for _, leak := range []string{"The record", "The trail", "bosses down"} {
|
|
if strings.Contains(body, leak) {
|
|
t.Errorf("no-history page should not render %q", leak)
|
|
}
|
|
}
|
|
}
|