package feed import ( "net/http" "net/http/httptest" "path/filepath" "testing" "adammathes.com/neko/config" "adammathes.com/neko/internal/safehttp" "adammathes.com/neko/models" ) func init() { safehttp.AllowLocal = true } func setupTestDB(t *testing.T) { t.Helper() config.Config.DBFile = filepath.Join(t.TempDir(), "test.db") 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)) } } func TestResolveFeedURLRelativePaths(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(``)) })) defer ts.Close() result := ResolveFeedURL(ts.URL) if result != ts.URL+"/rss.xml" { t.Errorf("Expected joined relative URL, got %q", result) } } func TestResolveFeedURLMultipleLinks(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(` `)) })) defer ts.Close() result := ResolveFeedURL(ts.URL) // it should pick the first matched one or whichever type is handled first in the code if result != "http://example.com/atom.xml" && result != "http://example.com/rss.xml" { t.Errorf("Expected one of the discovered feed URLs, got %q", result) } }