gogobee already sends boss/opponent/zone/outcome/level on every dispatch. renderAdventure melted them into prose and only the prose was persisted, so "how many bosses has she downed" was answerable only by parsing English back out of a headline. adventure_events keeps the fact as fact. It's the one adventure table that's a log rather than a snapshot: the roster answers where Josie is now and is replaced every tick; this answers what she has ever done, which no snapshot can. INSERT OR IGNORE on the guid because gogobee retries a fact whose ack it lost, and this is the only adventure store where a duplicate is permanently wrong — the roster forgives one by replacing itself, a double-counted kill is in the tally forever. On top of it the who page grows two public sections, counted from dispatches that were already public: the record (tallies, per-boss and per-zone, realm firsts, milestones) and the trail (the last 40 facts, each linking to the dispatch that told it). One read feeds both — the pool is MaxOpenConns(1), so six COUNT queries would serialize for an answer that fits in memory. Only the trail is capped. A limit on the read would truncate a *tally* rather than a list, and a veteran's kill count frozen at 40 reads as a fact instead of a missing page. Only the subject of a fact earns a trophy: a duel you lost still names you, and it belongs on your trail but not in your record. Trophies count forward only. Every adventurer's past is prose in the feed and can't be counted back out, so they show no record until they next do something — a clean absence rather than a wall of zeroes. No treasures: there's no loot fact on the wire, and inventory is current-state with no history, so counting the vault would score a bought sword as a trophy. Needs a fact type upstream.
269 lines
9.4 KiB
Go
269 lines
9.4 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}},
|
|
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},
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
"The trail",
|
|
"/adventure/boss_first:a:1", // the trail links back to the 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)
|
|
}
|
|
}
|
|
}
|