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.
127 lines
3.4 KiB
Go
127 lines
3.4 KiB
Go
package storage
|
|
|
|
import "testing"
|
|
|
|
func seedStory(t *testing.T, guid string) int64 {
|
|
t.Helper()
|
|
s := &Story{
|
|
GUID: guid,
|
|
Headline: "Headline " + guid,
|
|
Lede: "Lede.",
|
|
ArticleURL: "https://example.com/" + guid,
|
|
Source: "Test Source",
|
|
Channel: "tech",
|
|
Classified: true,
|
|
SeenAt: 1700000000,
|
|
}
|
|
if err := InsertStory(s); err != nil {
|
|
t.Fatalf("insert story %s: %v", guid, err)
|
|
}
|
|
var id int64
|
|
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, guid).Scan(&id); err != nil {
|
|
t.Fatalf("lookup id %s: %v", guid, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func TestUserStoryState_ReadAndBookmark(t *testing.T) {
|
|
setupTestDB(t)
|
|
id1 := seedStory(t, "s1")
|
|
id2 := seedStory(t, "s2")
|
|
const sub = "ak-sub-1"
|
|
|
|
// Nothing set yet.
|
|
read, marks, err := UserStoryState(sub, []int64{id1, id2})
|
|
if err != nil {
|
|
t.Fatalf("initial state: %v", err)
|
|
}
|
|
if len(read) != 0 || len(marks) != 0 {
|
|
t.Fatalf("expected empty state, got read=%v bookmarked=%v", read, marks)
|
|
}
|
|
|
|
// Mark id1 read, bookmark id1 and id2.
|
|
if err := SetRead(sub, id1, true); err != nil {
|
|
t.Fatalf("set read: %v", err)
|
|
}
|
|
if err := SetBookmark(sub, id1, true); err != nil {
|
|
t.Fatalf("set bookmark id1: %v", err)
|
|
}
|
|
if err := SetBookmark(sub, id2, true); err != nil {
|
|
t.Fatalf("set bookmark id2: %v", err)
|
|
}
|
|
read, marks, _ = UserStoryState(sub, []int64{id1, id2})
|
|
if !read[id1] || read[id2] {
|
|
t.Fatalf("read map wrong: %v", read)
|
|
}
|
|
if !marks[id1] || !marks[id2] {
|
|
t.Fatalf("bookmark map wrong: %v", marks)
|
|
}
|
|
// id1 carries both flags in a single row.
|
|
if !read[id1] || !marks[id1] {
|
|
t.Fatalf("expected id1 read+bookmarked, read=%v marks=%v", read, marks)
|
|
}
|
|
|
|
// ListBookmarks orders newest bookmark first, tiebreaking on story id so the
|
|
// result is deterministic within a single second.
|
|
bm, err := ListBookmarks(sub, 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("list bookmarks: %v", err)
|
|
}
|
|
if len(bm) != 2 {
|
|
t.Fatalf("expected 2 bookmarks, got %d", len(bm))
|
|
}
|
|
if bm[0].ID != id2 || bm[1].ID != id1 {
|
|
t.Fatalf("bookmark order wrong: %d then %d", bm[0].ID, bm[1].ID)
|
|
}
|
|
if n, _ := CountBookmarks(sub); n != 2 {
|
|
t.Fatalf("count bookmarks = %d, want 2", n)
|
|
}
|
|
}
|
|
|
|
func TestUserStoryState_ClearRemovesRow(t *testing.T) {
|
|
setupTestDB(t)
|
|
id := seedStory(t, "s1")
|
|
const sub = "ak-sub-1"
|
|
|
|
if err := SetRead(sub, id, true); err != nil {
|
|
t.Fatalf("set read: %v", err)
|
|
}
|
|
if err := SetRead(sub, id, false); err != nil {
|
|
t.Fatalf("unset read: %v", err)
|
|
}
|
|
// Row should be gone since neither flag is set.
|
|
var n int
|
|
if err := Get().QueryRow(`SELECT COUNT(*) FROM user_story_state WHERE user_sub = ?`, sub).Scan(&n); err != nil {
|
|
t.Fatalf("count rows: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Fatalf("expected row pruned, found %d", n)
|
|
}
|
|
|
|
// Setting read then bookmark then clearing only read must keep the row.
|
|
_ = SetRead(sub, id, true)
|
|
_ = SetBookmark(sub, id, true)
|
|
_ = SetRead(sub, id, false)
|
|
read, marks, _ := UserStoryState(sub, []int64{id})
|
|
if read[id] {
|
|
t.Fatalf("expected not read")
|
|
}
|
|
if !marks[id] {
|
|
t.Fatalf("expected still bookmarked after clearing read")
|
|
}
|
|
}
|
|
|
|
func TestUserStoryState_ScopedBySub(t *testing.T) {
|
|
setupTestDB(t)
|
|
id := seedStory(t, "s1")
|
|
_ = SetBookmark("user-a", id, true)
|
|
|
|
read, marks, _ := UserStoryState("user-b", []int64{id})
|
|
if len(read) != 0 || len(marks) != 0 {
|
|
t.Fatalf("user-b should see no state, got read=%v marks=%v", read, marks)
|
|
}
|
|
if n, _ := CountBookmarks("user-b"); n != 0 {
|
|
t.Fatalf("user-b count = %d, want 0", n)
|
|
}
|
|
}
|