mirror of
https://github.com/nkanaev/yarr.git
synced 2026-06-27 18:45:17 +00:00
565 lines
16 KiB
Go
565 lines
16 KiB
Go
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 <b>test</b> of the <i>emergency</i> 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: `<div class="secret-class">Don't find me by my class name</div>`,
|
|
},
|
|
})
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|