Files
Pete/internal/web/reader_test.go
prosolis 71f7050f41 Add personalization, outbound feeds, and PWA/push to the web UI
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.
2026-07-07 00:07:19 -07:00

96 lines
2.7 KiB
Go

package web
import (
"encoding/json"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
// TestReaderCardDataAndArticleAPI exercises the reader-mode path end to end: a
// classified story renders a card carrying its id + headline data attributes,
// and /api/article returns the stored full text for that id.
func TestReaderCardDataAndArticleAPI(t *testing.T) {
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "reader.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
story := &storage.Story{
GUID: "reader-e2e",
Headline: "A Distinctive Reader Headline",
Lede: "The lede.",
Content: "Opening paragraph of the piece.\n\nA second paragraph with more detail.",
ArticleURL: "https://example.com/story",
Source: "Example Wire",
Channel: "tech",
Classified: true,
SeenAt: time.Now().Unix(),
}
if err := storage.InsertStory(story); err != nil {
t.Fatal(err)
}
var id int64
if err := storage.Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, story.GUID).Scan(&id); err != nil {
t.Fatal(err)
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
if err != nil {
t.Fatal(err)
}
// Index page renders the card with the reader data attributes.
rw := httptest.NewRecorder()
s.handleIndex(rw, httptest.NewRequest("GET", "/", nil))
body := rw.Body.String()
if rw.Code != 200 {
t.Fatalf("index status = %d", rw.Code)
}
for _, want := range []string{
`data-id="` + strconv.FormatInt(id, 10) + `"`,
`data-headline="A Distinctive Reader Headline"`,
`data-story-card`,
} {
if !strings.Contains(body, want) {
t.Errorf("index HTML missing %q", want)
}
}
// The article endpoint returns the stored content for that id.
rw2 := httptest.NewRecorder()
s.handleArticle(rw2, httptest.NewRequest("GET", "/api/article?id="+strconv.FormatInt(id, 10), nil))
if rw2.Code != 200 {
t.Fatalf("article status = %d body=%s", rw2.Code, rw2.Body.String())
}
var got struct {
Content string `json:"content"`
Lede string `json:"lede"`
}
if err := json.Unmarshal(rw2.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Content != story.Content {
t.Errorf("content = %q, want %q", got.Content, story.Content)
}
// A bad id is a 400, an unknown id is a 404.
for _, tc := range []struct {
q string
code int
}{{"id=0", 400}, {"id=abc", 400}, {"id=999999", 404}} {
w := httptest.NewRecorder()
s.handleArticle(w, httptest.NewRequest("GET", "/api/article?"+tc.q, nil))
if w.Code != tc.code {
t.Errorf("article?%s status = %d, want %d", tc.q, w.Code, tc.code)
}
}
}