406 lines
11 KiB
Go
406 lines
11 KiB
Go
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)
|
|
}
|
|
}
|