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.
This commit is contained in:
141
internal/storage/rank_test.go
Normal file
141
internal/storage/rank_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedStoryFull inserts a story with an explicit channel + source so ranking
|
||||
// tests can build a skewed affinity profile.
|
||||
func seedStoryFull(t *testing.T, guid, channel, source, headline, lede string) int64 {
|
||||
t.Helper()
|
||||
s := &Story{
|
||||
GUID: guid,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: "https://example.com/" + guid,
|
||||
Source: source,
|
||||
Channel: channel,
|
||||
Classified: true,
|
||||
SeenAt: nowUnix(),
|
||||
}
|
||||
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 TestForYou_LeansToAffinityAndExcludesRead(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
const sub = "sub-1"
|
||||
|
||||
// A skewed history: the user reads/bookmarks gaming stories.
|
||||
g1 := seedStoryFull(t, "g1", "gaming", "GameSite", "Game one", "")
|
||||
g2 := seedStoryFull(t, "g2", "gaming", "GameSite", "Game two", "")
|
||||
// Candidates to rank (all unread until we mark some).
|
||||
gc := seedStoryFull(t, "gc", "gaming", "GameSite", "Fresh gaming story", "")
|
||||
tc := seedStoryFull(t, "tc", "tech", "TechSite", "Fresh tech story", "")
|
||||
|
||||
if err := SetRead(sub, g1, true); err != nil {
|
||||
t.Fatalf("read g1: %v", err)
|
||||
}
|
||||
if err := SetBookmark(sub, g2, true); err != nil {
|
||||
t.Fatalf("bookmark g2: %v", err)
|
||||
}
|
||||
|
||||
got, err := ForYou(sub, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ForYou: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatal("expected results")
|
||||
}
|
||||
// Affinity leans gaming, so a gaming story tops the list and the tech story
|
||||
// sinks to the bottom. (gc and the unread-but-bookmarked g2 both qualify and
|
||||
// tie on score, so we assert on channel rather than a specific id.)
|
||||
if got[0].Channel != "gaming" {
|
||||
t.Fatalf("expected a gaming story first, got channel %q (id %d)", got[0].Channel, got[0].ID)
|
||||
}
|
||||
if got[len(got)-1].ID != tc {
|
||||
t.Fatalf("expected tech candidate ranked last, got id %d", got[len(got)-1].ID)
|
||||
}
|
||||
// Read stories must never appear.
|
||||
for _, s := range got {
|
||||
if s.ID == g1 {
|
||||
t.Fatalf("read story g1 leaked into ForYou")
|
||||
}
|
||||
}
|
||||
// Sanity: the gaming candidate that was never touched should be present.
|
||||
var sawGC bool
|
||||
for _, s := range got {
|
||||
if s.ID == gc {
|
||||
sawGC = true
|
||||
}
|
||||
}
|
||||
if !sawGC {
|
||||
t.Fatalf("expected unread gaming candidate gc in results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForYou_NoHistoryReturnsNil(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedStoryFull(t, "a", "tech", "TechSite", "Something", "")
|
||||
got, err := ForYou("nobody", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ForYou: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for a user with no history, got %d rows", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelatedStories_OnTopicExcludesSelf(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seed := seedStoryFull(t, "seed", "tech", "TechSite",
|
||||
"Apple unveils new iPhone camera", "The new camera sensor is larger.")
|
||||
rel := seedStoryFull(t, "rel", "tech", "OtherSite",
|
||||
"iPhone camera teardown reveals sensor", "A look at the new iPhone camera.")
|
||||
seedStoryFull(t, "off", "gaming", "GameSite",
|
||||
"Nintendo announces handheld", "A brand new portable console.")
|
||||
|
||||
got, err := RelatedStories(seed, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("RelatedStories: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatal("expected at least one related story")
|
||||
}
|
||||
for _, s := range got {
|
||||
if s.ID == seed {
|
||||
t.Fatal("seed story returned as its own related")
|
||||
}
|
||||
}
|
||||
if got[0].ID != rel {
|
||||
t.Fatalf("expected the on-topic iPhone story first, got id %d", got[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRelatedFTSQuery(t *testing.T) {
|
||||
// Stopwords and 1-char tokens drop out; the rest become an OR of quoted terms.
|
||||
q := buildRelatedFTSQuery("The new iPhone camera is a big deal")
|
||||
if q == "" {
|
||||
t.Fatal("expected a non-empty query")
|
||||
}
|
||||
for _, bad := range []string{`"the"`, `"is"`, `"new"`} {
|
||||
if strings.Contains(q, bad) {
|
||||
t.Fatalf("stopword leaked into query: %s in %q", bad, q)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`"iphone"`, `"camera"`} {
|
||||
if !strings.Contains(q, want) {
|
||||
t.Fatalf("expected %s in query %q", want, q)
|
||||
}
|
||||
}
|
||||
if buildRelatedFTSQuery("the a of to") != "" {
|
||||
t.Fatal("expected empty query when only stopwords remain")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user