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.
305 lines
6.3 KiB
Go
305 lines
6.3 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// validMatrix is a reusable matrix config block for tests.
|
|
const validMatrix = `
|
|
[matrix]
|
|
homeserver = "https://matrix.example.org"
|
|
user_id = "@pete:example.org"
|
|
password = "testpass"
|
|
pickle_key = "test_pickle_key_1234"
|
|
[matrix.channels]
|
|
tech = "!tech:example.org"
|
|
politics = "!politics:example.org"
|
|
`
|
|
|
|
func TestLoadValid(t *testing.T) {
|
|
toml := validMatrix + `
|
|
[storage]
|
|
db_path = "/tmp/test.db"
|
|
|
|
[[sources]]
|
|
name = "Test Source"
|
|
feed_url = "https://example.com/rss"
|
|
tier = 1
|
|
poll_interval_minutes = 20
|
|
direct_route = "tech"
|
|
enabled = true
|
|
`
|
|
cfg := loadFromString(t, toml)
|
|
|
|
if cfg.Matrix.Homeserver != "https://matrix.example.org" {
|
|
t.Errorf("homeserver = %q", cfg.Matrix.Homeserver)
|
|
}
|
|
if cfg.Matrix.UserID != "@pete:example.org" {
|
|
t.Errorf("user_id = %q", cfg.Matrix.UserID)
|
|
}
|
|
if len(cfg.Sources) != 1 {
|
|
t.Fatalf("sources = %d, want 1", len(cfg.Sources))
|
|
}
|
|
if cfg.Sources[0].Name != "Test Source" {
|
|
t.Errorf("source name = %q", cfg.Sources[0].Name)
|
|
}
|
|
if cfg.Sources[0].DirectRoute != "tech" {
|
|
t.Errorf("direct_route = %q, want tech", cfg.Sources[0].DirectRoute)
|
|
}
|
|
}
|
|
|
|
func TestEnvVarExpansion(t *testing.T) {
|
|
t.Setenv("TEST_PETE_PASS", "secret_pass_123")
|
|
|
|
toml := `
|
|
[matrix]
|
|
homeserver = "https://matrix.example.org"
|
|
user_id = "@pete:example.org"
|
|
password = "${TEST_PETE_PASS}"
|
|
pickle_key = "test_pickle_key_1234"
|
|
[matrix.channels]
|
|
tech = "!tech:example.org"
|
|
|
|
[storage]
|
|
db_path = "/tmp/test.db"
|
|
`
|
|
cfg := loadFromString(t, toml)
|
|
|
|
if cfg.Matrix.Password != "secret_pass_123" {
|
|
t.Errorf("password = %q, want secret_pass_123", cfg.Matrix.Password)
|
|
}
|
|
}
|
|
|
|
func TestDefaults(t *testing.T) {
|
|
toml := validMatrix + `
|
|
[storage]
|
|
db_path = "/tmp/test.db"
|
|
|
|
[[sources]]
|
|
name = "S"
|
|
feed_url = "https://example.com/rss"
|
|
tier = 1
|
|
poll_interval_minutes = 20
|
|
direct_route = "tech"
|
|
enabled = true
|
|
`
|
|
cfg := loadFromString(t, toml)
|
|
|
|
if cfg.Matrix.DataDir != "./data" {
|
|
t.Errorf("data_dir default = %q, want ./data", cfg.Matrix.DataDir)
|
|
}
|
|
if cfg.Matrix.DisplayName != "Pete" {
|
|
t.Errorf("display_name default = %q, want Pete", cfg.Matrix.DisplayName)
|
|
}
|
|
if cfg.Posting.MinIntervalSeconds != 300 {
|
|
t.Errorf("min_interval default = %d, want 300", cfg.Posting.MinIntervalSeconds)
|
|
}
|
|
if cfg.Posting.BurstCapCount != 3 {
|
|
t.Errorf("burst_cap default = %d, want 3", cfg.Posting.BurstCapCount)
|
|
}
|
|
if cfg.Posting.BurstCapWindowSeconds != 1800 {
|
|
t.Errorf("burst_window default = %d, want 1800", cfg.Posting.BurstCapWindowSeconds)
|
|
}
|
|
if cfg.Storage.RecentWindowHours != 24 {
|
|
t.Errorf("recent_window default = %d, want 24", cfg.Storage.RecentWindowHours)
|
|
}
|
|
}
|
|
|
|
func TestValidationErrors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
toml string
|
|
}{
|
|
{"missing homeserver", `
|
|
[matrix]
|
|
user_id = "@p:h"
|
|
password = "pw"
|
|
pickle_key = "test_pickle_key_1234"
|
|
[matrix.channels]
|
|
tech = "!t:e"
|
|
[storage]
|
|
db_path = "/tmp/t.db"
|
|
`},
|
|
{"missing user_id", `
|
|
[matrix]
|
|
homeserver = "https://h"
|
|
password = "pw"
|
|
pickle_key = "test_pickle_key_1234"
|
|
[matrix.channels]
|
|
tech = "!t:e"
|
|
[storage]
|
|
db_path = "/tmp/t.db"
|
|
`},
|
|
{"missing password", `
|
|
[matrix]
|
|
homeserver = "https://h"
|
|
user_id = "@p:h"
|
|
[matrix.channels]
|
|
tech = "!t:e"
|
|
[storage]
|
|
db_path = "/tmp/t.db"
|
|
`},
|
|
{"no channels", `
|
|
[matrix]
|
|
homeserver = "https://h"
|
|
user_id = "@p:h"
|
|
password = "pw"
|
|
pickle_key = "test_pickle_key_1234"
|
|
[storage]
|
|
db_path = "/tmp/t.db"
|
|
`},
|
|
{"missing db_path", `
|
|
[matrix]
|
|
homeserver = "https://h"
|
|
user_id = "@p:h"
|
|
password = "pw"
|
|
pickle_key = "test_pickle_key_1234"
|
|
[matrix.channels]
|
|
tech = "!t:e"
|
|
[storage]
|
|
`},
|
|
{"invalid tier", `
|
|
[matrix]
|
|
homeserver = "https://h"
|
|
user_id = "@p:h"
|
|
password = "pw"
|
|
pickle_key = "test_pickle_key_1234"
|
|
[matrix.channels]
|
|
tech = "!t:e"
|
|
[storage]
|
|
db_path = "/tmp/t.db"
|
|
[[sources]]
|
|
name = "S"
|
|
feed_url = "https://e.com/rss"
|
|
tier = 5
|
|
poll_interval_minutes = 20
|
|
direct_route = "tech"
|
|
enabled = true
|
|
`},
|
|
{"missing source name", `
|
|
[matrix]
|
|
homeserver = "https://h"
|
|
user_id = "@p:h"
|
|
password = "pw"
|
|
pickle_key = "test_pickle_key_1234"
|
|
[matrix.channels]
|
|
tech = "!t:e"
|
|
[storage]
|
|
db_path = "/tmp/t.db"
|
|
[[sources]]
|
|
feed_url = "https://e.com/rss"
|
|
tier = 1
|
|
poll_interval_minutes = 20
|
|
direct_route = "tech"
|
|
enabled = true
|
|
`},
|
|
{"enabled source missing direct_route", `
|
|
[matrix]
|
|
homeserver = "https://h"
|
|
user_id = "@p:h"
|
|
password = "pw"
|
|
pickle_key = "test_pickle_key_1234"
|
|
[matrix.channels]
|
|
tech = "!t:e"
|
|
[storage]
|
|
db_path = "/tmp/t.db"
|
|
[[sources]]
|
|
name = "S"
|
|
feed_url = "https://e.com/rss"
|
|
tier = 1
|
|
poll_interval_minutes = 20
|
|
enabled = true
|
|
`},
|
|
{"push enabled without auth", `
|
|
[matrix]
|
|
homeserver = "https://h"
|
|
user_id = "@p:h"
|
|
password = "pw"
|
|
pickle_key = "test_pickle_key_1234"
|
|
[matrix.channels]
|
|
tech = "!t:e"
|
|
[storage]
|
|
db_path = "/tmp/t.db"
|
|
[web.push]
|
|
enabled = true
|
|
vapid_public_key = "pub"
|
|
vapid_private_key = "priv"
|
|
subject = "mailto:a@b.c"
|
|
`},
|
|
{"push enabled missing keys", `
|
|
[matrix]
|
|
homeserver = "https://h"
|
|
user_id = "@p:h"
|
|
password = "pw"
|
|
pickle_key = "test_pickle_key_1234"
|
|
[matrix.channels]
|
|
tech = "!t:e"
|
|
[storage]
|
|
db_path = "/tmp/t.db"
|
|
[web.auth]
|
|
enabled = true
|
|
issuer = "https://i"
|
|
client_id = "id"
|
|
client_secret = "secret"
|
|
redirect_url = "https://r/cb"
|
|
session_secret = "session_secret_16chars_long"
|
|
[web.push]
|
|
enabled = true
|
|
subject = "mailto:a@b.c"
|
|
`},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.toml")
|
|
os.WriteFile(path, []byte(tt.toml), 0o644)
|
|
_, err := Load(path)
|
|
if err == nil {
|
|
t.Error("expected validation error, got nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDisabledSourceSkipsDirectRouteCheck(t *testing.T) {
|
|
// A disabled source without direct_route must not fail validation.
|
|
toml := `
|
|
[matrix]
|
|
homeserver = "https://h"
|
|
user_id = "@p:h"
|
|
password = "pw"
|
|
pickle_key = "test_pickle_key_1234"
|
|
[matrix.channels]
|
|
tech = "!t:e"
|
|
[storage]
|
|
db_path = "/tmp/t.db"
|
|
[[sources]]
|
|
name = "Off"
|
|
feed_url = "https://e.com/rss"
|
|
tier = 1
|
|
poll_interval_minutes = 20
|
|
enabled = false
|
|
`
|
|
cfg := loadFromString(t, toml)
|
|
if cfg.Sources[0].DirectRoute != "" {
|
|
t.Errorf("expected empty direct_route, got %q", cfg.Sources[0].DirectRoute)
|
|
}
|
|
}
|
|
|
|
func loadFromString(t *testing.T, tomlContent string) *Config {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.toml")
|
|
if err := os.WriteFile(path, []byte(tomlContent), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := Load(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cfg
|
|
}
|