A treasure_found fact turns loot into a trophy: a story-grade find lands on the who page as a stat tile, a named row in a Treasures found showcase, and a trail entry linking back to the dispatch. A realm-first hoard rides the priority tier the way zone_first does, and gets the same star and callout. The item name travels in the fact's stakes field, which the events log now keeps (a new nullable column, backfilled to NULL for older rows). Counting is only ever from the fact, never the vault snapshot, so a bought sword is never a trophy and a history that predates the fact is a clean zero. This is a new event_type, so Pete's ingest must be deployed before gogobee emits one, or the first finds park forever on the retry ladder.
399 lines
15 KiB
Go
399 lines
15 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"pete/internal/config"
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// newAdvServer builds a web server with the adventure seam enabled and a
|
|
// capturing priority poster, backed by a fresh temp DB.
|
|
func newAdvServer(t *testing.T, token string) (*Server, *[]AdvPost) {
|
|
t.Helper()
|
|
storage.Close()
|
|
if err := storage.Init(filepath.Join(t.TempDir(), "adv.db")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { storage.Close() })
|
|
|
|
var posted []AdvPost
|
|
adv := config.AdventureConfig{Enabled: true, IngestToken: token, Channel: "adventure"}
|
|
// Mirror the production poster's side effect: queue.PostNow records a
|
|
// post_log row, which is exactly what marks a beat "posted" and thus
|
|
// excludes it from the bulletin digest.
|
|
poster := func(p AdvPost) {
|
|
posted = append(posted, p)
|
|
storage.InsertPostLog(p.GUID, "adventure", p.GUID, "", false)
|
|
}
|
|
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example"},
|
|
nil, true, adv, poster)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s, &posted
|
|
}
|
|
|
|
func postFact(t *testing.T, s *Server, token string, f AdvFact) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
body, _ := json.Marshal(f)
|
|
req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
rw := httptest.NewRecorder()
|
|
s.handleAdventureIngest(rw, req)
|
|
return rw
|
|
}
|
|
|
|
// TestAdventureIngestEndToEnd covers the seam: a priority death fact is
|
|
// bearer-accepted, templated, stored as an adventure story, and posted live.
|
|
func TestAdventureIngestEndToEnd(t *testing.T) {
|
|
const token = "s3cret-token"
|
|
s, posted := newAdvServer(t, token)
|
|
|
|
f := AdvFact{
|
|
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
|
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
|
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
|
|
}
|
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
|
t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
|
|
}
|
|
|
|
got, err := storage.GetStoryByGUID("death:abc:1000")
|
|
if err != nil || got == nil {
|
|
t.Fatalf("story not stored: %v", err)
|
|
}
|
|
if got.Channel != "adventure" || got.Source != advSource {
|
|
t.Errorf("channel/source = %q/%q", got.Channel, got.Source)
|
|
}
|
|
if got.Headline != "We lost Brannigan in the Underforge." {
|
|
t.Errorf("headline = %q", got.Headline)
|
|
}
|
|
if got.ArticleURL != "https://news.example/adventure/death:abc:1000" {
|
|
t.Errorf("article_url = %q", got.ArticleURL)
|
|
}
|
|
if len(*posted) != 1 || (*posted)[0].GUID != f.GUID {
|
|
t.Fatalf("priority post not delivered: %+v", *posted)
|
|
}
|
|
|
|
// Idempotent re-delivery: no error, no second post.
|
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
|
t.Fatalf("dup ingest status = %d", rw.Code)
|
|
}
|
|
if len(*posted) != 1 {
|
|
t.Errorf("duplicate fact re-posted: %d posts", len(*posted))
|
|
}
|
|
}
|
|
|
|
// TestAdventurePermalink renders the per-story page the article_url points at:
|
|
// an ingested dispatch must be fetchable at /adventure/{guid} with its headline
|
|
// and body, and an unknown guid must 404.
|
|
func TestAdventurePermalink(t *testing.T) {
|
|
const token = "t"
|
|
s, _ := newAdvServer(t, token)
|
|
f := AdvFact{
|
|
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
|
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
|
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
|
|
}
|
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
|
t.Fatalf("ingest status = %d", rw.Code)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
|
|
req.SetPathValue("guid", "death:abc:1000")
|
|
rw := httptest.NewRecorder()
|
|
s.handleAdventureStory(rw, req)
|
|
if rw.Code != 200 {
|
|
t.Fatalf("permalink status = %d body=%s", rw.Code, rw.Body.String())
|
|
}
|
|
body := rw.Body.String()
|
|
if !bytes.Contains([]byte(body), []byte("We lost Brannigan in the Underforge.")) {
|
|
t.Errorf("permalink missing headline; body=%s", body)
|
|
}
|
|
if !bytes.Contains([]byte(body), []byte("In memoriam")) {
|
|
t.Errorf("permalink missing event label; body=%s", body)
|
|
}
|
|
|
|
// Unknown guid 404s.
|
|
req2 := httptest.NewRequest("GET", "/adventure/nope:1", nil)
|
|
req2.SetPathValue("guid", "nope:1")
|
|
rw2 := httptest.NewRecorder()
|
|
s.handleAdventureStory(rw2, req2)
|
|
if rw2.Code != 404 {
|
|
t.Errorf("unknown guid status = %d, want 404", rw2.Code)
|
|
}
|
|
}
|
|
|
|
// TestDurUntilNextHour targets the next UTC occurrence of the digest hour and
|
|
// rolls to tomorrow when it's already that hour (so a restart can't double-fire).
|
|
func TestDurUntilNextHour(t *testing.T) {
|
|
// 14:30 UTC, targeting 17:00 → 2h30m today.
|
|
now := time.Date(2026, 7, 11, 14, 30, 0, 0, time.UTC)
|
|
if got := durUntilNextHour(now, 17); got != 2*time.Hour+30*time.Minute {
|
|
t.Errorf("before hour: got %v", got)
|
|
}
|
|
// 17:00 exactly → tomorrow's 17:00 (24h), not zero.
|
|
now = time.Date(2026, 7, 11, 17, 0, 0, 0, time.UTC)
|
|
if got := durUntilNextHour(now, 17); got != 24*time.Hour {
|
|
t.Errorf("at hour: got %v", got)
|
|
}
|
|
// 20:00, targeting 17:00 → tomorrow, 21h.
|
|
now = time.Date(2026, 7, 11, 20, 0, 0, 0, time.UTC)
|
|
if got := durUntilNextHour(now, 17); got != 21*time.Hour {
|
|
t.Errorf("after hour: got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestAdventureDigest covers the batched path: bulletins (no live post) are
|
|
// collected into one roundup, priority beats are excluded (they already posted),
|
|
// digested bulletins don't recur, and an empty window stays silent.
|
|
func TestAdventureDigest(t *testing.T) {
|
|
const token = "t"
|
|
s, posted := newAdvServer(t, token)
|
|
now := time.Now()
|
|
|
|
// Two bulletins + one priority (which posts live and must be excluded).
|
|
postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
|
|
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
|
|
postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin",
|
|
Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()})
|
|
postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
|
|
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
|
|
if len(*posted) != 1 {
|
|
t.Fatalf("setup: priority posts = %d, want 1", len(*posted))
|
|
}
|
|
|
|
s.postDailyDigest(now.UTC())
|
|
if len(*posted) != 2 {
|
|
t.Fatalf("digest not posted: total posts = %d, want 2", len(*posted))
|
|
}
|
|
dg := (*posted)[1]
|
|
if !strings.Contains(dg.Headline, "2 dispatches") {
|
|
t.Errorf("digest headline = %q", dg.Headline)
|
|
}
|
|
if !strings.HasPrefix(dg.GUID, "adv-digest:") {
|
|
t.Errorf("digest guid = %q", dg.GUID)
|
|
}
|
|
|
|
// Re-running finds nothing new (bulletins marked digested).
|
|
s.postDailyDigest(now.UTC())
|
|
if len(*posted) != 2 {
|
|
t.Errorf("digest re-posted: total = %d, want 2", len(*posted))
|
|
}
|
|
}
|
|
|
|
// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
|
|
// returns a themed SVG, ingested cards carry its local path, and the permalink
|
|
// page is noindex with an og:image.
|
|
// TestRenderZoneTaxonomy: a realm-first (zone_first) reads as first-ever; a
|
|
// repeat (zone_clear) reads as a personal clear, not a mis-labeled "first".
|
|
func TestRenderZoneTaxonomy(t *testing.T) {
|
|
first := AdvFact{EventType: "zone_first", Tier: "priority", Subject: "Brannigan",
|
|
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
|
|
hl, _, ok := renderAdventure(first)
|
|
if !ok || !strings.Contains(hl, "very first time") {
|
|
t.Errorf("zone_first headline = %q (ok=%v)", hl, ok)
|
|
}
|
|
|
|
repeat := AdvFact{EventType: "zone_clear", Tier: "bulletin", Subject: "Brannigan",
|
|
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
|
|
hl2, _, ok := renderAdventure(repeat)
|
|
if !ok || !strings.Contains(hl2, "Brannigan clears") || strings.Contains(hl2, "first") {
|
|
t.Errorf("zone_clear headline = %q (ok=%v)", hl2, ok)
|
|
}
|
|
|
|
// The permalink label distinguishes the two, so a repeat's page isn't stamped
|
|
// "First clear".
|
|
if lbl, _ := advEventMeta("zone_clear"); lbl == "First clear" {
|
|
t.Errorf("zone_clear meta label = %q, want distinct from first clear", lbl)
|
|
}
|
|
}
|
|
|
|
// TestRenderTreasure: a story-grade find names the item and its zone; a
|
|
// priority find is billed as a realm-first, and the rarity from outcome rides
|
|
// into the lede.
|
|
func TestRenderTreasure(t *testing.T) {
|
|
hoard := AdvFact{EventType: "treasure_found", Tier: "priority", Subject: "Josie",
|
|
Zone: "The Ossuary", Stakes: "Crown of the Drowned King", Outcome: "legendary", Level: 7}
|
|
hl, lede, ok := renderAdventure(hoard)
|
|
if !ok || !strings.Contains(hl, "First ever") || !strings.Contains(hl, "Crown of the Drowned King") {
|
|
t.Errorf("hoard headline = %q (ok=%v)", hl, ok)
|
|
}
|
|
if !strings.Contains(lede, "legendary") {
|
|
t.Errorf("hoard lede dropped the rarity: %q", lede)
|
|
}
|
|
|
|
find := AdvFact{EventType: "treasure_found", Tier: "bulletin", Subject: "Josie",
|
|
Zone: "The Sump", Stakes: "Ring of Nine Sorrows"}
|
|
hl2, _, ok := renderAdventure(find)
|
|
if !ok || !strings.Contains(hl2, "Josie") || !strings.Contains(hl2, "Ring of Nine Sorrows") ||
|
|
!strings.Contains(hl2, "The Sump") || strings.Contains(hl2, "First ever") {
|
|
t.Errorf("plain find headline = %q (ok=%v)", hl2, ok)
|
|
}
|
|
|
|
if lbl, emoji := advEventMeta("treasure_found"); lbl != "Treasure" || emoji == "" {
|
|
t.Errorf("treasure meta = %q/%q", lbl, emoji)
|
|
}
|
|
}
|
|
|
|
func TestAdventureArtAndMeta(t *testing.T) {
|
|
const token = "t"
|
|
s, _ := newAdvServer(t, token)
|
|
|
|
// Emblem endpoint returns SVG with the event's emoji.
|
|
areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
|
|
areq.SetPathValue("type", "death.svg")
|
|
arw := httptest.NewRecorder()
|
|
s.handleAdventureArt(arw, areq)
|
|
if arw.Code != 200 {
|
|
t.Fatalf("art status = %d", arw.Code)
|
|
}
|
|
if ct := arw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/svg+xml") {
|
|
t.Errorf("art content-type = %q", ct)
|
|
}
|
|
if b := arw.Body.String(); !strings.Contains(b, "🪦") || !strings.Contains(b, "<svg") {
|
|
t.Errorf("art body missing emblem: %s", b)
|
|
}
|
|
|
|
// Ingest sets the card image to the local emblem path.
|
|
f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
|
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
|
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
|
t.Fatalf("ingest status = %d", rw.Code)
|
|
}
|
|
got, _ := storage.GetStoryByGUID("death:abc:1000")
|
|
if got == nil || got.ImageURL != "/adventure/art/death.svg" {
|
|
t.Errorf("story image = %q", got.ImageURL)
|
|
}
|
|
|
|
// Permalink page is noindex with an og:image.
|
|
preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
|
|
preq.SetPathValue("guid", "death:abc:1000")
|
|
prw := httptest.NewRecorder()
|
|
s.handleAdventureStory(prw, preq)
|
|
body := prw.Body.String()
|
|
if !strings.Contains(body, `name="robots" content="noindex"`) {
|
|
t.Error("permalink not noindex")
|
|
}
|
|
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death.svg"`) {
|
|
t.Errorf("permalink missing og:image; body=%s", body)
|
|
}
|
|
}
|
|
|
|
// TestAdventureIngestBearer rejects missing/wrong tokens.
|
|
func TestAdventureIngestBearer(t *testing.T) {
|
|
s, _ := newAdvServer(t, "right")
|
|
f := AdvFact{GUID: "arrival:x:1", EventType: "arrival", Tier: "bulletin", OccurredAt: 1}
|
|
if rw := postFact(t, s, "", f); rw.Code != 401 {
|
|
t.Errorf("no token: status = %d, want 401", rw.Code)
|
|
}
|
|
if rw := postFact(t, s, "wrong", f); rw.Code != 401 {
|
|
t.Errorf("wrong token: status = %d, want 401", rw.Code)
|
|
}
|
|
}
|
|
|
|
// TestAdventureFactGuard rejects a subject not present in the actors allow-list,
|
|
// so a name that slipped the source can't reach a public page.
|
|
func TestAdventureFactGuard(t *testing.T) {
|
|
const token = "t"
|
|
s, posted := newAdvServer(t, token)
|
|
f := AdvFact{
|
|
GUID: "death:evil:1", EventType: "death", Tier: "priority",
|
|
Actors: []string{"Brannigan"}, Subject: "Kif", // Kif not in actors
|
|
Zone: "the Underforge", Level: 3, OccurredAt: 1,
|
|
}
|
|
if rw := postFact(t, s, token, f); rw.Code != 400 {
|
|
t.Errorf("fact-guard: status = %d, want 400", rw.Code)
|
|
}
|
|
if storage.IsGUIDSeen("death:evil:1") {
|
|
t.Error("guarded fact was stored")
|
|
}
|
|
if len(*posted) != 0 {
|
|
t.Error("guarded fact was posted")
|
|
}
|
|
}
|
|
|
|
// TestAdventureDisabled 404s when the seam is off.
|
|
func TestAdventureDisabled(t *testing.T) {
|
|
storage.Close()
|
|
if err := storage.Init(filepath.Join(t.TempDir(), "off.db")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { storage.Close() })
|
|
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := AdvFact{GUID: "x", EventType: "arrival", OccurredAt: 1}
|
|
if rw := postFact(t, s, "anything", f); rw.Code != 404 {
|
|
t.Errorf("disabled: status = %d, want 404", rw.Code)
|
|
}
|
|
}
|
|
|
|
// TestRenderMischief: gogobee's four mischief event types must all render. An
|
|
// unknown event_type is a 400 at ingest, which gogobee retries and then parks
|
|
// forever — so "Pete deploys first" only helps if Pete actually knows the types.
|
|
//
|
|
// It also pins the anonymity contract, which is the feature's whole social
|
|
// engine: an unsigned contract must not name the buyer, and a survival must.
|
|
func TestRenderMischief(t *testing.T) {
|
|
anon := AdvFact{EventType: "mischief_contract", Tier: "priority",
|
|
Subject: "Josie", Boss: "Elite", Stakes: "€350", Level: 14}
|
|
hl, lede, ok := renderAdventure(anon)
|
|
if !ok {
|
|
t.Fatal("mischief_contract did not render — ingest would 400")
|
|
}
|
|
if strings.Contains(hl+lede, "Brannigan") {
|
|
t.Error("anonymous contract leaked a buyer name")
|
|
}
|
|
if !strings.Contains(hl, "€350") {
|
|
t.Errorf("contract headline lost the stakes: %q", hl)
|
|
}
|
|
|
|
signed := anon
|
|
signed.Opponent = "Brannigan"
|
|
hl, _, ok = renderAdventure(signed)
|
|
if !ok || !strings.Contains(hl, "Brannigan") {
|
|
t.Errorf("signed contract should name the buyer: %q (ok=%v)", hl, ok)
|
|
}
|
|
|
|
// The unseal: a survival names the buyer whether or not they signed.
|
|
survived := AdvFact{EventType: "mischief_survived", Tier: "priority",
|
|
Subject: "Josie", Opponent: "Brannigan", Boss: "Bone Colossus", Stakes: "€228"}
|
|
hl, _, ok = renderAdventure(survived)
|
|
if !ok || !strings.Contains(hl, "Brannigan") {
|
|
t.Errorf("survival must unseal the buyer: %q (ok=%v)", hl, ok)
|
|
}
|
|
|
|
// A downed target with an anonymous buyer stays anonymous — being maimed
|
|
// doesn't buy you the name.
|
|
downed := AdvFact{EventType: "mischief_downed", Tier: "priority",
|
|
Subject: "Josie", Boss: "Bone Colossus", Level: 14}
|
|
hl, lede, ok = renderAdventure(downed)
|
|
if !ok {
|
|
t.Fatal("mischief_downed did not render")
|
|
}
|
|
if strings.Contains(hl+lede, "Brannigan") {
|
|
t.Error("anonymous buyer named on a downed contract")
|
|
}
|
|
|
|
if _, _, ok := renderAdventure(AdvFact{EventType: "mischief_fizzled", Subject: "Josie", Stakes: "€315"}); !ok {
|
|
t.Error("mischief_fizzled did not render")
|
|
}
|
|
|
|
for _, et := range []string{"mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled"} {
|
|
if lbl, _ := advEventMeta(et); lbl == "Dispatch" {
|
|
t.Errorf("%s has no permalink label", et)
|
|
}
|
|
}
|
|
}
|