Initial commit
This commit is contained in:
405
internal/storage/storage_test.go
Normal file
405
internal/storage/storage_test.go
Normal file
@@ -0,0 +1,405 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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",
|
||||
FeedHint: "tech",
|
||||
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 TestMarkClassifiedAndGetUnclassified(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
InsertStory(&Story{
|
||||
GUID: "g1", Headline: "H1", ArticleURL: "https://a.com", Source: "S", SeenAt: 1,
|
||||
})
|
||||
InsertStory(&Story{
|
||||
GUID: "g2", Headline: "H2", ArticleURL: "https://b.com", Source: "S", SeenAt: 2,
|
||||
})
|
||||
|
||||
unclassified, err := GetUnclassifiedStories("S")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(unclassified) != 2 {
|
||||
t.Fatalf("unclassified = %d, want 2", len(unclassified))
|
||||
}
|
||||
|
||||
MarkClassified("g1", "tech", "[]")
|
||||
|
||||
unclassified, err = GetUnclassifiedStories("S")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(unclassified) != 1 {
|
||||
t.Fatalf("unclassified = %d, want 1", len(unclassified))
|
||||
}
|
||||
if unclassified[0].GUID != "g2" {
|
||||
t.Errorf("remaining = %q, want g2", unclassified[0].GUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentHeadlines(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
// Insert with explicit timestamps to ensure ordering
|
||||
db := Get()
|
||||
db.Exec(`INSERT OR REPLACE INTO recent_headlines (guid, headline, source, seen_at) VALUES (?, ?, ?, ?)`,
|
||||
"g1", "Headline One", "Source A", 1000)
|
||||
db.Exec(`INSERT OR REPLACE INTO recent_headlines (guid, headline, source, seen_at) VALUES (?, ?, ?, ?)`,
|
||||
"g2", "Headline Two", "Source B", 2000)
|
||||
db.Exec(`INSERT OR REPLACE INTO recent_headlines (guid, headline, source, seen_at) VALUES (?, ?, ?, ?)`,
|
||||
"g3", "Headline Three", "Source A", 3000)
|
||||
|
||||
headlines, err := GetRecentHeadlines(2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(headlines) != 2 {
|
||||
t.Fatalf("headlines = %d, want 2", len(headlines))
|
||||
}
|
||||
// Should be most recent first
|
||||
if headlines[0].GUID != "g3" {
|
||||
t.Errorf("first = %q, want g3", headlines[0].GUID)
|
||||
}
|
||||
if headlines[1].GUID != "g2" {
|
||||
t.Errorf("second = %q, want g2", headlines[1].GUID)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestGetUnclassifiedStories_ScopedBySource(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
InsertStory(&Story{GUID: "g1", Headline: "H1", ArticleURL: "https://a.com", Source: "SourceA", SeenAt: 1})
|
||||
InsertStory(&Story{GUID: "g2", Headline: "H2", ArticleURL: "https://b.com", Source: "SourceB", SeenAt: 2})
|
||||
InsertStory(&Story{GUID: "g3", Headline: "H3", ArticleURL: "https://c.com", Source: "SourceA", SeenAt: 3})
|
||||
|
||||
got, err := GetUnclassifiedStories("SourceA")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 stories for SourceA, got %d", len(got))
|
||||
}
|
||||
|
||||
got, err = GetUnclassifiedStories("SourceB")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 story for SourceB, got %d", len(got))
|
||||
}
|
||||
|
||||
got, err = GetUnclassifiedStories("SourceC")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected 0 stories for SourceC, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUnclassifiedStories_Limit20(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
for i := 0; i < 30; i++ {
|
||||
InsertStory(&Story{
|
||||
GUID: fmt.Sprintf("g%d", i), Headline: fmt.Sprintf("H%d", i),
|
||||
ArticleURL: "https://a.com", Source: "S", SeenAt: int64(i),
|
||||
})
|
||||
}
|
||||
|
||||
got, err := GetUnclassifiedStories("S")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 20 {
|
||||
t.Fatalf("expected 20 (capped), got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Insert old + recent headlines
|
||||
db.Exec(`INSERT OR REPLACE INTO recent_headlines (guid, headline, source, seen_at) VALUES (?, ?, ?, ?)`, "old", "Old", "S", old)
|
||||
db.Exec(`INSERT OR REPLACE INTO recent_headlines (guid, headline, source, seen_at) VALUES (?, ?, ?, ?)`, "new", "New", "S", now)
|
||||
|
||||
RunMaintenance(24, 7)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
db.QueryRow(`SELECT COUNT(*) FROM recent_headlines`).Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("recent_headlines: 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user