Reader mode presents the stories on a page one at a time in a focused overlay, marking each read as it's shown. Left/right arrows (or the header book button / `f`) page through them; read stories dim on the grid. Read state is device-local in localStorage. Backing this required actually capturing article bodies, which Pete wasn't doing — it kept only the RSS <description> lede and discarded content:encoded: - stories.content column (idempotent migration; old rows fall back to lede) - parser keeps content:encoded as paragraph-preserving text - article fetch already done for paywall detection now also returns its body, so ingest stores the richer of feed-content vs scraped body with no extra request (prefers the archive snapshot body for paywalled stories) - GET /api/article?id= serves the stored text; card queries now select id and expose it as data-id for the reader Tests cover content extraction, the storage round-trip, and the article endpoint + card rendering end to end.
363 lines
9.8 KiB
Go
363 lines
9.8 KiB
Go
package storage
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func setupTestDB(t *testing.T) {
|
|
t.Helper()
|
|
|
|
// Reset global state
|
|
mu.Lock()
|
|
if globalDB != nil {
|
|
globalDB.Close()
|
|
globalDB = nil
|
|
}
|
|
mu.Unlock()
|
|
|
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
|
if err := Init(dbPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { Close() })
|
|
}
|
|
|
|
func TestInitAndGet(t *testing.T) {
|
|
setupTestDB(t)
|
|
db := Get()
|
|
if db == nil {
|
|
t.Fatal("Get() returned nil after Init()")
|
|
}
|
|
}
|
|
|
|
func TestIsGUIDSeen_NotSeen(t *testing.T) {
|
|
setupTestDB(t)
|
|
if IsGUIDSeen("never-seen-guid") {
|
|
t.Error("expected false for unseen GUID")
|
|
}
|
|
}
|
|
|
|
func TestInsertStoryAndGUIDSeen(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
s := &Story{
|
|
GUID: "test-guid-1",
|
|
Headline: "Test Headline",
|
|
Lede: "Test lede sentence.",
|
|
ArticleURL: "https://example.com/article",
|
|
Source: "Test Source",
|
|
SeenAt: 1700000000,
|
|
}
|
|
if err := InsertStory(s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if !IsGUIDSeen("test-guid-1") {
|
|
t.Error("expected true after insert")
|
|
}
|
|
if IsGUIDSeen("other-guid") {
|
|
t.Error("expected false for different GUID")
|
|
}
|
|
}
|
|
|
|
func TestGetStoryReaderText(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
s := &Story{
|
|
GUID: "reader-guid",
|
|
Headline: "Reader Headline",
|
|
Lede: "Short lede.",
|
|
Content: "First paragraph.\n\nSecond paragraph.",
|
|
ArticleURL: "https://example.com/reader",
|
|
Source: "Src",
|
|
SeenAt: 1700000000,
|
|
}
|
|
if err := InsertStory(s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var id int64
|
|
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, s.GUID).Scan(&id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
content, lede, found, err := GetStoryReaderText(id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !found {
|
|
t.Fatal("expected story to be found")
|
|
}
|
|
if content != s.Content {
|
|
t.Errorf("content = %q, want %q", content, s.Content)
|
|
}
|
|
if lede != s.Lede {
|
|
t.Errorf("lede = %q, want %q", lede, s.Lede)
|
|
}
|
|
|
|
// A story ingested before content capture (no content) is still found, with
|
|
// an empty content string so the reader falls back to the lede.
|
|
bare := &Story{GUID: "bare-guid", Headline: "H", Lede: "Only a lede.", ArticleURL: "https://a.com", Source: "S", SeenAt: 1}
|
|
if err := InsertStory(bare); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var bareID int64
|
|
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, bare.GUID).Scan(&bareID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, lede, found, err = GetStoryReaderText(bareID)
|
|
if err != nil || !found {
|
|
t.Fatalf("bare story: found=%v err=%v", found, err)
|
|
}
|
|
if content != "" {
|
|
t.Errorf("bare content = %q, want empty", content)
|
|
}
|
|
if lede != "Only a lede." {
|
|
t.Errorf("bare lede = %q", lede)
|
|
}
|
|
|
|
// Unknown id reports not-found rather than erroring.
|
|
if _, _, found, err = GetStoryReaderText(999999); err != nil || found {
|
|
t.Errorf("unknown id: found=%v err=%v, want found=false err=nil", found, err)
|
|
}
|
|
}
|
|
|
|
func TestInsertStoryDuplicateGUID(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
s := &Story{
|
|
GUID: "dup-guid", Headline: "H1", ArticleURL: "https://a.com", Source: "S", SeenAt: 1,
|
|
}
|
|
if err := InsertStory(s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Second insert with same GUID should fail
|
|
err := InsertStory(s)
|
|
if err == nil {
|
|
t.Error("expected error on duplicate GUID insert")
|
|
}
|
|
}
|
|
|
|
func TestMarkClassified(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
InsertStory(&Story{
|
|
GUID: "g1", Headline: "H1", ArticleURL: "https://a.com", Source: "S", SeenAt: 1,
|
|
})
|
|
|
|
MarkClassified("g1", "tech", "[]")
|
|
|
|
var channel string
|
|
var classified int
|
|
Get().QueryRow(`SELECT channel, classified FROM stories WHERE guid = ?`, "g1").
|
|
Scan(&channel, &classified)
|
|
if channel != "tech" || classified != 1 {
|
|
t.Errorf("after MarkClassified: channel=%q classified=%d, want tech/1", channel, classified)
|
|
}
|
|
}
|
|
|
|
func TestPostLogAndLookup(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
InsertPostLog("story-1", "tech", "$event1:example.org", "", false)
|
|
InsertPostLog("story-2", "politics", "$event2:example.org", "", false)
|
|
|
|
guid, channel, found := LookupPostGUID("$event1:example.org")
|
|
if !found {
|
|
t.Fatal("expected to find event")
|
|
}
|
|
if guid != "story-1" || channel != "tech" {
|
|
t.Errorf("got guid=%q channel=%q", guid, channel)
|
|
}
|
|
|
|
_, _, found = LookupPostGUID("$nonexistent:example.org")
|
|
if found {
|
|
t.Error("expected not found for unknown event")
|
|
}
|
|
}
|
|
|
|
func TestLastPostTimeAndCountInWindow(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
if got := GetLastPostTime("tech"); got != 0 {
|
|
t.Errorf("empty channel last post = %d, want 0", got)
|
|
}
|
|
|
|
// Insert posts with specific timestamps
|
|
Get().Exec(`INSERT INTO post_log (guid, channel, event_id, posted_at) VALUES (?, ?, ?, ?)`,
|
|
"g1", "tech", "$e1", 1000)
|
|
Get().Exec(`INSERT INTO post_log (guid, channel, event_id, posted_at) VALUES (?, ?, ?, ?)`,
|
|
"g2", "tech", "$e2", 2000)
|
|
Get().Exec(`INSERT INTO post_log (guid, channel, event_id, posted_at) VALUES (?, ?, ?, ?)`,
|
|
"g3", "politics", "$e3", 1500)
|
|
|
|
if got := GetLastPostTime("tech"); got != 2000 {
|
|
t.Errorf("tech last post = %d, want 2000", got)
|
|
}
|
|
|
|
count := CountPostsInWindow("tech", 500)
|
|
if count != 2 {
|
|
t.Errorf("tech posts in window = %d, want 2", count)
|
|
}
|
|
|
|
count = CountPostsInWindow("tech", 1500)
|
|
if count != 1 {
|
|
t.Errorf("tech posts after 1500 = %d, want 1", count)
|
|
}
|
|
|
|
count = CountPostsInWindow("politics", 0)
|
|
if count != 1 {
|
|
t.Errorf("politics posts = %d, want 1", count)
|
|
}
|
|
}
|
|
|
|
func TestReactions(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
InsertReaction("story-1", "tech", "$react1", "📰", "@user:example.org", 1000)
|
|
InsertReaction("story-1", "tech", "$react2", "👍", "@user2:example.org", 1001)
|
|
|
|
var count int
|
|
Get().QueryRow(`SELECT COUNT(*) FROM reactions WHERE post_guid = ?`, "story-1").Scan(&count)
|
|
if count != 2 {
|
|
t.Errorf("reactions = %d, want 2", count)
|
|
}
|
|
}
|
|
|
|
func TestMarshalUnmarshalPlatforms(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input []string
|
|
json string
|
|
back []string
|
|
}{
|
|
{"nil", nil, "[]", nil},
|
|
{"empty", []string{}, "[]", nil},
|
|
{"single", []string{"pc"}, `["pc"]`, []string{"pc"}},
|
|
{"multi", []string{"nintendo-switch", "pc"}, `["nintendo-switch","pc"]`, []string{"nintendo-switch", "pc"}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := MarshalPlatforms(tt.input)
|
|
if got != tt.json {
|
|
t.Errorf("Marshal(%v) = %q, want %q", tt.input, got, tt.json)
|
|
}
|
|
back := UnmarshalPlatforms(got)
|
|
if len(back) != len(tt.back) {
|
|
t.Errorf("Unmarshal roundtrip: %v, want %v", back, tt.back)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInsertPostLog_DuplicateIgnored(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
InsertPostLog("story-1", "tech", "$event1:example.org", "", false)
|
|
// Same guid+channel should be silently ignored
|
|
InsertPostLog("story-1", "tech", "$event1-retry:example.org", "", false)
|
|
|
|
var count int
|
|
Get().QueryRow(`SELECT COUNT(*) FROM post_log WHERE guid = ? AND channel = ?`, "story-1", "tech").Scan(&count)
|
|
if count != 1 {
|
|
t.Errorf("expected 1 post_log entry, got %d", count)
|
|
}
|
|
|
|
// Different channel should be allowed
|
|
InsertPostLog("story-1", "politics", "$event2:example.org", "", false)
|
|
Get().QueryRow(`SELECT COUNT(*) FROM post_log WHERE guid = ?`, "story-1").Scan(&count)
|
|
if count != 2 {
|
|
t.Errorf("expected 2 post_log entries across channels, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestInsertReaction_DuplicateIgnored(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
InsertReaction("story-1", "tech", "$react1", "📰", "@user:example.org", 1000)
|
|
// Same event_id should be silently ignored
|
|
InsertReaction("story-1", "tech", "$react1", "📰", "@user:example.org", 1001)
|
|
|
|
var count int
|
|
Get().QueryRow(`SELECT COUNT(*) FROM reactions WHERE event_id = ?`, "$react1").Scan(&count)
|
|
if count != 1 {
|
|
t.Errorf("expected 1 reaction, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestRunMaintenance(t *testing.T) {
|
|
setupTestDB(t)
|
|
db := Get()
|
|
|
|
now := nowUnix()
|
|
old := now - 100*86400 // 100 days ago
|
|
|
|
// Insert old + recent stories
|
|
InsertStory(&Story{GUID: "old", Headline: "Old", ArticleURL: "https://a.com", Source: "S", Classified: true, SeenAt: old})
|
|
InsertStory(&Story{GUID: "new", Headline: "New", ArticleURL: "https://b.com", Source: "S", Classified: true, SeenAt: now})
|
|
MarkClassified("old", "tech", "[]")
|
|
MarkClassified("new", "tech", "[]")
|
|
|
|
// Insert old + recent post logs
|
|
db.Exec(`INSERT INTO post_log (guid, channel, event_id, posted_at) VALUES (?, ?, ?, ?)`, "old", "tech", "$e1", old)
|
|
db.Exec(`INSERT INTO post_log (guid, channel, event_id, posted_at) VALUES (?, ?, ?, ?)`, "new", "tech", "$e2", now)
|
|
|
|
// Insert old + recent reactions
|
|
InsertReaction("old", "tech", "$r1", "👍", "@u:x", old)
|
|
InsertReaction("new", "tech", "$r2", "👍", "@u:x", now)
|
|
|
|
RunMaintenance()
|
|
|
|
// Old stories should be pruned
|
|
var count int
|
|
db.QueryRow(`SELECT COUNT(*) FROM stories`).Scan(&count)
|
|
if count != 1 {
|
|
t.Errorf("stories: expected 1, got %d", count)
|
|
}
|
|
|
|
db.QueryRow(`SELECT COUNT(*) FROM post_log`).Scan(&count)
|
|
if count != 1 {
|
|
t.Errorf("post_log: expected 1, got %d", count)
|
|
}
|
|
|
|
db.QueryRow(`SELECT COUNT(*) FROM reactions`).Scan(&count)
|
|
if count != 1 {
|
|
t.Errorf("reactions: expected 1, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestFTS5Search(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
InsertStory(&Story{
|
|
GUID: "g1", Headline: "Elden Ring DLC breaks sales records", Lede: "FromSoftware celebrates milestone.",
|
|
ArticleURL: "https://a.com", Source: "S", SeenAt: 1,
|
|
})
|
|
InsertStory(&Story{
|
|
GUID: "g2", Headline: "Apple unveils new MacBook Pro", Lede: "M5 chip delivers impressive gains.",
|
|
ArticleURL: "https://b.com", Source: "S", SeenAt: 2,
|
|
})
|
|
|
|
// Search for Elden Ring
|
|
rows, err := Get().Query(`
|
|
SELECT s.guid, s.headline FROM stories s
|
|
JOIN stories_fts f ON s.id = f.rowid
|
|
WHERE stories_fts MATCH 'Elden Ring'
|
|
ORDER BY rank`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []string
|
|
for rows.Next() {
|
|
var guid, headline string
|
|
rows.Scan(&guid, &headline)
|
|
results = append(results, guid)
|
|
}
|
|
if len(results) != 1 || results[0] != "g1" {
|
|
t.Errorf("FTS5 search results = %v, want [g1]", results)
|
|
}
|
|
}
|