Pete's side of the Adventure news feed. Receives structured game-event
facts from gogobee, templates them in Pete's warm-reporter voice, and
publishes to a new /adventure section + live Matrix posts.
- adventure.go: bearer ingest + fact-guard + 13 event templates;
/adventure/{guid} permalink (story.html); per-event SVG emblems at
/adventure/art/{type}.svg (card image + og:image); NoPush suppresses
the live Matrix post (cold-start backfill).
- adventure_digest.go: daily BULLETIN roundup at DigestHour (UTC);
unposted-in-48h = bulletins; marks them digested; per-day ?digest= URL
avoids canonical dedup.
- config AdventureConfig (enabled/ingest_token/channel/digest_hour);
web.New takes the seam + a priority poster; started in main.
- adventure theme colors; thumbURL passes through local emblem paths;
adventure pages are noindex (player-named; gap #5).
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
211 lines
6.6 KiB
Go
211 lines
6.6 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"pete/internal/config"
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// TestFeeds exercises the outbound RSS and JSON feeds end to end: a classified
|
|
// story appears in both formats, carrying its canonical link, full content, and
|
|
// a real pubDate; a channel-scoped feed excludes stories from other channels.
|
|
func TestFeeds(t *testing.T) {
|
|
storage.Close()
|
|
if err := storage.Init(filepath.Join(t.TempDir(), "feed.db")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { storage.Close() })
|
|
|
|
pub := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC).Unix()
|
|
tech := &storage.Story{
|
|
GUID: "feed-tech-1",
|
|
Headline: "Chips & Dips: A Tech Tale",
|
|
Lede: "The lede for the tech story.",
|
|
Content: "First paragraph.\n\nSecond paragraph with <html> & symbols.",
|
|
ArticleURL: "https://example.com/tech/story?utm=x",
|
|
URLCanonical: "https://example.com/tech/story",
|
|
Source: "Example Wire",
|
|
Channel: "tech",
|
|
Classified: true,
|
|
SeenAt: time.Now().Unix(),
|
|
PublishedAt: pub,
|
|
}
|
|
gaming := &storage.Story{
|
|
GUID: "feed-gaming-1",
|
|
Headline: "A Gaming Headline",
|
|
Lede: "Gaming lede.",
|
|
ArticleURL: "https://example.com/gaming/story",
|
|
Source: "Play Wire",
|
|
Channel: "gaming",
|
|
Classified: true,
|
|
SeenAt: time.Now().Unix(),
|
|
}
|
|
for _, st := range []*storage.Story{tech, gaming} {
|
|
if err := storage.InsertStory(st); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true, config.AdventureConfig{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// --- Site-wide RSS ---
|
|
rw := httptest.NewRecorder()
|
|
s.handleFeedXML(rw, httptest.NewRequest("GET", "/feed.xml", nil), "")
|
|
if rw.Code != 200 {
|
|
t.Fatalf("rss status = %d", rw.Code)
|
|
}
|
|
if ct := rw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/rss+xml") {
|
|
t.Errorf("rss content-type = %q", ct)
|
|
}
|
|
var rss struct {
|
|
Channel struct {
|
|
Title string `xml:"title"`
|
|
AtomLink struct {
|
|
Href string `xml:"href,attr"`
|
|
} `xml:"link"`
|
|
Items []struct {
|
|
Title string `xml:"title"`
|
|
Link string `xml:"link"`
|
|
GUID string `xml:"guid"`
|
|
PubDate string `xml:"pubDate"`
|
|
Content string `xml:"encoded"` // content:encoded
|
|
} `xml:"item"`
|
|
} `xml:"channel"`
|
|
}
|
|
if err := xml.Unmarshal(rw.Body.Bytes(), &rss); err != nil {
|
|
t.Fatalf("rss did not parse as XML: %v\n%s", err, rw.Body.String())
|
|
}
|
|
if len(rss.Channel.Items) != 2 {
|
|
t.Fatalf("rss items = %d, want 2", len(rss.Channel.Items))
|
|
}
|
|
// Newest-first: PublishedAt March 2026 vs gaming's seen_at (now) — gaming is
|
|
// newer, so it sorts first. Find the tech item to assert on.
|
|
var techItem *struct {
|
|
Title string `xml:"title"`
|
|
Link string `xml:"link"`
|
|
GUID string `xml:"guid"`
|
|
PubDate string `xml:"pubDate"`
|
|
Content string `xml:"encoded"`
|
|
}
|
|
for i := range rss.Channel.Items {
|
|
if rss.Channel.Items[i].GUID == "feed-tech-1" {
|
|
techItem = &rss.Channel.Items[i]
|
|
}
|
|
}
|
|
if techItem == nil {
|
|
t.Fatal("tech item missing from rss")
|
|
}
|
|
if techItem.Link != "https://example.com/tech/story" {
|
|
t.Errorf("rss link = %q, want canonical", techItem.Link)
|
|
}
|
|
if techItem.Title != "Chips & Dips: A Tech Tale" {
|
|
t.Errorf("rss title = %q", techItem.Title)
|
|
}
|
|
if !strings.Contains(techItem.Content, "<p>First paragraph.</p>") {
|
|
t.Errorf("rss content missing paragraph markup: %q", techItem.Content)
|
|
}
|
|
if !strings.Contains(techItem.Content, "<html>") {
|
|
t.Errorf("rss content did not escape embedded markup: %q", techItem.Content)
|
|
}
|
|
if !strings.Contains(techItem.PubDate, "2026") {
|
|
t.Errorf("rss pubDate = %q, want the published date", techItem.PubDate)
|
|
}
|
|
if !strings.Contains(rw.Body.String(), `href="https://news.example/feed.xml"`) {
|
|
t.Errorf("rss missing atom self link with base_url")
|
|
}
|
|
|
|
// --- Site-wide JSON Feed ---
|
|
rw = httptest.NewRecorder()
|
|
s.handleFeedJSON(rw, httptest.NewRequest("GET", "/feed.json", nil), "")
|
|
if rw.Code != 200 {
|
|
t.Fatalf("json status = %d", rw.Code)
|
|
}
|
|
if ct := rw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/feed+json") {
|
|
t.Errorf("json content-type = %q", ct)
|
|
}
|
|
var jf struct {
|
|
Version string `json:"version"`
|
|
FeedURL string `json:"feed_url"`
|
|
HomePageURL string `json:"home_page_url"`
|
|
Items []struct {
|
|
ID string `json:"id"`
|
|
URL string `json:"url"`
|
|
Title string `json:"title"`
|
|
ContentText string `json:"content_text"`
|
|
Tags []string `json:"tags"`
|
|
Authors []struct {
|
|
Name string `json:"name"`
|
|
} `json:"authors"`
|
|
} `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(rw.Body.Bytes(), &jf); err != nil {
|
|
t.Fatalf("json feed did not parse: %v", err)
|
|
}
|
|
if !strings.Contains(jf.Version, "jsonfeed.org") {
|
|
t.Errorf("json version = %q", jf.Version)
|
|
}
|
|
if jf.FeedURL != "https://news.example/feed.json" {
|
|
t.Errorf("json feed_url = %q", jf.FeedURL)
|
|
}
|
|
if len(jf.Items) != 2 {
|
|
t.Fatalf("json items = %d, want 2", len(jf.Items))
|
|
}
|
|
var jTech *struct {
|
|
ID string `json:"id"`
|
|
URL string `json:"url"`
|
|
Title string `json:"title"`
|
|
ContentText string `json:"content_text"`
|
|
Tags []string `json:"tags"`
|
|
Authors []struct {
|
|
Name string `json:"name"`
|
|
} `json:"authors"`
|
|
}
|
|
for i := range jf.Items {
|
|
if jf.Items[i].ID == "feed-tech-1" {
|
|
jTech = &jf.Items[i]
|
|
}
|
|
}
|
|
if jTech == nil {
|
|
t.Fatal("tech item missing from json feed")
|
|
}
|
|
if jTech.URL != "https://example.com/tech/story" {
|
|
t.Errorf("json url = %q, want canonical", jTech.URL)
|
|
}
|
|
if !strings.Contains(jTech.ContentText, "Second paragraph") {
|
|
t.Errorf("json content_text = %q", jTech.ContentText)
|
|
}
|
|
if len(jTech.Tags) != 1 || jTech.Tags[0] != "tech" {
|
|
t.Errorf("json tags = %v", jTech.Tags)
|
|
}
|
|
if len(jTech.Authors) != 1 || jTech.Authors[0].Name != "Example Wire" {
|
|
t.Errorf("json authors = %v", jTech.Authors)
|
|
}
|
|
|
|
// --- Channel-scoped RSS excludes other channels ---
|
|
rw = httptest.NewRecorder()
|
|
s.handleFeedXML(rw, httptest.NewRequest("GET", "/tech/feed.xml", nil), "tech")
|
|
rss.Channel.Items = nil // xml.Unmarshal appends; clear the site-wide items
|
|
if err := xml.Unmarshal(rw.Body.Bytes(), &rss); err != nil {
|
|
t.Fatalf("channel rss parse: %v", err)
|
|
}
|
|
if len(rss.Channel.Items) != 1 {
|
|
t.Fatalf("channel rss items = %d, want 1", len(rss.Channel.Items))
|
|
}
|
|
if rss.Channel.Items[0].GUID != "feed-tech-1" {
|
|
t.Errorf("channel rss wrong item: %q", rss.Channel.Items[0].GUID)
|
|
}
|
|
if !strings.Contains(rss.Channel.Title, "Tech") {
|
|
t.Errorf("channel rss title = %q, want channel name", rss.Channel.Title)
|
|
}
|
|
}
|