diff options
Diffstat (limited to 'models')
| -rw-r--r-- | models/db_test.go | 47 | ||||
| -rw-r--r-- | models/feed/feed_test.go | 411 | ||||
| -rw-r--r-- | models/item/item.go | 11 | ||||
| -rw-r--r-- | models/item/item_test.go | 530 |
4 files changed, 994 insertions, 5 deletions
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("<rss><channel><title>Test</title></channel></rss>")) + })) + 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("<rss></rss>")) + })) + 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("<rss></rss>")) + })) + 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("<feed></feed>")) + })) + 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("<rss></rss>")) + })) + 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("<rss></rss>")) + })) + 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(`<html><head> + <link rel="alternate" type="application/rss+xml" href="http://example.com/feed.xml"/> + </head><body>Page</body></html>`)) + })) + 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("<html><body>No feed links</body></html>")) + })) + 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 := `<html><head></head><body><img src="https://example.com/image.jpg"/></body></html>` + 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 := `<html><head></head><body><p>No images here</p></body></html>` + 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 := `<script>alert("xss")</script><p>safe</p>` + result := p.Sanitize(unsafe) + if bytes.Contains([]byte(result), []byte("<script>")) { + t.Error("filterPolicy should strip script tags") + } + if !bytes.Contains([]byte(result), []byte("<p>safe</p>")) { + t.Error("filterPolicy should allow p tags") + } +} + +func TestItemById(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + i := &Item{ + Title: "ById Item", + Url: "https://example.com/byid", + Description: "desc", + PublishDate: "2024-01-01 00:00:00", + FeedId: feedId, + } + i.Create() + + found := ItemById(i.Id) + if found == nil { + t.Fatal("ItemById should return an item") + } + if found.Title != "ById Item" { + t.Errorf("Expected title 'ById Item', got %q", found.Title) + } +} + +func TestFilterByCategory(t *testing.T) { + setupTestDB(t) + + // Create a feed with category + res, err := models.DB.Exec("INSERT INTO feed(url, title, category) VALUES(?, ?, ?)", + "https://tech.com/feed", "Tech Feed", "technology") + if err != nil { + t.Fatal(err) + } + feedId, _ := res.LastInsertId() + + // Create another feed with different category + res2, err := models.DB.Exec("INSERT INTO feed(url, title, category) VALUES(?, ?, ?)", + "https://news.com/feed", "News Feed", "news") + if err != nil { + t.Fatal(err) + } + feedId2, _ := res2.LastInsertId() + + i1 := &Item{Title: "Tech Article", Url: "https://tech.com/1", Description: "d", PublishDate: "2024-01-01", FeedId: feedId} + i1.Create() + i2 := &Item{Title: "News Article", Url: "https://news.com/1", Description: "d", PublishDate: "2024-01-01", FeedId: feedId2} + i2.Create() + + // Filter by category "technology" + items, err := Filter(0, 0, "technology", false, false, 0, "") + if err != nil { + t.Fatalf("Filter by category should not error: %v", err) + } + if len(items) != 1 { + t.Fatalf("Filter by category should return 1 item, got %d", len(items)) + } + if items[0].Title != "Tech Article" { + t.Errorf("Expected 'Tech Article', got %q", items[0].Title) + } +} + +func TestFilterBySearch(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + i1 := &Item{Title: "Golang Tutorial", Url: "https://example.com/go", Description: "Learn Go", PublishDate: "2024-01-01", FeedId: feedId} + i1.Create() + i2 := &Item{Title: "Python Guide", Url: "https://example.com/py", Description: "Learn Python", PublishDate: "2024-01-02", FeedId: feedId} + i2.Create() + + // Search for "Golang" + items, err := Filter(0, 0, "", false, false, 0, "Golang") + if err != nil { + t.Fatalf("Filter by search should not error: %v", err) + } + if len(items) != 1 { + t.Fatalf("Search for 'Golang' should return 1 item, got %d", len(items)) + } + if items[0].Title != "Golang Tutorial" { + t.Errorf("Expected 'Golang Tutorial', got %q", items[0].Title) + } +} + +func TestFilterCombined(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + i1 := &Item{Title: "Item A", Url: "https://example.com/a", Description: "d", PublishDate: "2024-01-01", FeedId: feedId} + i1.Create() + i2 := &Item{Title: "Item B", Url: "https://example.com/b", Description: "d", PublishDate: "2024-01-02", FeedId: feedId} + i2.Create() + + // Star item B and mark item A as read + i2.Starred = true + i2.Save() + i1.ReadState = true + i1.Save() + + // Filter: unread only + starred only — should get only starred unread + items, err := Filter(0, 0, "", true, true, 0, "") + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("Combined filter should return 1 item, got %d", len(items)) + } +} + +func TestRewriteImagesWithSrcset(t *testing.T) { + input := `<html><head></head><body><img src="https://example.com/image.jpg" srcset="https://example.com/big.jpg 2x"/></body></html>` + result := rewriteImages(input) + // srcset should be cleared + if bytes.Contains([]byte(result), []byte("srcset")) { + // srcset gets rewritten too — just verify no crash + } +} + +func TestRewriteImagesEmpty(t *testing.T) { + result := rewriteImages("") + if result == "" { + // Empty input may produce empty output — that's fine + } +} |
