Every dispatch rendered the same image: one violet gradient, a swapped
emoji, a label. A death, a first-ever clear and a legendary hoard were
visually identical — and this image is not decoration. It is the
og:image on every link Pete puts in Matrix and the thumbnail on every
feed card. The most interesting thing that has ever happened in the
realm looked exactly like the most routine.
The card is now keyed on the dispatch GUID instead of the event type,
so the renderer can read the fact behind it and put the actual nouns on
it — the boss's name, the zone, the item, the level. Each family gets
its own palette, and treasure is tinted by the rarity gogobee already
computes and currently spends on an adjective in a sentence.
A realm-first gets visible ceremony. The priority/bulletin split is
already computed upstream and until now it only decided whether Matrix
got pinged; a thing nobody has ever done should also look different
from the ninth time somebody did it. It earns a ribbon on the card and
a badge and a heavier ring in the feed.
Siege cards carry the HP bar, which is the W1 deferral landing. The
siege fact has the boss and the defender count but never the HP, so the
bar comes from the war-room snapshot: the live row while that boss is
still camped, the history once it has closed. When neither has it yet —
the real two-minute window between a win being filed and the push that
explains it — the card renders barless rather than wrong, and caches
for five minutes instead of a day so the unfurl isn't pinned that way.
Nothing needs backfilling. A story with a type-keyed image_url still
serves, and a dispatch with no stored fact degrades to the old emblem.
Two things learned by looking at the rendered output rather than at the
tests, both of which unit tests would never have caught: the feed
thumbnail is a 16/10 crop of a 1200x630 card and was eating the chip
("EGENDARY"), hence advSafeX; and an empty bar under a victory headline
reads at a glance as a wipe, hence the event-aware caption.
Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
554 lines
21 KiB
Go
554 lines
21 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)
|
|
|
|
// A bare event type still renders — that is what every story ingested before
|
|
// the card became guid-keyed has in its image_url column, and those links
|
|
// must not start 404ing.
|
|
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 keys the card image on the GUID, not the event type: the renderer
|
|
// reads the fact behind the dispatch so it can name the zone and the level
|
|
// instead of drawing the identical emblem for every death.
|
|
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:abc:1000.svg" {
|
|
t.Errorf("story image = %q", got.ImageURL)
|
|
}
|
|
|
|
// And that card carries the nouns.
|
|
greq := httptest.NewRequest("GET", "/adventure/art/death:abc:1000.svg", nil)
|
|
greq.SetPathValue("type", "death:abc:1000.svg")
|
|
grw := httptest.NewRecorder()
|
|
s.handleAdventureArt(grw, greq)
|
|
gb := grw.Body.String()
|
|
if !strings.Contains(gb, "Brannigan") || !strings.Contains(gb, "the Underforge") || !strings.Contains(gb, "level 4") {
|
|
t.Errorf("guid card missing the fact's nouns: %s", gb)
|
|
}
|
|
|
|
// Permalink page is noindex with an og:image pointing at that same card.
|
|
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:abc:1000.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
|
|
// untemplated type no longer 400s — it publishes on the neutral fallback (see
|
|
// TestUnknownEventTypePublishes) — so what is at stake here is voice, not data
|
|
// loss: these four carry the anonymity mechanic, and the generic fallback would
|
|
// strip out the part that makes it work.
|
|
//
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRenderCompanionHire pins the template whose absence was a live bug.
|
|
//
|
|
// gogobee has emitted companion_hire from `!expedition hire` since the combat-
|
|
// engine work landed (expedition_companion_cmd.go). Pete had no case for it, so
|
|
// every one of those dispatches 400'd, retried to peteclient's cap, and parked
|
|
// forever. Nothing surfaced the loss: the game logged a successful emit, the
|
|
// queue row just never sent.
|
|
//
|
|
// The unknown-type inversion (TestUnknownEventTypePublishes) means a repeat of
|
|
// this costs a thin card rather than a deleted event — but the template is still
|
|
// the point, and this test is what says so.
|
|
func TestRenderCompanionHire(t *testing.T) {
|
|
f := AdvFact{EventType: "companion_hire", Tier: "bulletin",
|
|
Subject: "Josie", ClassRace: "Cleric", Zone: "holymachina", Level: 14}
|
|
hl, lede, ok := renderAdventure(f)
|
|
if !ok {
|
|
t.Fatal("companion_hire did not render — this is the bug, do not re-break it")
|
|
}
|
|
if !strings.Contains(hl, "cleric") {
|
|
t.Errorf("headline lost the seat Pete is filling: %q", hl)
|
|
}
|
|
if !strings.Contains(lede, "Josie") || !strings.Contains(lede, "holymachina") {
|
|
t.Errorf("lede lost the leader or the zone: %q", lede)
|
|
}
|
|
// He is talking about himself here, like his duels. Third-person Pete filling
|
|
// in as a cleric reads as someone else reporting on him.
|
|
if !strings.Contains(lede, "I'm") && !strings.Contains(lede, "I ") {
|
|
t.Errorf("companion_hire should be first-person Pete: %q", lede)
|
|
}
|
|
// "needed a cleric", never "needed cleric".
|
|
if !strings.Contains(lede, "a cleric") {
|
|
t.Errorf("seat needs its article in the lede: %q", lede)
|
|
}
|
|
if lbl, _ := advEventMeta("companion_hire"); lbl == "Dispatch" {
|
|
t.Error("companion_hire has no permalink label")
|
|
}
|
|
|
|
// A missing class must not produce "needed a ." — the fallback seat carries
|
|
// its own article.
|
|
bare := AdvFact{EventType: "companion_hire", Subject: "Josie"}
|
|
_, bareLede, ok := renderAdventure(bare)
|
|
if !ok {
|
|
t.Fatal("companion_hire with no class did not render")
|
|
}
|
|
if strings.Contains(bareLede, "a .") || strings.Contains(bareLede, "needed ") {
|
|
t.Errorf("empty class produced malformed prose: %q", bareLede)
|
|
}
|
|
}
|
|
|
|
// TestUnknownEventTypePublishes is the regression for the whole class of bug.
|
|
//
|
|
// An event type Pete has no template for must PUBLISH, not 400. A 400 is retried
|
|
// to peteclient's cap and then parked forever, so rejecting an unrecognised type
|
|
// does not defer the event — it deletes it, permanently, and that is how
|
|
// companion_hire went missing. The site can carry a thin card; it cannot recover
|
|
// a dispatch gogobee has given up on.
|
|
func TestUnknownEventTypePublishes(t *testing.T) {
|
|
const token = "s3cret-token"
|
|
s, posted := newAdvServer(t, token)
|
|
|
|
f := AdvFact{
|
|
GUID: "brand_new_thing:abc:5000", EventType: "brand_new_thing",
|
|
Tier: "priority", // claims priority, and still must not interrupt Matrix
|
|
Subject: "Josie", Actors: []string{"Josie"}, Zone: "holymachina",
|
|
OccurredAt: 5000,
|
|
}
|
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
|
t.Fatalf("unknown event_type: status = %d, want 200 — a 400 parks the dispatch forever", rw.Code)
|
|
}
|
|
|
|
got, err := storage.GetStoryByGUID("brand_new_thing:abc:5000")
|
|
if err != nil || got == nil {
|
|
t.Fatal("unknown event_type was not stored; the event is lost")
|
|
}
|
|
if !strings.Contains(got.Headline+got.Lede, "Josie") {
|
|
t.Errorf("fallback dropped the subject: %q / %q", got.Headline, got.Lede)
|
|
}
|
|
|
|
// Untemplated types never post live, whatever tier they claim: a thin card on
|
|
// the site is cheap, a thin ping to everyone in the room is not. It still
|
|
// reaches Matrix via the daily digest.
|
|
if len(*posted) != 0 {
|
|
t.Errorf("untemplated priority fact posted live to Matrix: %+v", *posted)
|
|
}
|
|
|
|
// And the operator can see what Pete needs to learn.
|
|
if AdvUnknownTypeCounts()["brand_new_thing"] == 0 {
|
|
t.Error("unknown type was not counted for the status page")
|
|
}
|
|
}
|
|
|
|
// TestUnknownEventTypeUsesProse: the inversion is not a downgrade in practice.
|
|
// gogobee authors LLM prose from the fact's fields with no per-type switch
|
|
// (authorDispatch), so a type Pete has never heard of still arrives with a real
|
|
// headline and lede — and must be allowed to use them. The thin fallback is only
|
|
// for when the model is off or the prose-guard rejected the output.
|
|
func TestUnknownEventTypeUsesProse(t *testing.T) {
|
|
const token = "s3cret-token"
|
|
s, _ := newAdvServer(t, token)
|
|
|
|
f := AdvFact{
|
|
GUID: "another_new_thing:def:6000", EventType: "another_new_thing",
|
|
Tier: "bulletin", Subject: "Josie", Actors: []string{"Josie"},
|
|
OccurredAt: 6000,
|
|
Headline: "Josie has taken up beekeeping.",
|
|
Lede: "Not the news I expected today, but there she is, out behind the chapel with a smoker and a very calm expression.",
|
|
}
|
|
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
|
t.Fatalf("status = %d, want 200", rw.Code)
|
|
}
|
|
got, err := storage.GetStoryByGUID("another_new_thing:def:6000")
|
|
if err != nil || got == nil {
|
|
t.Fatal("story not stored")
|
|
}
|
|
if got.Headline != f.Headline {
|
|
t.Errorf("LLM prose was discarded for an unknown type: got %q", got.Headline)
|
|
}
|
|
}
|
|
|
|
// TestUnknownEventTypeStillGuarded: publishing an untemplated type must not
|
|
// weaken the name guards. The fact-guard rejection is still a 400, because a
|
|
// fact naming someone it did not authorise is genuinely invalid — unlike a type
|
|
// Pete simply hasn't learned to phrase.
|
|
func TestUnknownEventTypeStillGuarded(t *testing.T) {
|
|
const token = "s3cret-token"
|
|
s, _ := newAdvServer(t, token)
|
|
|
|
f := AdvFact{
|
|
GUID: "unknowable:evil:1", EventType: "unknowable",
|
|
Subject: "Josie", Actors: []string{"Brannigan"}, OccurredAt: 1,
|
|
}
|
|
if rw := postFact(t, s, token, f); rw.Code != 400 {
|
|
t.Errorf("unguarded subject on an unknown type: status = %d, want 400", rw.Code)
|
|
}
|
|
if storage.IsGUIDSeen("unknowable:evil:1") {
|
|
t.Error("fact-guard rejection was stored anyway")
|
|
}
|
|
}
|