From 16186a344a7b61633cb7342aac37ac56ad83d261 Mon Sep 17 00:00:00 2001 From: Adam Mathes Date: Thu, 12 Feb 2026 19:55:05 -0800 Subject: =?UTF-8?q?Add=20comprehensive=20test=20suite=20=E2=80=94=2081%=20?= =?UTF-8?q?cross-package=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - config: remove unused log import - item: fix Printf format %d->%t for boolean ReadState - util: update stale config.Read -> config.Init, remove config.Config.DBServer Test files added: - config/config_test.go: Init, readConfig, addDefaults (100%) - vlog/vlog_test.go: Printf, Println verbose/silent (100%) - models/db_test.go: InitDB tests - models/feed/feed_test.go: CRUD, filter, Categories, NewFeed, ResolveFeedURL (87%) - models/item/item_test.go: CRUD, Filter with category/search/starred, rewriteImages (71%) - exporter/exporter_test.go: all export formats (91%) - importer/importer_test.go: InsertIItem, ImportJSON (90%) - crawler/crawler_test.go: GetFeedContent, CrawlFeed, CrawlWorker, Crawl (89%) - web/web_test.go: auth, login/logout, stream, item, feed, category, export, crawl, imageProxy handlers (77%) Remaining 0% functions require HTTP/rice.MustFindBox/main entry and can't be unit tested without refactoring (see tickets NK-gqkh96, NK-6q9nyg). --- models/db_test.go | 47 +++++ models/feed/feed_test.go | 411 ++++++++++++++++++++++++++++++++++++ models/item/item.go | 11 +- models/item/item_test.go | 530 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 994 insertions(+), 5 deletions(-) create mode 100644 models/db_test.go create mode 100644 models/feed/feed_test.go create mode 100644 models/item/item_test.go (limited to 'models') diff --git a/models/db_test.go b/models/db_test.go new file mode 100644 index 0000000..08ceb44 --- /dev/null +++ b/models/db_test.go @@ -0,0 +1,47 @@ +package models + +import ( + "testing" + + "adammathes.com/neko/config" +) + +func TestInitDB(t *testing.T) { + config.Config.DBFile = ":memory:" + InitDB() + defer DB.Close() + + if DB == nil { + t.Fatal("DB should not be nil after InitDB") + } + + err := DB.Ping() + if err != nil { + t.Fatalf("DB.Ping() should succeed: %v", err) + } + + // Verify schema was created by checking tables exist + var name string + err = DB.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='feed'").Scan(&name) + if err != nil { + t.Fatalf("feed table should exist: %v", err) + } + + err = DB.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='item'").Scan(&name) + if err != nil { + t.Fatalf("item table should exist: %v", err) + } +} + +// SetupTestDB initializes an in-memory SQLite database for testing. +// Call this from other packages' tests to get a working DB. +func SetupTestDB(t *testing.T) { + t.Helper() + config.Config.DBFile = ":memory:" + InitDB() + t.Cleanup(func() { + if DB != nil { + DB.Close() + } + }) +} diff --git a/models/feed/feed_test.go b/models/feed/feed_test.go new file mode 100644 index 0000000..d02916a --- /dev/null +++ b/models/feed/feed_test.go @@ -0,0 +1,411 @@ +package feed + +import ( + "net/http" + "net/http/httptest" + "testing" + + "adammathes.com/neko/config" + "adammathes.com/neko/models" +) + +func setupTestDB(t *testing.T) { + t.Helper() + config.Config.DBFile = ":memory:" + models.InitDB() + t.Cleanup(func() { + if models.DB != nil { + models.DB.Close() + } + }) +} + +func TestCreateAndByUrl(t *testing.T) { + setupTestDB(t) + + f := &Feed{Url: "https://example.com/feed.xml", Title: "Example Feed"} + err := f.Create() + if err != nil { + t.Fatalf("Create() should not error: %v", err) + } + if f.Id == 0 { + t.Error("Create() should set Id") + } + + // Test ByUrl + f2 := &Feed{} + err = f2.ByUrl("https://example.com/feed.xml") + if err != nil { + t.Fatalf("ByUrl() should not error: %v", err) + } + if f2.Id != f.Id { + t.Errorf("ByUrl() Id mismatch: got %d, want %d", f2.Id, f.Id) + } + if f2.Title != "Example Feed" { + t.Errorf("ByUrl() Title mismatch: got %q, want %q", f2.Title, "Example Feed") + } +} + +func TestByUrlNotFound(t *testing.T) { + setupTestDB(t) + + f := &Feed{} + err := f.ByUrl("https://nonexistent.com/feed.xml") + if err == nil { + t.Error("ByUrl() should error for nonexistent feed") + } +} + +func TestAll(t *testing.T) { + setupTestDB(t) + + // Insert two feeds + f1 := &Feed{Url: "https://a.com/feed", Title: "Alpha"} + f1.Create() + f2 := &Feed{Url: "https://b.com/feed", Title: "Beta"} + f2.Create() + + feeds, err := All() + if err != nil { + t.Fatalf("All() should not error: %v", err) + } + if len(feeds) != 2 { + t.Fatalf("All() should return 2 feeds, got %d", len(feeds)) + } + + // Should be ordered by title (lowercase) + if feeds[0].Title != "Alpha" { + t.Errorf("First feed should be Alpha, got %q", feeds[0].Title) + } + if feeds[1].Title != "Beta" { + t.Errorf("Second feed should be Beta, got %q", feeds[1].Title) + } +} + +func TestUpdate(t *testing.T) { + setupTestDB(t) + + f := &Feed{Url: "https://example.com/feed", Title: "Original"} + f.Create() + + f.Title = "Updated" + f.WebUrl = "https://example.com" + f.Category = "tech" + f.Update() + + // Verify by fetching + f2 := &Feed{} + err := f2.ByUrl("https://example.com/feed") + if err != nil { + t.Fatal(err) + } + if f2.Title != "Updated" { + t.Errorf("Title should be 'Updated', got %q", f2.Title) + } +} + +func TestUpdateEmptyTitle(t *testing.T) { + setupTestDB(t) + + f := &Feed{Url: "https://example.com/feed", Title: "Original"} + f.Create() + + // Update with empty title should be a no-op + f.Title = "" + f.Update() + + f2 := &Feed{} + f2.ByUrl("https://example.com/feed") + if f2.Title != "Original" { + t.Errorf("Title should remain 'Original' after empty-title update, got %q", f2.Title) + } +} + +func TestUpdateZeroId(t *testing.T) { + setupTestDB(t) + + f := &Feed{Id: 0, Url: "https://example.com/feed", Title: "Test"} + // Should be a no-op since ID is 0 + f.Update() +} + +func TestUpdateEmptyUrl(t *testing.T) { + setupTestDB(t) + + f := &Feed{Id: 1, Url: "", Title: "Test"} + // Should be a no-op since URL is empty + f.Update() +} + +func TestDelete(t *testing.T) { + setupTestDB(t) + + f := &Feed{Url: "https://example.com/feed", Title: "ToDelete"} + f.Create() + + f.Delete() + + feeds, _ := All() + if len(feeds) != 0 { + t.Errorf("After delete, All() should return 0 feeds, got %d", len(feeds)) + } +} + +func TestCategories(t *testing.T) { + setupTestDB(t) + + f1 := &Feed{Url: "https://a.com/feed", Title: "A"} + f1.Create() + f1.Category = "tech" + f1.Update() + + f2 := &Feed{Url: "https://b.com/feed", Title: "B"} + f2.Create() + f2.Category = "news" + f2.Update() + + f3 := &Feed{Url: "https://c.com/feed", Title: "C"} + f3.Create() + f3.Category = "tech" + f3.Update() + + cats, err := Categories() + if err != nil { + t.Fatalf("Categories() should not error: %v", err) + } + if len(cats) != 2 { + t.Fatalf("Should have 2 distinct categories, got %d", len(cats)) + } +} + +func TestNewFeed(t *testing.T) { + setupTestDB(t) + + // Create a test server that returns RSS content-type + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + w.WriteHeader(200) + w.Write([]byte("Test")) + })) + defer ts.Close() + + err := NewFeed(ts.URL) + if err != nil { + t.Fatalf("NewFeed should not error: %v", err) + } + + // Verify the feed was inserted + f := &Feed{} + err = f.ByUrl(ts.URL) + if err != nil { + t.Fatalf("Feed should exist after NewFeed: %v", err) + } +} + +func TestNewFeedDuplicate(t *testing.T) { + setupTestDB(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + w.WriteHeader(200) + w.Write([]byte("")) + })) + defer ts.Close() + + NewFeed(ts.URL) + err := NewFeed(ts.URL) + if err == nil { + t.Error("NewFeed should error for duplicate URL") + } +} + +func TestResolveFeedURLDirectRSS(t *testing.T) { + // Server returns RSS content-type directly + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + w.WriteHeader(200) + w.Write([]byte("")) + })) + defer ts.Close() + + result := ResolveFeedURL(ts.URL) + if result != ts.URL { + t.Errorf("Expected original URL for RSS content-type, got %q", result) + } +} + +func TestResolveFeedURLAtom(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/atom+xml") + w.WriteHeader(200) + w.Write([]byte("")) + })) + defer ts.Close() + + result := ResolveFeedURL(ts.URL) + if result != ts.URL { + t.Errorf("Expected original URL for Atom content-type, got %q", result) + } +} + +func TestResolveFeedURLTextXML(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml") + w.WriteHeader(200) + w.Write([]byte("")) + })) + defer ts.Close() + + result := ResolveFeedURL(ts.URL) + if result != ts.URL { + t.Errorf("Expected original URL for text/xml, got %q", result) + } +} + +func TestResolveFeedURLTextRSS(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/rss+xml") + w.WriteHeader(200) + w.Write([]byte("")) + })) + defer ts.Close() + + result := ResolveFeedURL(ts.URL) + if result != ts.URL { + t.Errorf("Expected original URL for text/rss+xml, got %q", result) + } +} + +func TestResolveFeedURLHTMLWithLinks(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(200) + w.Write([]byte(` + + Page`)) + })) + defer ts.Close() + + result := ResolveFeedURL(ts.URL) + if result != "http://example.com/feed.xml" { + t.Errorf("Expected discovered feed URL, got %q", result) + } +} + +func TestResolveFeedURLHTMLNoLinks(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(200) + w.Write([]byte("No feed links")) + })) + defer ts.Close() + + result := ResolveFeedURL(ts.URL) + // Should fall back to original URL when no feed links + if result != ts.URL { + t.Errorf("Expected fallback to original URL, got %q", result) + } +} + +func TestResolveFeedURLBadURL(t *testing.T) { + result := ResolveFeedURL("http://invalid.invalid.invalid:99999") + if result != "http://invalid.invalid.invalid:99999" { + t.Errorf("Expected original URL on error, got %q", result) + } +} + +func TestFilterEmpty(t *testing.T) { + setupTestDB(t) + + feeds, err := filter("") + if err != nil { + t.Fatalf("filter() should not error: %v", err) + } + if len(feeds) != 0 { + t.Errorf("filter() on empty DB should return 0 feeds, got %d", len(feeds)) + } +} + +func TestFilterByCategory(t *testing.T) { + setupTestDB(t) + + f1 := &Feed{Url: "https://a.com/feed", Title: "A"} + f1.Create() + f1.Category = "tech" + f1.Update() + + f2 := &Feed{Url: "https://b.com/feed", Title: "B"} + f2.Create() + f2.Category = "news" + f2.Update() + + // Filter by "tech" category using proper WHERE clause + feeds, err := filter("WHERE category='tech'") + if err != nil { + t.Fatalf("filter with category should not error: %v", err) + } + if len(feeds) != 1 { + t.Fatalf("filter with category should return 1 feed, got %d", len(feeds)) + } + if feeds[0].Title != "A" { + t.Errorf("Expected feed 'A', got %q", feeds[0].Title) + } +} + +func TestDeleteWithItems(t *testing.T) { + setupTestDB(t) + + f := &Feed{Url: "https://example.com/feed", Title: "ToDelete"} + f.Create() + + // Add an item to this feed + _, err := models.DB.Exec("INSERT INTO item(title, url, description, feed_id) VALUES(?, ?, ?, ?)", + "Item 1", "https://example.com/1", "d", f.Id) + if err != nil { + t.Fatal(err) + } + + f.Delete() + + // Verify feed deleted + feeds, _ := All() + if len(feeds) != 0 { + t.Errorf("After delete, should have 0 feeds, got %d", len(feeds)) + } + + // Note: Delete() only removes the feed row, not associated items + // (no cascade in the schema) + var count int + models.DB.QueryRow("SELECT COUNT(*) FROM item WHERE feed_id=?", f.Id).Scan(&count) + if count != 1 { + t.Errorf("Items should still exist after feed-only delete, got %d", count) + } +} + +func TestCreateDuplicate(t *testing.T) { + setupTestDB(t) + + f1 := &Feed{Url: "https://same.com/feed", Title: "First"} + err := f1.Create() + if err != nil { + t.Fatal(err) + } + + f2 := &Feed{Url: "https://same.com/feed", Title: "Second"} + err = f2.Create() + if err == nil { + t.Error("Create() should error for duplicate URL") + } +} + +func TestCategoriesEmpty(t *testing.T) { + setupTestDB(t) + + cats, err := Categories() + if err != nil { + t.Fatalf("Categories() should not error on empty DB: %v", err) + } + if len(cats) != 0 { + t.Errorf("Should have 0 categories on empty DB, got %d", len(cats)) + } +} diff --git a/models/item/item.go b/models/item/item.go index 500fec9..9639595 100644 --- a/models/item/item.go +++ b/models/item/item.go @@ -1,16 +1,17 @@ package item import ( + "encoding/base64" + "fmt" + "strings" + "adammathes.com/neko/config" "adammathes.com/neko/models" "adammathes.com/neko/vlog" - "encoding/base64" - "fmt" "github.com/PuerkitoBio/goquery" - "github.com/advancedlogic/GoOse" + goose "github.com/advancedlogic/GoOse" "github.com/microcosm-cc/bluemonday" "github.com/russross/blackfriday" - "strings" ) type Item struct { @@ -37,7 +38,7 @@ type Item struct { func (i *Item) Print() { fmt.Printf("id: %d\n", i.Id) fmt.Printf("title: %s\n", i.Title) - fmt.Printf("ReadState: %d\n", i.ReadState) + fmt.Printf("ReadState: %t\n", i.ReadState) } func (i *Item) Create() error { diff --git a/models/item/item_test.go b/models/item/item_test.go new file mode 100644 index 0000000..ef81c76 --- /dev/null +++ b/models/item/item_test.go @@ -0,0 +1,530 @@ +package item + +import ( + "bytes" + "fmt" + "os" + "testing" + + "adammathes.com/neko/config" + "adammathes.com/neko/models" +) + +func setupTestDB(t *testing.T) { + t.Helper() + config.Config.DBFile = ":memory:" + models.InitDB() + t.Cleanup(func() { + if models.DB != nil { + models.DB.Close() + } + }) +} + +func createTestFeed(t *testing.T) int64 { + t.Helper() + res, err := models.DB.Exec("INSERT INTO feed(url, title) VALUES(?, ?)", "https://example.com/feed", "Test Feed") + if err != nil { + t.Fatal(err) + } + id, _ := res.LastInsertId() + return id +} + +func TestPrint(t *testing.T) { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + i := &Item{Id: 42, Title: "Test Title", ReadState: true} + i.Print() + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + expected := fmt.Sprintf("id: 42\ntitle: Test Title\nReadState: true\n") + if output != expected { + t.Errorf("Print() output mismatch:\ngot: %q\nwant: %q", output, expected) + } +} + +func TestCleanHeaderImage(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"blank wp image", "https://s0.wp.com/i/blank.jpg", ""}, + {"normal image", "https://example.com/image.jpg", "https://example.com/image.jpg"}, + {"empty string", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + i := &Item{HeaderImage: tt.input} + i.CleanHeaderImage() + if i.HeaderImage != tt.expected { + t.Errorf("got %q, want %q", i.HeaderImage, tt.expected) + } + }) + } +} + +func TestProxyURL(t *testing.T) { + url := "https://example.com/image.jpg" + result := proxyURL(url) + if result == "" { + t.Error("proxyURL should not return empty string") + } + if result[:7] != "/image/" { + t.Errorf("proxyURL should start with '/image/', got %q", result[:7]) + } +} + +func TestRewriteImages(t *testing.T) { + input := `` + result := rewriteImages(input) + if result == "" { + t.Error("rewriteImages should not return empty") + } + // The src should be rewritten to use the proxy + if !bytes.Contains([]byte(result), []byte("/image/")) { + t.Errorf("rewriteImages should contain proxy URL, got %q", result) + } +} + +func TestRewriteImagesNoImages(t *testing.T) { + input := `

No images here

` + result := rewriteImages(input) + if result == "" { + t.Error("rewriteImages should not return empty for input without images") + } +} + +func TestCreate(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + i := &Item{ + Title: "Test Item", + Url: "https://example.com/article", + Description: "A test item", + PublishDate: "2024-01-01 00:00:00", + FeedId: feedId, + } + err := i.Create() + if err != nil { + t.Fatalf("Create() should not error: %v", err) + } + if i.Id == 0 { + t.Error("Create() should set Id") + } +} + +func TestCreateDuplicateUrl(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + i1 := &Item{ + Title: "Item 1", + Url: "https://example.com/same-url", + Description: "First", + PublishDate: "2024-01-01 00:00:00", + FeedId: feedId, + } + i1.Create() + + i2 := &Item{ + Title: "Item 2", + Url: "https://example.com/same-url", + Description: "Duplicate", + PublishDate: "2024-01-02 00:00:00", + FeedId: feedId, + } + err := i2.Create() + if err == nil { + t.Error("Create() with duplicate URL should error") + } +} + +func TestSave(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + i := &Item{ + Title: "Test Item", + Url: "https://example.com/article", + Description: "A test item", + PublishDate: "2024-01-01 00:00:00", + FeedId: feedId, + } + i.Create() + + i.ReadState = true + i.Starred = true + i.Save() + + // Verify via direct query + var readState, starred bool + err := models.DB.QueryRow("SELECT read_state, starred FROM item WHERE id=?", i.Id).Scan(&readState, &starred) + if err != nil { + t.Fatal(err) + } + if !readState { + t.Error("ReadState should be true after Save") + } + if !starred { + t.Error("Starred should be true after Save") + } +} + +func TestFullSave(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + i := &Item{ + Title: "Original Title", + Url: "https://example.com/article", + Description: "Original desc", + PublishDate: "2024-01-01 00:00:00", + FeedId: feedId, + } + i.Create() + + i.Title = "Updated Title" + i.Description = "Updated desc" + i.FullSave() + + // Verify via direct query + var title, desc string + err := models.DB.QueryRow("SELECT title, description FROM item WHERE id=?", i.Id).Scan(&title, &desc) + if err != nil { + t.Fatal(err) + } + if title != "Updated Title" { + t.Errorf("Title should be 'Updated Title', got %q", title) + } + if desc != "Updated desc" { + t.Errorf("Description should be 'Updated desc', got %q", desc) + } +} + +func TestFilterBasic(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + // Insert an item + i := &Item{ + Title: "Filterable Item", + Url: "https://example.com/filterable", + Description: "A filterable item", + PublishDate: "2024-01-01 00:00:00", + FeedId: feedId, + } + i.Create() + + // Filter with no constraints (except unread_only=false to not filter by read) + items, err := Filter(0, 0, "", false, false, 0, "") + if err != nil { + t.Fatalf("Filter() should not error: %v", err) + } + if len(items) != 1 { + t.Fatalf("Filter() should return 1 item, got %d", len(items)) + } + if items[0].Title != "Filterable Item" { + t.Errorf("Unexpected title: %q", items[0].Title) + } +} + +func TestFilterByFeedId(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + i := &Item{ + Title: "Item 1", + Url: "https://example.com/1", + Description: "desc", + PublishDate: "2024-01-01 00:00:00", + FeedId: feedId, + } + i.Create() + + // Filter by a non-matching feed id + items, err := Filter(0, 999, "", false, false, 0, "") + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Errorf("Filter by non-matching feed_id should return 0 items, got %d", len(items)) + } + + // Filter by matching feed id + items, err = Filter(0, feedId, "", false, false, 0, "") + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Errorf("Filter by matching feed_id should return 1 item, got %d", len(items)) + } +} + +func TestFilterUnreadOnly(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + i := &Item{ + Title: "Unread Item", + Url: "https://example.com/unread", + Description: "desc", + PublishDate: "2024-01-01 00:00:00", + FeedId: feedId, + } + i.Create() + + // All items start unread (read_state=0) + items, err := Filter(0, 0, "", true, false, 0, "") + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("Unread filter should return 1 item, got %d", len(items)) + } + + // Mark as read + i.ReadState = true + i.Save() + + items, err = Filter(0, 0, "", true, false, 0, "") + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Errorf("Unread filter should return 0 items after marking read, got %d", len(items)) + } +} + +func TestFilterStarredOnly(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + i := &Item{ + Title: "Starred Item", + Url: "https://example.com/starred", + Description: "desc", + PublishDate: "2024-01-01 00:00:00", + FeedId: feedId, + } + i.Create() + + // Not starred yet + items, err := Filter(0, 0, "", false, true, 0, "") + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Errorf("Starred filter should return 0 items initially, got %d", len(items)) + } + + // Star it + i.Starred = true + i.Save() + + items, err = Filter(0, 0, "", false, true, 0, "") + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Errorf("Starred filter should return 1 item, got %d", len(items)) + } +} + +func TestFilterByItemId(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + i := &Item{ + Title: "Specific Item", + Url: "https://example.com/specific", + Description: "desc", + PublishDate: "2024-01-01 00:00:00", + FeedId: feedId, + } + i.Create() + + items, err := Filter(0, 0, "", false, false, i.Id, "") + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("Filter by item_id should return 1 item, got %d", len(items)) + } + if items[0].Id != i.Id { + t.Errorf("Unexpected item id: %d", items[0].Id) + } +} + +func TestFilterByMaxId(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + i1 := &Item{Title: "Item 1", Url: "https://example.com/1", Description: "d", PublishDate: "2024-01-01", FeedId: feedId} + i1.Create() + + i2 := &Item{Title: "Item 2", Url: "https://example.com/2", Description: "d", PublishDate: "2024-01-02", FeedId: feedId} + i2.Create() + + // max_id = i2.Id should only return items with id < i2.Id + items, err := Filter(i2.Id, 0, "", false, false, 0, "") + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("MaxId filter should return 1 item, got %d", len(items)) + } + if items[0].Id != i1.Id { + t.Errorf("Expected item %d, got %d", i1.Id, items[0].Id) + } +} + +func TestFilterPolicy(t *testing.T) { + p := filterPolicy() + if p == nil { + t.Fatal("filterPolicy should not return nil") + } + + // Test that it strips disallowed tags + unsafe := `

safe

` + result := p.Sanitize(unsafe) + if bytes.Contains([]byte(result), []byte("