gogobee now splits the realm's first-ever clear (zone_first, priority)
from a later repeat (zone_clear, bulletin) so a repeat is never filed as
a first-ever. Handle both:
- renderAdventure: zone_first keeps the "cleared for the very first
time" headline; zone_clear gets the personal "{subject} clears {zone}"
headline, sharing the lede. A tier fallback still handles a legacy
zone_first that predates the split.
- advEventMeta: zone_clear -> "Zone cleared" (distinct from zone_first's
"First clear"), so a repeat's permalink page and emblem aren't stamped
"First clear".
The GUID contract is otherwise unchanged: prefix == event_type, so the
permalink label derives correctly. Test: TestRenderZoneTaxonomy.
Deploy this before the matching gogobee change — a zone_clear fact hits
an un-updated Pete as an unknown event_type (400).
314 lines
11 KiB
Go
314 lines
11 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)
|
|
}
|
|
}
|