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. 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, "