A multi-session build turning Pete's read-only web UI into something people return to. Five phases, signed-in features keyed off the OIDC subject; anonymous visitors keep the reverse-chron feed and localStorage-only state. Phase 1 — per-user read + bookmark state: user_story_state table + storage/userstate.go; auth-gated /api/read, /api/bookmark, /api/state and a /bookmarks page; reader.js syncs state server-side for signed-in users. Also hides the Matrix-posting UI when posting.enabled=false (web-only mode). Phase 2 — outbound feeds: storage.ListForFeed + web/feed.go hand-build RSS 2.0 (content:encoded) and JSON Feed 1.1 (no new dep); /feed.xml, /feed.json and per-channel variants; <link rel=alternate> discovery tags. Phase 3 — "For you" + related: storage/rank.go scores recent unread candidates by channel/source affinity + recency decay; RelatedStories via FTS5. ForYou rail + /for-you page; public /api/related feeds the reader's "You might also like". Phase 4 — source-health dashboard: source_health table + storage/sourcehealth.go (RecordPollResult, ListSourceHealth, SourceContentStats), written by the poller; admin-gated /status page behind web.admin_subs. Phase 5 — PWA + offline reader + Web Push: root-scoped manifest.webmanifest and sw.js (app-shell precache, /api/article runtime cache for offline reading, offline fallback, push/notificationclick handlers); PNG icons from pete.avif; pwa.js registers the SW and drives a notifications toggle. Web Push adds webpush-go, a [web.push] config block (pete -genvapid mints VAPID keys), a push_subscriptions table, auth-gated subscribe/unsubscribe endpoints, and a digest sender that pings each subscriber "N new stories" past their watermark, honoring disabled-sources and pruning gone endpoints. Tests added beside each new storage/web file; go test ./... and go vet clean.
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)
|
|
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)
|
|
}
|
|
}
|