package tests import ( "fmt" "maps" "reflect" "slices" "strconv" "testing" "testing/synctest" "time" "github.com/nkanaev/yarr/src/storage" "github.com/nkanaev/yarr/src/storage/model" ) /* - folder1 - feed11 - item111 (unread) - item112 (read) - item113 (starred) - feed12 - item121 (unread) - item122 (read) - folder2 - feed21 - item211 (read) - item212 (starred) - feed01 - item011 (unread) - item012 (read) - item013 (starred) */ type testItemScope struct { feed11, feed12 *model.Feed feed21, feed01 *model.Feed folder1, folder2 *model.Folder items map[string]model.Item } func MustGet[K comparable, V any](m map[K]V, key K) V { value, ok := m[key] if !ok { panic(fmt.Sprintf("key %v not found in map", key)) } return value } func testItemsSetup(db storage.Storage) testItemScope { folder1 := db.CreateFolder("folder1") folder2 := db.CreateFolder("folder2") feed11 := db.CreateFeed(model.CreateFeedParams{Title: "feed11", FeedLink: "http://test.com/feed11.xml", FolderID: &folder1.Id}) feed12 := db.CreateFeed(model.CreateFeedParams{Title: "feed12", FeedLink: "http://test.com/feed12.xml", FolderID: &folder1.Id}) feed21 := db.CreateFeed(model.CreateFeedParams{Title: "feed21", FeedLink: "http://test.com/feed21.xml", FolderID: &folder2.Id}) feed01 := db.CreateFeed(model.CreateFeedParams{Title: "feed01", FeedLink: "http://test.com/feed01.xml"}) now := time.Now() items := map[string]model.Item{ // feed11 "item111": { GUID: "item111", FeedId: feed11.Id, Title: "title111", Date: now.Add(time.Hour * 24 * 1), }, "item112": { GUID: "item112", FeedId: feed11.Id, Title: "title112", Date: now.Add(time.Hour * 24 * 2), Status: model.READ, }, // read "item113": { GUID: "item113", FeedId: feed11.Id, Title: "title113", Date: now.Add(time.Hour * 24 * 3), Status: model.STARRED, }, // starred // feed12 "item121": { GUID: "item121", FeedId: feed12.Id, Title: "title121", Date: now.Add(time.Hour * 24 * 4), }, "item122": { GUID: "item122", FeedId: feed12.Id, Title: "title122", Date: now.Add(time.Hour * 24 * 5), Status: model.READ, }, // read // feed21 "item211": { GUID: "item211", FeedId: feed21.Id, Title: "title211", Date: now.Add(time.Hour * 24 * 6), Status: model.READ, }, // read "item212": { GUID: "item212", FeedId: feed21.Id, Title: "title212", Date: now.Add(time.Hour * 24 * 7), Status: model.STARRED, }, // starred // feed01 "item011": { GUID: "item011", FeedId: feed01.Id, Title: "title011", Date: now.Add(time.Hour * 24 * 8), }, "item012": { GUID: "item012", FeedId: feed01.Id, Title: "title012", Date: now.Add(time.Hour * 24 * 9), Status: model.READ, }, // read "item013": { GUID: "item013", FeedId: feed01.Id, Title: "title013", Date: now.Add(time.Hour * 24 * 10), Status: model.STARRED, }, // starred } db.CreateItems(slices.Collect(maps.Values(items))) return testItemScope{ feed11: feed11, feed12: feed12, feed21: feed21, feed01: feed01, folder1: folder1, folder2: folder2, items: items, } } func getItemGuids(items []model.Item) []string { guids := make([]string, 0) for _, item := range items { guids = append(guids, item.GUID) } return guids } func TestListItems(t *testing.T) { dbtest(t, func(t *testing.T, db storage.Storage) { scope := testItemsSetup(db) // filter by folder_id have := getItemGuids(db.ListItems(model.ItemFilter{FolderID: &scope.folder1.Id}, 10, false, false)) want := []string{"item111", "item112", "item113", "item121", "item122"} if !reflect.DeepEqual(have, want) { t.Logf("want: %#v", want) t.Logf("have: %#v", have) t.Fail() } have = getItemGuids(db.ListItems(model.ItemFilter{FolderID: &scope.folder2.Id}, 10, false, false)) want = []string{"item211", "item212"} if !reflect.DeepEqual(have, want) { t.Logf("want: %#v", want) t.Logf("have: %#v", have) t.Fail() } // filter by feed_id have = getItemGuids(db.ListItems(model.ItemFilter{FeedID: &scope.feed11.Id}, 10, false, false)) want = []string{"item111", "item112", "item113"} if !reflect.DeepEqual(have, want) { t.Logf("want: %#v", want) t.Logf("have: %#v", have) t.Fail() } have = getItemGuids(db.ListItems(model.ItemFilter{FeedID: &scope.feed01.Id}, 10, false, false)) want = []string{"item011", "item012", "item013"} if !reflect.DeepEqual(have, want) { t.Logf("want: %#v", want) t.Logf("have: %#v", have) t.Fail() } // filter by status var starred model.ItemStatus = model.STARRED have = getItemGuids(db.ListItems(model.ItemFilter{Status: &starred}, 10, false, false)) want = []string{"item113", "item212", "item013"} if !reflect.DeepEqual(have, want) { t.Logf("want: %#v", want) t.Logf("have: %#v", have) t.Fail() } var unread model.ItemStatus = model.UNREAD have = getItemGuids(db.ListItems(model.ItemFilter{Status: &unread}, 10, false, false)) want = []string{"item111", "item121", "item011"} if !reflect.DeepEqual(have, want) { t.Logf("want: %#v", want) t.Logf("have: %#v", have) t.Fail() } // limit have = getItemGuids(db.ListItems(model.ItemFilter{}, 2, false, false)) want = []string{"item111", "item112"} if !reflect.DeepEqual(have, want) { t.Logf("want: %#v", want) t.Logf("have: %#v", have) t.Fail() } // sort by date have = getItemGuids(db.ListItems(model.ItemFilter{}, 4, true, false)) want = []string{"item013", "item012", "item011", "item212"} if !reflect.DeepEqual(have, want) { t.Logf("want: %#v", want) t.Logf("have: %#v", have) t.Fail() } }) } func TestListItemsPaginated(t *testing.T) { dbtest(t, func(t *testing.T, db storage.Storage) { testItemsSetup(db) itemsByGUID := make(map[string]model.Item) for _, item := range db.ListItems(model.ItemFilter{}, 1000, false, false) { itemsByGUID[item.GUID] = item } item012 := MustGet(itemsByGUID, "item012") item121 := MustGet(itemsByGUID, "item121") // all, newest first have := getItemGuids(db.ListItems(model.ItemFilter{After: &item012.Id}, 3, true, false)) want := []string{"item011", "item212", "item211"} if !reflect.DeepEqual(have, want) { t.Logf("want: %#v", want) t.Logf("have: %#v", have) t.Fail() } // unread, newest first unread := model.UNREAD have = getItemGuids( db.ListItems(model.ItemFilter{After: &item012.Id, Status: &unread}, 3, true, false), ) want = []string{"item011", "item121", "item111"} if !reflect.DeepEqual(have, want) { t.Logf("want: %#v", want) t.Logf("have: %#v", have) t.Fail() } // starred, oldest first starred := model.STARRED have = getItemGuids( db.ListItems(model.ItemFilter{After: &item121.Id, Status: &starred}, 3, false, false), ) want = []string{"item212", "item013"} if !reflect.DeepEqual(have, want) { t.Logf("want: %#v", want) t.Logf("have: %#v", have) t.Fail() } }) } func TestMarkAllItemsRead(t *testing.T) { var read model.ItemStatus = model.READ dbtest(t, func(t *testing.T, db storage.Storage) { testItemsSetup(db) db.MarkItemsRead(model.MarkFilter{}) have := getItemGuids(db.ListItems(model.ItemFilter{Status: &read}, 10, false, false)) want := []string{ "item111", "item112", "item121", "item122", "item211", "item011", "item012", } if !reflect.DeepEqual(have, want) { t.Logf("want: %#v", want) t.Logf("have: %#v", have) t.Fail() } }) } func TestMarkItemsReadByFolder(t *testing.T) { var read model.ItemStatus = model.READ dbtest(t, func(t *testing.T, db storage.Storage) { scope := testItemsSetup(db) db.MarkItemsRead(model.MarkFilter{FolderID: &scope.folder1.Id}) have := getItemGuids(db.ListItems(model.ItemFilter{Status: &read}, 10, false, false)) want := []string{ "item111", "item112", "item121", "item122", "item211", "item012", } if !reflect.DeepEqual(have, want) { t.Logf("want: %#v", want) t.Logf("have: %#v", have) t.Fail() } }) } func TestMarkItemsReadByFeed(t *testing.T) { var read model.ItemStatus = model.READ dbtest(t, func(t *testing.T, db storage.Storage) { scope := testItemsSetup(db) db.MarkItemsRead(model.MarkFilter{FeedID: &scope.feed11.Id}) have := getItemGuids(db.ListItems(model.ItemFilter{Status: &read}, 10, false, false)) want := []string{ "item111", "item112", "item122", "item211", "item012", } if !reflect.DeepEqual(have, want) { t.Logf("want: %#v", want) t.Logf("have: %#v", have) t.Fail() } }) } func TestDeleteOldItems(t *testing.T) { t.Run("keeps at least 50 items", func(t *testing.T) { dbtest(t, func(t *testing.T, db storage.Storage) { synctest.Test(t, func(t *testing.T) { feed := db.CreateFeed(model.CreateFeedParams{Title: "f", FeedLink: "http://f.xml"}) now := time.Now() items := make([]model.Item, 100) for i := range 100 { items[i] = model.Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Hour * 24)} } db.CreateItems(items) // // Set 1 recent (latest), 99 old (100 days ago) time.Sleep(100 * 24 * time.Hour) db.CreateItems([]model.Item{items[99]}) db.DeleteOldItems() remaining := db.ListItems(model.ItemFilter{FeedID: &feed.Id}, 1000, false, false) if len(remaining) != 50 { t.Errorf("expected 50 items, have %d", len(remaining)) } }) }) }) t.Run("keeps all less than 90 days old", func(t *testing.T) { dbtest(t, func(t *testing.T, db storage.Storage) { synctest.Test(t, func(t *testing.T) { feed := db.CreateFeed(model.CreateFeedParams{Title: "f", FeedLink: "http://f.xml"}) now := time.Now() items := make([]model.Item, 100) for i := 0; i < 100; i++ { items[i] = model.Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now} } db.CreateItems(items) // Latest item at "now" // All others at 80 days ago (keep) time.Sleep(80 * 24 * time.Hour) db.CreateItems([]model.Item{items[99]}) db.DeleteOldItems() remaining := db.ListItems(model.ItemFilter{FeedID: &feed.Id}, 1000, false, false) if len(remaining) != 100 { t.Errorf("expected 100 items, have %d", len(remaining)) } }) }) }) t.Run("keeps starred", func(t *testing.T) { dbtest(t, func(t *testing.T, db storage.Storage) { synctest.Test(t, func(t *testing.T) { feed := db.CreateFeed(model.CreateFeedParams{Title: "f", FeedLink: "http://f.xml"}) now := time.Now() items := make([]model.Item, 100) for i := 0; i < 100; i++ { items[i] = model.Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Second)} } db.CreateItems(items) // Set all to 100 days ago, except one recent time.Sleep(100 * 24 * time.Hour) db.CreateItems([]model.Item{items[99]}) // Star 10 old items that would otherwise be deleted (rn > 50 and old) allItems := db.ListItems(model.ItemFilter{FeedID: &feed.Id}, 100, false, false) for _, item := range allItems { guid, _ := strconv.Atoi(item.GUID) if guid < 10 { db.UpdateItemStatus(item.Id, model.STARRED) } } db.DeleteOldItems() // 50 (limit) + 10 (starred) = 60 items should remain. remaining := db.ListItems(model.ItemFilter{FeedID: &feed.Id}, 1000, false, false) if len(remaining) != 60 { t.Errorf("expected 60 items, have %d", len(remaining)) } }) }) }) // }) } func TestDeleteItem(t *testing.T) { dbtest(t, func(t *testing.T, db storage.Storage) { feed := db.CreateFeed(model.CreateFeedParams{FeedLink: "http://test.com/feed.xml"}) db.CreateItems([]model.Item{{GUID: "i1", FeedId: feed.Id, Title: "item"}}) items := db.ListItems(model.ItemFilter{}, 10, false, false) if len(items) != 1 { t.Fatal("expected 1 item") } // delete non-existent returns true (err == nil) if !db.DeleteItem(99999) { t.Error("expected true when deleting non-existent item") } // delete existing if !db.DeleteItem(items[0].Id) { t.Fatal("delete failed") } items = db.ListItems(model.ItemFilter{}, 10, false, false) if len(items) != 0 { t.Errorf("expected 0 items, got %d", len(items)) } }) } func TestCountItems(t *testing.T) { dbtest(t, func(t *testing.T, db storage.Storage) { if count := db.CountItems(); count != 0 { t.Errorf("expected 0, got %d", count) } feed := db.CreateFeed(model.CreateFeedParams{FeedLink: "http://test.com/feed.xml"}) db.CreateItems([]model.Item{ {GUID: "i1", FeedId: feed.Id}, {GUID: "i2", FeedId: feed.Id}, {GUID: "i3", FeedId: feed.Id}, }) if count := db.CountItems(); count != 3 { t.Errorf("expected 3, got %d", count) } items := db.ListItems(model.ItemFilter{}, 10, false, false) db.DeleteItem(items[0].Id) if count := db.CountItems(); count != 2 { t.Errorf("expected 2, got %d", count) } }) } func TestSearch(t *testing.T) { dbtest(t, func(t *testing.T, db storage.Storage) { feed := db.CreateFeed(model.CreateFeedParams{Title: "f", FeedLink: "http://f.xml"}) db.CreateItems([]model.Item{ { GUID: "i1", FeedId: feed.Id, Title: "Hello World", Content: "This is a test of the emergency broadcast system.", }, { GUID: "i2", FeedId: feed.Id, Title: "FTS5 Unicode", Content: "Unicode support with characters like: Привет, 世界, 🚀", }, { GUID: "i3", FeedId: feed.Id, Title: "Hidden Tag", Content: `
Don't find me by my class name
`, }, }) itemsByGUID := make(map[string]model.Item) for _, item := range db.ListItems(model.ItemFilter{}, 1000, false, false) { itemsByGUID[item.GUID] = item } // 1. Basic search s1 := "emergency" have := getItemGuids(db.ListItems(model.ItemFilter{Search: &s1}, 10, true, false)) if !reflect.DeepEqual(have, []string{"i1"}) { t.Errorf("basic search failed: expected [i1], got %v", have) } // 2. HTML stripping: Should find text, but NOT the tags s2 := "test" have = getItemGuids(db.ListItems(model.ItemFilter{Search: &s2}, 10, true, false)) if !reflect.DeepEqual(have, []string{"i1"}) { t.Errorf("html text search failed: expected [i1], got %v", have) } s3 := "secret-class" have = getItemGuids(db.ListItems(model.ItemFilter{Search: &s3}, 10, true, false)) if len(have) > 0 { t.Errorf("html tag search should have failed but found: %v", have) } // 3. Multi-word (AND) s4 := "broadcast system" have = getItemGuids(db.ListItems(model.ItemFilter{Search: &s4}, 10, true, false)) if !reflect.DeepEqual(have, []string{"i1"}) { t.Errorf("multi-word search failed: expected [i1], got %v", have) } // 4. Unicode s5 := "Привет" have = getItemGuids(db.ListItems(model.ItemFilter{Search: &s5}, 10, true, false)) if !reflect.DeepEqual(have, []string{"i2"}) { t.Errorf("unicode search failed: expected [i2], got %v", have) } s6 := "世界" have = getItemGuids(db.ListItems(model.ItemFilter{Search: &s6}, 10, true, false)) if !reflect.DeepEqual(have, []string{"i2"}) { t.Errorf("unicode search (CJK) failed: expected [i2], got %v", have) } // 5. Trigger: Update db.UpdateItem(MustGet(itemsByGUID, "i1").Id, model.UpdateItemParams{Title: ptr("Updated Title")}) s7 := "Updated" have = getItemGuids(db.ListItems(model.ItemFilter{Search: &s7}, 10, true, false)) if !reflect.DeepEqual(have, []string{"i1"}) { t.Errorf("update trigger failed: expected [i1], got %v", have) } // 6. Trigger: Delete // db.db.Exec("delete from items where guid = 'i1'") db.DeleteItem(MustGet(itemsByGUID, "i1").Id) have = getItemGuids(db.ListItems(model.ItemFilter{Search: &s7}, 10, true, false)) if len(have) > 0 { t.Errorf("delete trigger failed: found deleted item: %v", have) } }) }