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.
106 lines
3.2 KiB
Go
106 lines
3.2 KiB
Go
package storage
|
|
|
|
import "testing"
|
|
|
|
func TestPushSubscriptionLifecycle(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
if err := AddPushSubscription("sub-1", "https://push.example/ep-a", "p256-a", "auth-a"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A second endpoint for the same user (e.g. a second device).
|
|
if err := AddPushSubscription("sub-1", "https://push.example/ep-b", "p256-b", "auth-b"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A different user.
|
|
if err := AddPushSubscription("sub-2", "https://push.example/ep-c", "p256-c", "auth-c"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
subs, err := ListPushSubscriptions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(subs) != 3 {
|
|
t.Fatalf("got %d subscriptions, want 3", len(subs))
|
|
}
|
|
|
|
// Re-subscribing the same endpoint updates keys in place, not a new row.
|
|
if err := AddPushSubscription("sub-1", "https://push.example/ep-a", "p256-a2", "auth-a2"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
subs, _ = ListPushSubscriptions()
|
|
if len(subs) != 3 {
|
|
t.Fatalf("after re-subscribe got %d rows, want 3 (upsert, not insert)", len(subs))
|
|
}
|
|
var epA PushSubscription
|
|
for _, s := range subs {
|
|
if s.Endpoint == "https://push.example/ep-a" {
|
|
epA = s
|
|
}
|
|
}
|
|
if epA.P256dh != "p256-a2" || epA.Auth != "auth-a2" {
|
|
t.Errorf("re-subscribe did not refresh keys: %+v", epA)
|
|
}
|
|
|
|
if err := RemovePushSubscription("https://push.example/ep-a"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
subs, _ = ListPushSubscriptions()
|
|
if len(subs) != 2 {
|
|
t.Fatalf("after remove got %d rows, want 2", len(subs))
|
|
}
|
|
}
|
|
|
|
func TestTouchPushSubscriptionAdvancesWatermark(t *testing.T) {
|
|
setupTestDB(t)
|
|
if err := AddPushSubscription("sub-1", "https://push.example/ep", "p", "a"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
subs, _ := ListPushSubscriptions()
|
|
orig := subs[0].LastNotifiedAt
|
|
|
|
want := orig + 5000
|
|
if err := TouchPushSubscription("https://push.example/ep", want); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
subs, _ = ListPushSubscriptions()
|
|
if subs[0].LastNotifiedAt != want {
|
|
t.Errorf("watermark = %d, want %d", subs[0].LastNotifiedAt, want)
|
|
}
|
|
}
|
|
|
|
func TestNewClassifiedSince(t *testing.T) {
|
|
setupTestDB(t)
|
|
now := nowUnix()
|
|
|
|
insert := func(guid, headline, source, channel string, classified bool, seenAt int64) {
|
|
if err := InsertStory(&Story{
|
|
GUID: guid, Headline: headline, ArticleURL: "https://x/" + guid,
|
|
Source: source, Channel: channel, Classified: classified, SeenAt: seenAt,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
insert("old", "Old news", "Feed A", "tech", true, now-1000)
|
|
insert("fresh1", "Fresh one", "Feed A", "tech", true, now-100)
|
|
insert("fresh2", "Fresh two", "Feed B", "gaming", true, now-50)
|
|
insert("unclassified", "Pending", "Feed A", "", false, now-10)
|
|
insert("discarded", "Junk", "Feed A", "_discarded", true, now-10)
|
|
|
|
got, err := NewClassifiedSince(now-500, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("got %d stories, want 2 (fresh classified, non-discarded, after watermark)", len(got))
|
|
}
|
|
// Newest first.
|
|
if got[0].GUID != "" && got[0].Headline != "Fresh two" {
|
|
t.Errorf("first result = %q, want newest 'Fresh two'", got[0].Headline)
|
|
}
|
|
if got[0].Source != "Feed B" || got[1].Source != "Feed A" {
|
|
t.Errorf("unexpected order/sources: %q then %q", got[0].Source, got[1].Source)
|
|
}
|
|
}
|