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 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", "") InsertPostLog("story-2", "politics", "$event2:example.org", "") 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", "") // Same guid+channel should be silently ignored InsertPostLog("story-1", "tech", "$event1-retry:example.org", "") 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", "") 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) } }