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/item/item.go | 11 +- models/item/item_test.go | 530 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 536 insertions(+), 5 deletions(-) create mode 100644 models/item/item_test.go (limited to 'models/item') 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("