Files
Pete/internal/web/adventure_test.go
prosolis e85ebe56f7 news: Pete learns to report a contract killing
gogobee's Mischief Makers ships four new event types — a hit going out, the
target walking away from it, the target not walking away, and the monster
turning up to an empty dungeon. Pete 400s an unknown event_type, gogobee retries
and then parks the bulletin forever, so this deploys first.

The anonymity is the story, not an implementation detail. A contract is
anonymous unless the buyer paid extra to sign it, so the lede reports the money
and pointedly not the name Pete doesn't have. A survival unseals the buyer
whether or not they signed — that exposure is the only brake on casual griefing,
and it's the one beat worth leading with. Being maimed buys you nothing: an
anonymous buyer stays anonymous when the contract lands.
2026-07-13 20:03:28 -07:00

372 lines
14 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)
}
}
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)
}
}
}