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 & 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, "
First paragraph.
") { 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) } }