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.
105 lines
3.5 KiB
Go
105 lines
3.5 KiB
Go
package storage
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
func TestRecordPollResult_SuccessThenFailure(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
// First a successful poll returning 7 items.
|
|
RecordPollResult("Feed A", true, 7, nil)
|
|
h, err := ListSourceHealth()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a, ok := h["Feed A"]
|
|
if !ok {
|
|
t.Fatal("expected a health row for Feed A")
|
|
}
|
|
if a.LastItemCount != 7 || a.ConsecutiveFailures != 0 || a.LastError != "" {
|
|
t.Errorf("after success: items=%d fails=%d err=%q", a.LastItemCount, a.ConsecutiveFailures, a.LastError)
|
|
}
|
|
if a.LastPollAt == 0 || a.LastSuccessAt == 0 {
|
|
t.Errorf("after success: last_poll=%d last_success=%d, want both set", a.LastPollAt, a.LastSuccessAt)
|
|
}
|
|
successTS := a.LastSuccessAt
|
|
|
|
// Two failures in a row: failures accumulate, but the last-success timestamp
|
|
// and item count are preserved so the dashboard still shows when it last worked.
|
|
RecordPollResult("Feed A", false, 0, errors.New("dial tcp: timeout"))
|
|
RecordPollResult("Feed A", false, 0, errors.New("502 bad gateway"))
|
|
h, _ = ListSourceHealth()
|
|
a = h["Feed A"]
|
|
if a.ConsecutiveFailures != 2 {
|
|
t.Errorf("consecutive failures = %d, want 2", a.ConsecutiveFailures)
|
|
}
|
|
if a.LastError != "502 bad gateway" {
|
|
t.Errorf("last error = %q, want %q", a.LastError, "502 bad gateway")
|
|
}
|
|
if a.LastSuccessAt != successTS {
|
|
t.Errorf("last success = %d, want preserved %d", a.LastSuccessAt, successTS)
|
|
}
|
|
if a.LastItemCount != 7 {
|
|
t.Errorf("last item count = %d, want preserved 7", a.LastItemCount)
|
|
}
|
|
|
|
// Recovery clears the error and resets the counter.
|
|
RecordPollResult("Feed A", true, 3, nil)
|
|
h, _ = ListSourceHealth()
|
|
a = h["Feed A"]
|
|
if a.ConsecutiveFailures != 0 || a.LastError != "" || a.LastItemCount != 3 {
|
|
t.Errorf("after recovery: fails=%d err=%q items=%d", a.ConsecutiveFailures, a.LastError, a.LastItemCount)
|
|
}
|
|
}
|
|
|
|
func TestSourceContentStats(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
// Source X: two classified stories, one paywalled; one posted.
|
|
InsertStory(&Story{GUID: "x1", Headline: "X one", ArticleURL: "https://x.com/1", Source: "X", Channel: "tech", Classified: true, SeenAt: 100})
|
|
InsertStory(&Story{GUID: "x2", Headline: "X two", ArticleURL: "https://x.com/2", Source: "X", Channel: "tech", Classified: true, Paywalled: true, SeenAt: 200})
|
|
// Source Y: one unclassified story, never posted.
|
|
InsertStory(&Story{GUID: "y1", Headline: "Y one", ArticleURL: "https://y.com/1", Source: "Y", SeenAt: 150})
|
|
|
|
InsertPostLog("x1", "tech", "$e1", "", false)
|
|
// Backdate/forward the post so we can assert MAX(posted_at).
|
|
Get().Exec(`UPDATE post_log SET posted_at = ? WHERE guid = ?`, int64(500), "x1")
|
|
|
|
stats, err := SourceContentStats()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
x := stats["X"]
|
|
if x.Total != 2 || x.Classified != 2 || x.Paywalled != 1 {
|
|
t.Errorf("X: total=%d classified=%d paywalled=%d, want 2/2/1", x.Total, x.Classified, x.Paywalled)
|
|
}
|
|
if x.LastSeenAt != 200 {
|
|
t.Errorf("X last seen = %d, want 200", x.LastSeenAt)
|
|
}
|
|
if x.LastPostedAt != 500 {
|
|
t.Errorf("X last posted = %d, want 500", x.LastPostedAt)
|
|
}
|
|
|
|
y := stats["Y"]
|
|
if y.Total != 1 || y.Classified != 0 || y.Paywalled != 0 {
|
|
t.Errorf("Y: total=%d classified=%d paywalled=%d, want 1/0/0", y.Total, y.Classified, y.Paywalled)
|
|
}
|
|
if y.LastPostedAt != 0 {
|
|
t.Errorf("Y last posted = %d, want 0 (never posted)", y.LastPostedAt)
|
|
}
|
|
}
|
|
|
|
func TestListSourceHealth_Empty(t *testing.T) {
|
|
setupTestDB(t)
|
|
h, err := ListSourceHealth()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(h) != 0 {
|
|
t.Errorf("expected no health rows, got %d", len(h))
|
|
}
|
|
}
|