From 525e885229854bf6ae4679b95eb3f202283baa0b Mon Sep 17 00:00:00 2001 From: phedayat Date: Sun, 26 Jul 2026 09:08:16 -0500 Subject: [PATCH 01/11] Added blog group flag to commands --- internal/cli/commands.go | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 305e8e1..250af7d 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -53,7 +53,7 @@ func newAddCommand() *cobra.Command { name := args[0] url := args[1] return withDatabase(cmd, func(db *storage.Database) error { - _, err := controller.AddBlog(cmd.Context(), db, name, url, viper.GetString("feed-url"), viper.GetString("scrape-selector")) + _, err := controller.AddBlog(cmd.Context(), db, name, url, viper.GetString("feed-url"), viper.GetString("scrape-selector"), viper.GetString("group")) if err != nil { printError(err) return markError(err) @@ -65,6 +65,7 @@ func newAddCommand() *cobra.Command { } cmd.Flags().String("feed-url", "", "RSS/Atom feed URL (auto-discovered if not provided)") cmd.Flags().String("scrape-selector", "", "CSS selector for HTML scraping fallback") + cmd.Flags().StringP("group", "g", "", "Group name for organizing this blog") return cmd } @@ -104,12 +105,17 @@ func newBlogsCommand() *cobra.Command { Short: "List all tracked blogs.", RunE: func(cmd *cobra.Command, args []string) error { return withDatabase(cmd, func(db *storage.Database) error { - blogs, err := db.ListBlogs(cmd.Context()) + group := viper.GetString("group") + blogs, err := db.ListBlogs(cmd.Context(), stringPtrOrNil(group)) if err != nil { return err } if len(blogs) == 0 { - fmt.Println("No blogs tracked yet. Use 'blogwatcher-cli add' to add one.") + if group != "" { + fmt.Printf("No blogs found in group '%s'.\n", group) + } else { + fmt.Println("No blogs tracked yet. Use 'blogwatcher-cli add' to add one.") + } return nil } cprintf([]color.Attribute{color.FgCyan, color.Bold}, "Tracked blogs (%d):\n\n", len(blogs)) @@ -122,6 +128,9 @@ func newBlogsCommand() *cobra.Command { if blog.ScrapeSelector != "" { fmt.Printf(" Selector: %s\n", blog.ScrapeSelector) } + if blog.Group != "" { + fmt.Printf(" Group: %s\n", blog.Group) + } if blog.LastScanned != nil { fmt.Printf(" Last scanned: %s\n", blog.LastScanned.Format("2006-01-02 15:04")) } @@ -131,6 +140,7 @@ func newBlogsCommand() *cobra.Command { }) }, } + cmd.Flags().StringP("group", "g", "", "Filter by group name") return cmd } @@ -161,18 +171,23 @@ func newScanCommand() *cobra.Command { printScanResult(*result) } } else { - blogs, err := db.ListBlogs(cmd.Context()) + groupName := viper.GetString("group") + blogs, err := db.ListBlogs(cmd.Context(), stringPtrOrNil(groupName)) if err != nil { return err } if len(blogs) == 0 { - fmt.Println("No blogs tracked yet. Use 'blogwatcher-cli add' to add one.") + if groupName != "" { + fmt.Printf("No blogs found in group '%s'.\n", groupName) + } else { + fmt.Println("No blogs tracked yet. Use 'blogwatcher-cli add' to add one.") + } return nil } if !silent { cprintf([]color.Attribute{color.FgCyan}, "Scanning %d blog(s)...\n\n", len(blogs)) } - results, err := sc.ScanAllBlogs(cmd.Context(), db, workers) + results, err := sc.ScanAllBlogs(cmd.Context(), db, workers, groupName) if err != nil { return err } @@ -218,6 +233,7 @@ func newScanCommand() *cobra.Command { } cmd.Flags().BoolP("silent", "s", false, "Only output 'scan done' when complete") cmd.Flags().IntP("workers", "w", 8, "Number of concurrent workers when scanning all blogs") + cmd.Flags().StringP("group", "g", "", "Only scan blogs in this group") return cmd } @@ -235,7 +251,7 @@ func newArticlesCommand() *cobra.Command { } return withDatabase(cmd, func(db *storage.Database) error { - articles, blogNames, err := controller.GetArticles(cmd.Context(), db, showAll, viper.GetString("blog"), viper.GetString("category"), since, before) + articles, blogNames, err := controller.GetArticles(cmd.Context(), db, showAll, viper.GetString("blog"), viper.GetString("category"), viper.GetString("group"), since, before) if err != nil { printError(err) return markError(err) @@ -265,6 +281,7 @@ func newArticlesCommand() *cobra.Command { cmd.Flags().BoolP("all", "a", false, "Show all articles (including read)") cmd.Flags().StringP("blog", "b", "", "Filter by blog name") cmd.Flags().StringP("category", "c", "", "Filter by category") + cmd.Flags().StringP("group", "g", "", "Filter by group name") cmd.Flags().String("since", "", "Show articles published on or after YYYY-MM-DD") cmd.Flags().String("before", "", "Show articles published before YYYY-MM-DD") return cmd @@ -306,7 +323,7 @@ func newReadAllCommand() *cobra.Command { blogName := viper.GetString("blog") return withDatabase(cmd, func(db *storage.Database) error { - articles, _, err := controller.GetArticles(cmd.Context(), db, false, blogName, "", nil, nil) + articles, _, err := controller.GetArticles(cmd.Context(), db, false, blogName, "", "", nil, nil) if err != nil { printError(err) return markError(err) @@ -474,6 +491,13 @@ func csprintf(attrs []color.Attribute, format string, a ...any) string { return color.New(attrs...).Sprintf(format, a...) } +func stringPtrOrNil(s string) *string { + if s == "" { + return nil + } + return &s +} + func parseID(value string) (int64, error) { parsed, err := strconv.ParseInt(value, 10, 64) if err != nil { From c806c11864c76d06478ab4f9bbbd5ad05a91e16b Mon Sep 17 00:00:00 2001 From: phedayat Date: Sun, 26 Jul 2026 09:09:14 -0500 Subject: [PATCH 02/11] Updated relevant blog and article controllers to include blog group --- internal/controller/controller.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/internal/controller/controller.go b/internal/controller/controller.go index a6fd8f0..e764b51 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -58,7 +58,7 @@ func validateHTTPURL(s string) error { return nil } -func AddBlog(ctx context.Context, db *storage.Database, name string, urlStr string, feedURL string, scrapeSelector string) (model.Blog, error) { +func AddBlog(ctx context.Context, db *storage.Database, name string, urlStr string, feedURL string, scrapeSelector string, group string) (model.Blog, error) { if err := validateHTTPURL(urlStr); err != nil { return model.Blog{}, err } @@ -84,6 +84,7 @@ func AddBlog(ctx context.Context, db *storage.Database, name string, urlStr stri URL: urlStr, FeedURL: feedURL, ScrapeSelector: scrapeSelector, + Group: group, } return db.AddBlog(ctx, blog) } @@ -100,7 +101,7 @@ func RemoveBlog(ctx context.Context, db *storage.Database, name string) error { return err } -func GetArticles(ctx context.Context, db *storage.Database, showAll bool, blogName string, category string, since *time.Time, before *time.Time) ([]model.Article, map[int64]string, error) { +func GetArticles(ctx context.Context, db *storage.Database, showAll bool, blogName string, category string, group string, since *time.Time, before *time.Time) ([]model.Article, map[int64]string, error) { var blogID *int64 if blogName != "" { blog, err := db.GetBlogByName(ctx, blogName) @@ -118,11 +119,16 @@ func GetArticles(ctx context.Context, db *storage.Database, showAll bool, blogNa categoryPtr = &category } - articles, err := db.ListArticles(ctx, !showAll, blogID, categoryPtr, since, before) + var groupPtr *string + if group != "" { + groupPtr = &group + } + + articles, err := db.ListArticles(ctx, !showAll, blogID, categoryPtr, groupPtr, since, before) if err != nil { return nil, nil, err } - blogs, err := db.ListBlogs(ctx) + blogs, err := db.ListBlogs(ctx, nil) if err != nil { return nil, nil, err } @@ -164,7 +170,7 @@ func MarkAllArticlesRead(ctx context.Context, db *storage.Database, blogName str blogID = &blog.ID } - articles, err := db.ListArticles(ctx, true, blogID, nil, nil, nil) + articles, err := db.ListArticles(ctx, true, blogID, nil, nil, nil, nil) if err != nil { return nil, err } @@ -193,7 +199,7 @@ func ImportOPML(ctx context.Context, db *storage.Database, r io.Reader) (added i if title == "" { title = siteURL } - _, err := AddBlog(ctx, db, title, siteURL, feed.FeedURL, "") + _, err := AddBlog(ctx, db, title, siteURL, feed.FeedURL, "", "") if err != nil { var alreadyExists BlogAlreadyExistsError var invalidURL InvalidURLError From 07ba8a1210fa50916576413f9ee0ebb60977ff0e Mon Sep 17 00:00:00 2001 From: phedayat Date: Sun, 26 Jul 2026 09:09:30 -0500 Subject: [PATCH 03/11] Added blog group to model --- internal/model/model.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/model/model.go b/internal/model/model.go index 07d0fee..88961e8 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -8,6 +8,7 @@ type Blog struct { URL string FeedURL string ScrapeSelector string + Group string LastScanned *time.Time } From 7ca9c267f81b4125ba9a44b4f53466c1b24e3cd2 Mon Sep 17 00:00:00 2001 From: phedayat Date: Sun, 26 Jul 2026 09:10:22 -0500 Subject: [PATCH 04/11] Added blog group to scanner --- internal/scanner/scanner.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 3d8b05e..d687ccf 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -177,8 +177,12 @@ func (s *Scanner) ScanBlog(ctx context.Context, db *storage.Database, blog model }, nil } -func (s *Scanner) ScanAllBlogs(ctx context.Context, db *storage.Database, workers int) ([]ScanResult, error) { - blogs, err := db.ListBlogs(ctx) +func (s *Scanner) ScanAllBlogs(ctx context.Context, db *storage.Database, workers int, groupName string) ([]ScanResult, error) { + var groupPtr *string + if groupName != "" { + groupPtr = &groupName + } + blogs, err := db.ListBlogs(ctx, groupPtr) if err != nil { return nil, err } From cf31fb8b8e9625195ebab579a7b3a0789c9266a5 Mon Sep 17 00:00:00 2001 From: phedayat Date: Sun, 26 Jul 2026 09:36:50 -0500 Subject: [PATCH 05/11] Added up and down migrations for blog groups --- .../migrations/000004_add_blog_group.down.sql | 54 +++++++++++++++++++ .../migrations/000004_add_blog_group.up.sql | 1 + 2 files changed, 55 insertions(+) create mode 100644 internal/storage/migrations/000004_add_blog_group.down.sql create mode 100644 internal/storage/migrations/000004_add_blog_group.up.sql diff --git a/internal/storage/migrations/000004_add_blog_group.down.sql b/internal/storage/migrations/000004_add_blog_group.down.sql new file mode 100644 index 0000000..d2f4f30 --- /dev/null +++ b/internal/storage/migrations/000004_add_blog_group.down.sql @@ -0,0 +1,54 @@ +-- SQLite does not support DROP COLUMN prior to 3.35.0. +-- Recreate the blogs table without the group_name column. +-- +-- foreign_keys is enabled on every connection (see OpenDatabase), and +-- articles.blog_id references blogs(id). Dropping blogs while any table's +-- schema still declares that reference would fail with a foreign key +-- constraint violation. So articles is rebuilt too: first into a plain +-- temp table with no foreign key clause (nothing then references blogs, +-- so dropping it is safe), then recreated with the original foreign key +-- once blogs exists again under its final name. +CREATE TABLE articles_temp ( + id INTEGER PRIMARY KEY, + blog_id INTEGER NOT NULL, + title TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + published_date TIMESTAMP, + discovered_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_read BOOLEAN DEFAULT FALSE, + categories TEXT +); + +INSERT INTO articles_temp SELECT id, blog_id, title, url, published_date, discovered_date, is_read, categories FROM articles; + +CREATE TABLE blogs_backup ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + feed_url TEXT, + scrape_selector TEXT, + last_scanned TIMESTAMP +); + +INSERT INTO blogs_backup SELECT id, name, url, feed_url, scrape_selector, last_scanned FROM blogs; + +DROP TABLE articles; +DROP TABLE blogs; + +ALTER TABLE blogs_backup RENAME TO blogs; + +CREATE TABLE articles ( + id INTEGER PRIMARY KEY, + blog_id INTEGER NOT NULL, + title TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + published_date TIMESTAMP, + discovered_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_read BOOLEAN DEFAULT FALSE, + categories TEXT, + FOREIGN KEY (blog_id) REFERENCES blogs(id) +); + +INSERT INTO articles SELECT id, blog_id, title, url, published_date, discovered_date, is_read, categories FROM articles_temp; + +DROP TABLE articles_temp; diff --git a/internal/storage/migrations/000004_add_blog_group.up.sql b/internal/storage/migrations/000004_add_blog_group.up.sql new file mode 100644 index 0000000..f8fe49c --- /dev/null +++ b/internal/storage/migrations/000004_add_blog_group.up.sql @@ -0,0 +1 @@ +ALTER TABLE blogs ADD COLUMN group_name TEXT; From 5891163783a91ae0d580704d0727ff45a1053df9 Mon Sep 17 00:00:00 2001 From: phedayat Date: Sun, 26 Jul 2026 09:37:12 -0500 Subject: [PATCH 06/11] Updated db calls to include new blog groups column group_name --- internal/storage/database.go | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/internal/storage/database.go b/internal/storage/database.go index 9b3ab99..977ed08 100644 --- a/internal/storage/database.go +++ b/internal/storage/database.go @@ -113,8 +113,8 @@ func (db *Database) migrate() error { func (db *Database) AddBlog(ctx context.Context, blog model.Blog) (model.Blog, error) { result, err := sq.Insert("blogs"). - Columns("name", "url", "feed_url", "scrape_selector", "last_scanned"). - Values(blog.Name, blog.URL, nullIfEmpty(blog.FeedURL), nullIfEmpty(blog.ScrapeSelector), formatTimePtr(blog.LastScanned)). + Columns("name", "url", "feed_url", "scrape_selector", "group_name", "last_scanned"). + Values(blog.Name, blog.URL, nullIfEmpty(blog.FeedURL), nullIfEmpty(blog.ScrapeSelector), nullIfEmpty(blog.Group), formatTimePtr(blog.LastScanned)). RunWith(db.conn). ExecContext(ctx) if err != nil { @@ -129,7 +129,7 @@ func (db *Database) AddBlog(ctx context.Context, blog model.Blog) (model.Blog, e } func (db *Database) GetBlog(ctx context.Context, id int64) (*model.Blog, error) { - row := sq.Select("id", "name", "url", "feed_url", "scrape_selector", "last_scanned"). + row := sq.Select("id", "name", "url", "feed_url", "scrape_selector", "group_name", "last_scanned"). From("blogs"). Where(sq.Eq{"id": id}). RunWith(db.conn). @@ -138,7 +138,7 @@ func (db *Database) GetBlog(ctx context.Context, id int64) (*model.Blog, error) } func (db *Database) GetBlogByName(ctx context.Context, name string) (*model.Blog, error) { - row := sq.Select("id", "name", "url", "feed_url", "scrape_selector", "last_scanned"). + row := sq.Select("id", "name", "url", "feed_url", "scrape_selector", "group_name", "last_scanned"). From("blogs"). Where(sq.Eq{"name": name}). RunWith(db.conn). @@ -147,7 +147,7 @@ func (db *Database) GetBlogByName(ctx context.Context, name string) (*model.Blog } func (db *Database) GetBlogByURL(ctx context.Context, url string) (*model.Blog, error) { - row := sq.Select("id", "name", "url", "feed_url", "scrape_selector", "last_scanned"). + row := sq.Select("id", "name", "url", "feed_url", "scrape_selector", "group_name", "last_scanned"). From("blogs"). Where(sq.Eq{"url": url}). RunWith(db.conn). @@ -155,12 +155,16 @@ func (db *Database) GetBlogByURL(ctx context.Context, url string) (*model.Blog, return scanBlog(row) } -func (db *Database) ListBlogs(ctx context.Context) ([]model.Blog, error) { - rows, err := sq.Select("id", "name", "url", "feed_url", "scrape_selector", "last_scanned"). +func (db *Database) ListBlogs(ctx context.Context, groupName *string) ([]model.Blog, error) { + query := sq.Select("id", "name", "url", "feed_url", "scrape_selector", "group_name", "last_scanned"). From("blogs"). - OrderBy("name"). - RunWith(db.conn). - QueryContext(ctx) + OrderBy("name") + + if groupName != nil && *groupName != "" { + query = query.Where("LOWER(group_name) = LOWER(?)", *groupName) + } + + rows, err := query.RunWith(db.conn).QueryContext(ctx) if err != nil { return nil, err } @@ -189,6 +193,7 @@ func (db *Database) UpdateBlog(ctx context.Context, blog model.Blog) error { Set("url", blog.URL). Set("feed_url", nullIfEmpty(blog.FeedURL)). Set("scrape_selector", nullIfEmpty(blog.ScrapeSelector)). + Set("group_name", nullIfEmpty(blog.Group)). Set("last_scanned", formatTimePtr(blog.LastScanned)). Where(sq.Eq{"id": blog.ID}). RunWith(db.conn). @@ -371,7 +376,7 @@ func (db *Database) GetExistingArticleURLs(ctx context.Context, urls []string) ( return result, nil } -func (db *Database) ListArticles(ctx context.Context, unreadOnly bool, blogID *int64, category *string, since *time.Time, before *time.Time) ([]model.Article, error) { +func (db *Database) ListArticles(ctx context.Context, unreadOnly bool, blogID *int64, category *string, groupName *string, since *time.Time, before *time.Time) ([]model.Article, error) { query := sq.Select("id", "blog_id", "title", "url", "published_date", "discovered_date", "is_read", "categories"). From("articles"). OrderBy("discovered_date DESC") @@ -387,6 +392,9 @@ func (db *Database) ListArticles(ctx context.Context, unreadOnly bool, blogID *i // for exact element matching. query = query.Where("EXISTS (SELECT 1 FROM json_each(categories) WHERE LOWER(json_each.value) = LOWER(?))", *category) } + if groupName != nil && *groupName != "" { + query = query.Where("blog_id IN (SELECT id FROM blogs WHERE LOWER(group_name) = LOWER(?))", *groupName) + } if since != nil { query = query.Where(sq.GtOrEq{"published_date": since.UTC().Format(sqliteWriteLayout)}) } @@ -458,9 +466,10 @@ func scanBlog(scanner interface{ Scan(dest ...any) error }) (*model.Blog, error) url string feedURL sql.NullString scrapeSelector sql.NullString + groupName sql.NullString lastScanned sql.NullString ) - if err := scanner.Scan(&id, &name, &url, &feedURL, &scrapeSelector, &lastScanned); err != nil { + if err := scanner.Scan(&id, &name, &url, &feedURL, &scrapeSelector, &groupName, &lastScanned); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -473,6 +482,7 @@ func scanBlog(scanner interface{ Scan(dest ...any) error }) (*model.Blog, error) URL: url, FeedURL: feedURL.String, ScrapeSelector: scrapeSelector.String, + Group: groupName.String, } if lastScanned.Valid { if parsed, err := parseTime(lastScanned.String); err == nil { From c97f3c3b22a1bd50dc032512ccaa095a54dabcc3 Mon Sep 17 00:00:00 2001 From: phedayat Date: Sun, 26 Jul 2026 09:37:30 -0500 Subject: [PATCH 07/11] Updated commands tests to include blog groups --- internal/cli/commands_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index f625d4d..ee25b25 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -34,6 +34,18 @@ func TestParseDateFilter(t *testing.T) { }) } +func TestStringPtrOrNil(t *testing.T) { + t.Run("empty string returns nil", func(t *testing.T) { + assert.Nil(t, stringPtrOrNil("")) + }) + + t.Run("non-empty string returns pointer to value", func(t *testing.T) { + got := stringPtrOrNil("Team A") + require.NotNil(t, got) + assert.Equal(t, "Team A", *got) + }) +} + func TestParseDateRange(t *testing.T) { t.Run("both empty returns nils", func(t *testing.T) { since, before, err := parseDateRange("", "") From 6f200cd7c02a8397536fb11fd5548e3810b24266 Mon Sep 17 00:00:00 2001 From: phedayat Date: Sun, 26 Jul 2026 09:37:36 -0500 Subject: [PATCH 08/11] Updated controller tests to include blog groups --- internal/controller/controller_test.go | 69 ++++++++++++++++++++------ 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go index a90f06d..0d995f6 100644 --- a/internal/controller/controller_test.go +++ b/internal/controller/controller_test.go @@ -17,19 +17,34 @@ func TestAddBlogAndRemoveBlog(t *testing.T) { db := openTestDB(t) defer func() { require.NoError(t, db.Close()) }() - blog, err := AddBlog(ctx, db, "Test", "https://example.com", "", "") + blog, err := AddBlog(ctx, db, "Test", "https://example.com", "", "", "") require.NoError(t, err, "add blog") - _, err = AddBlog(ctx, db, "Test", "https://other.com", "", "") + _, err = AddBlog(ctx, db, "Test", "https://other.com", "", "", "") require.Error(t, err, "expected duplicate name error") - _, err = AddBlog(ctx, db, "Other", "https://example.com", "", "") + _, err = AddBlog(ctx, db, "Other", "https://example.com", "", "", "") require.Error(t, err, "expected duplicate url error") err = RemoveBlog(ctx, db, blog.Name) require.NoError(t, err, "remove blog") } +func TestAddBlogWithGroup(t *testing.T) { + ctx := context.Background() + db := openTestDB(t) + defer func() { require.NoError(t, db.Close()) }() + + blog, err := AddBlog(ctx, db, "Test", "https://example.com", "", "", "Feed Group 1") + require.NoError(t, err, "add blog") + assert.Equal(t, "Feed Group 1", blog.Group) + + fetched, err := db.GetBlogByName(ctx, "Test") + require.NoError(t, err, "get blog by name") + require.NotNil(t, fetched) + assert.Equal(t, "Feed Group 1", fetched.Group) +} + func TestAddBlogInvalidURL(t *testing.T) { ctx := context.Background() db := openTestDB(t) @@ -53,7 +68,7 @@ func TestAddBlogInvalidURL(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - _, err := AddBlog(ctx, db, "Test"+tc.name, tc.url, tc.feedURL, "") + _, err := AddBlog(ctx, db, "Test"+tc.name, tc.url, tc.feedURL, "", "") require.Error(t, err, "expected error for invalid URL") var invalidURLErr InvalidURLError @@ -81,7 +96,7 @@ func TestAddBlogValidURL(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { blogName := "Valid" + tc.name - blog, err := AddBlog(ctx, db, blogName, tc.url, tc.feedURL, "") + blog, err := AddBlog(ctx, db, blogName, tc.url, tc.feedURL, "", "") require.NoError(t, err, "expected no error for valid URL") require.Equal(t, tc.url, blog.URL) require.Equal(t, tc.feedURL, blog.FeedURL) @@ -98,7 +113,7 @@ func TestArticleReadUnread(t *testing.T) { db := openTestDB(t) defer func() { require.NoError(t, db.Close()) }() - blog, err := AddBlog(ctx, db, "Test", "https://example.com", "", "") + blog, err := AddBlog(ctx, db, "Test", "https://example.com", "", "", "") require.NoError(t, err, "add blog") article, err := db.AddArticle(ctx, model.Article{BlogID: blog.ID, Title: "Title", URL: "https://example.com/1"}) require.NoError(t, err, "add article") @@ -117,17 +132,17 @@ func TestGetArticlesFilters(t *testing.T) { db := openTestDB(t) defer func() { require.NoError(t, db.Close()) }() - blog, err := AddBlog(ctx, db, "Test", "https://example.com", "", "") + blog, err := AddBlog(ctx, db, "Test", "https://example.com", "", "", "") require.NoError(t, err, "add blog") _, err = db.AddArticle(ctx, model.Article{BlogID: blog.ID, Title: "Title", URL: "https://example.com/1"}) require.NoError(t, err, "add article") - articles, blogNames, err := GetArticles(ctx, db, false, "", "", nil, nil) + articles, blogNames, err := GetArticles(ctx, db, false, "", "", "", nil, nil) require.NoError(t, err, "get articles") require.Len(t, articles, 1) require.Equal(t, blog.Name, blogNames[blog.ID]) - _, _, err = GetArticles(ctx, db, false, "Missing", "", nil, nil) + _, _, err = GetArticles(ctx, db, false, "Missing", "", "", nil, nil) require.Error(t, err, "expected blog not found error") } @@ -153,7 +168,7 @@ func TestImportOPML(t *testing.T) { assert.Equal(t, 0, skipped) // Verify blogs were actually persisted. - blogs, err := db.ListBlogs(ctx) + blogs, err := db.ListBlogs(ctx, nil) require.NoError(t, err) assert.Len(t, blogs, 2) } @@ -164,7 +179,7 @@ func TestImportOPMLSkipsDuplicates(t *testing.T) { defer func() { require.NoError(t, db.Close()) }() // Pre-add a blog that will conflict. - _, err := AddBlog(ctx, db, "Blog A", "http://a.com", "http://a.com/feed", "") + _, err := AddBlog(ctx, db, "Blog A", "http://a.com", "http://a.com/feed", "", "") require.NoError(t, err) opmlData := ` @@ -281,7 +296,7 @@ func TestGetArticlesFilterByCategory(t *testing.T) { db := openTestDB(t) defer func() { require.NoError(t, db.Close()) }() - blog, err := AddBlog(ctx, db, "Test", "https://example.com", "", "") + blog, err := AddBlog(ctx, db, "Test", "https://example.com", "", "", "") require.NoError(t, err, "add blog") _, err = db.AddArticle(ctx, model.Article{BlogID: blog.ID, Title: "Go Post", URL: "https://example.com/1", Categories: []string{"Go", "Programming"}}) @@ -290,17 +305,43 @@ func TestGetArticlesFilterByCategory(t *testing.T) { require.NoError(t, err, "add article") // Filter by Go - articles, _, err := GetArticles(ctx, db, false, "", "Go", nil, nil) + articles, _, err := GetArticles(ctx, db, false, "", "Go", "", nil, nil) require.NoError(t, err, "get articles by category") require.Len(t, articles, 1) require.Equal(t, "Go Post", articles[0].Title) // No filter returns all - all, _, err := GetArticles(ctx, db, false, "", "", nil, nil) + all, _, err := GetArticles(ctx, db, false, "", "", "", nil, nil) require.NoError(t, err, "get all articles") require.Len(t, all, 2) } +func TestGetArticlesFilterByGroup(t *testing.T) { + ctx := context.Background() + db := openTestDB(t) + defer func() { require.NoError(t, db.Close()) }() + + blogA, err := AddBlog(ctx, db, "A", "https://a.example.com", "", "", "Feed Group 1") + require.NoError(t, err, "add blog A") + blogB, err := AddBlog(ctx, db, "B", "https://b.example.com", "", "", "Feed Group 2") + require.NoError(t, err, "add blog B") + + _, err = db.AddArticle(ctx, model.Article{BlogID: blogA.ID, Title: "A1", URL: "https://a.example.com/1"}) + require.NoError(t, err, "add article for blog A") + _, err = db.AddArticle(ctx, model.Article{BlogID: blogB.ID, Title: "B1", URL: "https://b.example.com/1"}) + require.NoError(t, err, "add article for blog B") + + articles, _, err := GetArticles(ctx, db, false, "", "", "Feed Group 1", nil, nil) + require.NoError(t, err, "get articles by group") + require.Len(t, articles, 1) + require.Equal(t, "A1", articles[0].Title) + + // Unknown group returns no results, not an error + articles, _, err = GetArticles(ctx, db, false, "", "", "Nonexistent", nil, nil) + require.NoError(t, err, "get articles by nonexistent group") + require.Empty(t, articles) +} + func openTestDB(t *testing.T) *storage.Database { t.Helper() path := filepath.Join(t.TempDir(), "blogwatcher-cli.db") From bda984e10654cda660700f74e0497aef47063d6c Mon Sep 17 00:00:00 2001 From: phedayat Date: Sun, 26 Jul 2026 09:37:43 -0500 Subject: [PATCH 09/11] Updated scanner tests to include blog groups --- internal/scanner/scanner_test.go | 74 +++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index 92ce27f..62af96b 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -58,7 +58,7 @@ func TestScanBlogRSS(t *testing.T) { require.Equal(t, 2, result.NewArticles) require.Equal(t, "rss", result.Source) - articles, err := db.ListArticles(ctx, false, nil, nil, nil, nil) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, nil, nil) require.NoError(t, err, "list articles") require.Len(t, articles, 2) } @@ -130,11 +130,71 @@ func TestScanAllBlogsConcurrent(t *testing.T) { require.NoError(t, err, "add blog %s", name) } - results, err := newTestScanner().ScanAllBlogs(ctx, db, 2) + results, err := newTestScanner().ScanAllBlogs(ctx, db, 2, "") require.NoError(t, err, "scan all blogs") require.Len(t, results, 2) } +func TestScanAllBlogsFilterByGroup(t *testing.T) { + ctx := context.Background() + + feedTemplate := ` +%s +Post 1https://%s.example.com/1 +` + + mux := http.NewServeMux() + for _, name := range []string{"a", "c", "b"} { + feed := fmt.Sprintf(feedTemplate, name, name) + mux.HandleFunc("/"+name+"/feed", func(w http.ResponseWriter, r *http.Request) { + if _, writeErr := w.Write([]byte(feed)); writeErr != nil { + http.Error(w, writeErr.Error(), http.StatusInternalServerError) + } + }) + } + server := httptest.NewServer(mux) + defer server.Close() + + db := openTestDB(t) + defer func() { require.NoError(t, db.Close()) }() + + _, err := db.AddBlog(ctx, model.Blog{ + Name: "Test-a", + URL: "https://a.example.com", + FeedURL: server.URL + "/a/feed", + Group: "Feed Group 1", + }) + require.NoError(t, err, "add blog a") + _, err = db.AddBlog(ctx, model.Blog{ + Name: "Test-c", + URL: "https://c.example.com", + FeedURL: server.URL + "/c/feed", + Group: "Feed Group 1", + }) + require.NoError(t, err, "add blog c") + _, err = db.AddBlog(ctx, model.Blog{ + Name: "Test-b", + URL: "https://b.example.com", + FeedURL: server.URL + "/b/feed", + Group: "Feed Group 2", + }) + require.NoError(t, err, "add blog b") + + results, err := newTestScanner().ScanAllBlogs(ctx, db, 2, "Feed Group 1") + require.NoError(t, err, "scan by group") + require.Len(t, results, 2, "both blogs in the group must be scanned, not just the first match") + var scanned []string + for _, r := range results { + scanned = append(scanned, r.BlogName) + } + require.ElementsMatch(t, []string{"Test-a", "Test-c"}, scanned) + + // Exact match only -- a prefix of a real group name must not match. + results, err = newTestScanner().ScanAllBlogs(ctx, db, 2, "Feed Group") + require.NoError(t, err, "scan by prefix group") + require.Empty(t, results, "group filter must be an exact match, not a prefix match") +} + func openTestDB(t *testing.T) *storage.Database { t.Helper() path := filepath.Join(t.TempDir(), "blogwatcher-cli.db") @@ -204,7 +264,7 @@ func TestScanBlogRSSWithCategories(t *testing.T) { require.NoError(t, scanErr) require.Equal(t, 2, result.NewArticles) - articles, err := db.ListArticles(ctx, false, nil, nil, nil, nil) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, nil, nil) require.NoError(t, err, "list articles") require.Len(t, articles, 2) @@ -298,7 +358,7 @@ func TestScanAllBlogsPartialFailure(t *testing.T) { _, err = db.AddBlog(ctx, model.Blog{Name: "bad-blog", URL: "https://bad.example.com", FeedURL: server.URL + "/bad/feed"}) require.NoError(t, err) - results, scanErr := newTestScanner().ScanAllBlogs(ctx, db, 2) + results, scanErr := newTestScanner().ScanAllBlogs(ctx, db, 2, "") require.NoError(t, scanErr, "ScanAllBlogs should not return an error for blog-level failures") require.Len(t, results, 2) @@ -343,7 +403,7 @@ func TestScanAllBlogsPartialFailureSequential(t *testing.T) { _, err = db.AddBlog(ctx, model.Blog{Name: "bad-blog", URL: "https://bad.example.com", FeedURL: server.URL + "/bad/feed"}) require.NoError(t, err) - results, scanErr := newTestScanner().ScanAllBlogs(ctx, db, 1) + results, scanErr := newTestScanner().ScanAllBlogs(ctx, db, 1, "") require.NoError(t, scanErr, "ScanAllBlogs should not return an error for blog-level failures") require.Len(t, results, 2) @@ -384,7 +444,7 @@ func TestScanAllBlogsPropagatesContextCancellation(t *testing.T) { _, err := db.AddBlog(ctx, model.Blog{Name: "cancel-blog", URL: "https://cancel.example.com", FeedURL: server.URL}) require.NoError(t, err) - _, scanErr := newTestScanner().ScanAllBlogs(ctx, db, 1) + _, scanErr := newTestScanner().ScanAllBlogs(ctx, db, 1, "") require.Error(t, scanErr, "should propagate context cancellation as a fatal error") require.ErrorIs(t, scanErr, context.Canceled) } @@ -407,7 +467,7 @@ func TestScanAllBlogsPropagatesContextCancellationConcurrent(t *testing.T) { _, err := db.AddBlog(ctx, model.Blog{Name: "cancel-blog", URL: "https://cancel.example.com", FeedURL: server.URL}) require.NoError(t, err) - _, scanErr := newTestScanner().ScanAllBlogs(ctx, db, 2) + _, scanErr := newTestScanner().ScanAllBlogs(ctx, db, 2, "") require.Error(t, scanErr, "should propagate context cancellation as a fatal error") require.ErrorIs(t, scanErr, context.Canceled) } From 497ef41c3e8fefb981836386202689d3feead3df Mon Sep 17 00:00:00 2001 From: phedayat Date: Sun, 26 Jul 2026 09:37:50 -0500 Subject: [PATCH 10/11] Updated db tests to include blog groups --- internal/storage/database_test.go | 144 +++++++++++++++++++++++++----- 1 file changed, 124 insertions(+), 20 deletions(-) diff --git a/internal/storage/database_test.go b/internal/storage/database_test.go index d9877ad..8fbe45e 100644 --- a/internal/storage/database_test.go +++ b/internal/storage/database_test.go @@ -42,7 +42,7 @@ func TestDatabaseCreatesFileAndCRUD(t *testing.T) { require.NoError(t, err, "add articles bulk") require.Equal(t, 2, count) - list, err := db.ListArticles(ctx, false, nil, nil, nil, nil) + list, err := db.ListArticles(ctx, false, nil, nil, nil, nil, nil) require.NoError(t, err, "list articles") require.Len(t, list, 2) @@ -114,6 +114,67 @@ func TestBlogOptionalFieldsRoundTrip(t *testing.T) { require.NotNil(t, fetched) require.Empty(t, fetched.FeedURL) require.Empty(t, fetched.ScrapeSelector) + require.Empty(t, fetched.Group) +} + +func TestBlogGroupRoundTrip(t *testing.T) { + ctx := context.Background() + db := openTestDB(t) + defer func() { require.NoError(t, db.Close()) }() + + blog, err := db.AddBlog(ctx, model.Blog{Name: "Test", URL: "https://example.com", Group: "Feed Group 1"}) + require.NoError(t, err, "add blog") + + fetched, err := db.GetBlog(ctx, blog.ID) + require.NoError(t, err, "get blog") + require.NotNil(t, fetched) + require.Equal(t, "Feed Group 1", fetched.Group) + + fetched.Group = "Feed Group 2" + require.NoError(t, db.UpdateBlog(ctx, *fetched), "update blog") + + updated, err := db.GetBlog(ctx, blog.ID) + require.NoError(t, err, "get updated blog") + require.NotNil(t, updated) + require.Equal(t, "Feed Group 2", updated.Group) +} + +func TestListBlogsFilterByGroup(t *testing.T) { + ctx := context.Background() + db := openTestDB(t) + defer func() { require.NoError(t, db.Close()) }() + + _, err := db.AddBlog(ctx, model.Blog{Name: "A", URL: "https://a.example.com", Group: "Feed Group 1"}) + require.NoError(t, err, "add blog A") + _, err = db.AddBlog(ctx, model.Blog{Name: "D", URL: "https://d.example.com", Group: "Feed Group 1"}) + require.NoError(t, err, "add blog D") + _, err = db.AddBlog(ctx, model.Blog{Name: "B", URL: "https://b.example.com", Group: "Feed Group 2"}) + require.NoError(t, err, "add blog B") + _, err = db.AddBlog(ctx, model.Blog{Name: "C", URL: "https://c.example.com"}) + require.NoError(t, err, "add blog C") + + group := "Feed Group 1" + filtered, err := db.ListBlogs(ctx, &group) + require.NoError(t, err, "list by group") + require.Len(t, filtered, 2, "both blogs in the group must be returned, not just the first match") + require.ElementsMatch(t, []string{"A", "D"}, []string{filtered[0].Name, filtered[1].Name}) + + // Case-insensitive match + group = "feed group 2" + filtered, err = db.ListBlogs(ctx, &group) + require.NoError(t, err, "list by lowercase group") + require.Len(t, filtered, 1) + require.Equal(t, "B", filtered[0].Name) + + // Exact match only -- a prefix of a real group name must not match. + group = "Feed Group" + filtered, err = db.ListBlogs(ctx, &group) + require.NoError(t, err, "list by prefix group") + require.Empty(t, filtered, "group filter must be an exact match, not a prefix match") + + all, err := db.ListBlogs(ctx, nil) + require.NoError(t, err, "list all") + require.Len(t, all, 4) } func TestBlogTimeRoundTrip(t *testing.T) { @@ -197,17 +258,17 @@ func TestListArticlesFiltersAndOrdering(t *testing.T) { _, err = db.MarkArticleRead(ctx, first.ID) require.NoError(t, err, "mark read") - all, err := db.ListArticles(ctx, false, nil, nil, nil, nil) + all, err := db.ListArticles(ctx, false, nil, nil, nil, nil, nil) require.NoError(t, err, "list articles") require.Len(t, all, 3) require.Equal(t, second.ID, all[0].ID, "expected newest article first") - unread, err := db.ListArticles(ctx, true, nil, nil, nil, nil) + unread, err := db.ListArticles(ctx, true, nil, nil, nil, nil, nil) require.NoError(t, err, "list unread") require.Len(t, unread, 2) blogID := blogB.ID - filtered, err := db.ListArticles(ctx, false, &blogID, nil, nil, nil) + filtered, err := db.ListArticles(ctx, false, &blogID, nil, nil, nil, nil) require.NoError(t, err, "list by blog") require.Len(t, filtered, 1) require.Equal(t, blogB.ID, filtered[0].BlogID) @@ -238,7 +299,7 @@ func TestBulkInsertDuplicateRollbackAndEmpty(t *testing.T) { _, err = db.AddArticlesBulk(ctx, dupArticles) require.Error(t, err, "expected bulk insert to fail on duplicate url") - articles, err := db.ListArticles(ctx, false, nil, nil, nil, nil) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, nil, nil) require.NoError(t, err, "list articles") require.Len(t, articles, 1, "expected rollback on duplicate") } @@ -353,42 +414,85 @@ func TestListArticlesFilterByCategory(t *testing.T) { // Filter by "Go" - should return only the Go article cat := "Go" - goArticles, err := db.ListArticles(ctx, false, nil, &cat, nil, nil) + goArticles, err := db.ListArticles(ctx, false, nil, &cat, nil, nil, nil) require.NoError(t, err, "list by category Go") require.Len(t, goArticles, 1) require.Equal(t, "Go Article", goArticles[0].Title) // Filter by "Programming" - should return both categorized articles cat = "Programming" - progArticles, err := db.ListArticles(ctx, false, nil, &cat, nil, nil) + progArticles, err := db.ListArticles(ctx, false, nil, &cat, nil, nil, nil) require.NoError(t, err, "list by category Programming") require.Len(t, progArticles, 2) // No filter - should return all 3 - all, err := db.ListArticles(ctx, false, nil, nil, nil, nil) + all, err := db.ListArticles(ctx, false, nil, nil, nil, nil, nil) require.NoError(t, err, "list all") require.Len(t, all, 3) // Case-insensitive match - "go" should match "Go" cat = "go" - goLower, err := db.ListArticles(ctx, false, nil, &cat, nil, nil) + goLower, err := db.ListArticles(ctx, false, nil, &cat, nil, nil, nil) require.NoError(t, err, "list by category go (lowercase)") require.Len(t, goLower, 1) require.Equal(t, "Go Article", goLower[0].Title) // Case-insensitive match - "PROGRAMMING" should match "Programming" cat = "PROGRAMMING" - progUpper, err := db.ListArticles(ctx, false, nil, &cat, nil, nil) + progUpper, err := db.ListArticles(ctx, false, nil, &cat, nil, nil, nil) require.NoError(t, err, "list by category PROGRAMMING (uppercase)") require.Len(t, progUpper, 2) // Empty string category should return all empty := "" - allEmpty, err := db.ListArticles(ctx, false, nil, &empty, nil, nil) + allEmpty, err := db.ListArticles(ctx, false, nil, &empty, nil, nil, nil) require.NoError(t, err, "list with empty category") require.Len(t, allEmpty, 3) } +func TestListArticlesFilterByGroup(t *testing.T) { + ctx := context.Background() + db := openTestDB(t) + defer func() { require.NoError(t, db.Close()) }() + + blogA, err := db.AddBlog(ctx, model.Blog{Name: "A", URL: "https://a.example.com", Group: "Feed Group 1"}) + require.NoError(t, err, "add blog A") + blogD, err := db.AddBlog(ctx, model.Blog{Name: "D", URL: "https://d.example.com", Group: "Feed Group 1"}) + require.NoError(t, err, "add blog D") + blogB, err := db.AddBlog(ctx, model.Blog{Name: "B", URL: "https://b.example.com", Group: "Feed Group 2"}) + require.NoError(t, err, "add blog B") + + _, err = db.AddArticle(ctx, model.Article{BlogID: blogA.ID, Title: "A1", URL: "https://a.example.com/1"}) + require.NoError(t, err, "add article for blog A") + _, err = db.AddArticle(ctx, model.Article{BlogID: blogD.ID, Title: "D1", URL: "https://d.example.com/1"}) + require.NoError(t, err, "add article for blog D") + _, err = db.AddArticle(ctx, model.Article{BlogID: blogB.ID, Title: "B1", URL: "https://b.example.com/1"}) + require.NoError(t, err, "add article for blog B") + + group := "Feed Group 1" + filtered, err := db.ListArticles(ctx, false, nil, nil, &group, nil, nil) + require.NoError(t, err, "list by group") + require.Len(t, filtered, 2, "articles from both blogs in the group must be returned, not just the first match") + require.ElementsMatch(t, []string{"A1", "D1"}, []string{filtered[0].Title, filtered[1].Title}) + + // Case-insensitive match + group = "feed group 2" + filtered, err = db.ListArticles(ctx, false, nil, nil, &group, nil, nil) + require.NoError(t, err, "list by lowercase group") + require.Len(t, filtered, 1) + require.Equal(t, "B1", filtered[0].Title) + + // Exact match only -- a prefix of a real group name must not match. + group = "Feed Group" + filtered, err = db.ListArticles(ctx, false, nil, nil, &group, nil, nil) + require.NoError(t, err, "list by prefix group") + require.Empty(t, filtered, "group filter must be an exact match, not a prefix match") + + all, err := db.ListArticles(ctx, false, nil, nil, nil, nil, nil) + require.NoError(t, err, "list all") + require.Len(t, all, 3) +} + func TestBulkInsertWithCategories(t *testing.T) { ctx := context.Background() db := openTestDB(t) @@ -405,7 +509,7 @@ func TestBulkInsertWithCategories(t *testing.T) { require.NoError(t, err, "bulk insert") require.Equal(t, 2, count) - list, err := db.ListArticles(ctx, false, nil, nil, nil, nil) + list, err := db.ListArticles(ctx, false, nil, nil, nil, nil, nil) require.NoError(t, err, "list articles") require.Len(t, list, 2) @@ -450,14 +554,14 @@ func TestListArticlesFilterByDate(t *testing.T) { require.NoError(t, err, "add article without date") t.Run("without filters returns all articles", func(t *testing.T) { - articles, err := db.ListArticles(ctx, false, nil, nil, nil, nil) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, nil, nil) require.NoError(t, err, "list articles") require.Len(t, articles, 4, "should return all articles including no-date article") }) t.Run("since filter inclusive", func(t *testing.T) { since := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) - articles, err := db.ListArticles(ctx, false, nil, nil, &since, nil) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, &since, nil) require.NoError(t, err, "list articles with since filter") require.Len(t, articles, 2, "should return articles on or after since date (Article2 and Article3)") titles := []string{articles[0].Title, articles[1].Title} @@ -467,7 +571,7 @@ func TestListArticlesFilterByDate(t *testing.T) { t.Run("before filter exclusive", func(t *testing.T) { before := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) - articles, err := db.ListArticles(ctx, false, nil, nil, nil, &before) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, nil, &before) require.NoError(t, err, "list articles with before filter") require.Len(t, articles, 1, "should return articles before date (only Article1)") require.Equal(t, "Article1", articles[0].Title, "should only include Article1 before before-date") @@ -476,7 +580,7 @@ func TestListArticlesFilterByDate(t *testing.T) { t.Run("combined filters", func(t *testing.T) { since := time.Date(2024, 1, 10, 0, 0, 0, 0, time.UTC) before := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC) - articles, err := db.ListArticles(ctx, false, nil, nil, &since, &before) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, &since, &before) require.NoError(t, err, "list articles with combined filters") require.Len(t, articles, 1, "should return only Article2 in range") require.Equal(t, "Article2", articles[0].Title, "should only include Article2") @@ -484,7 +588,7 @@ func TestListArticlesFilterByDate(t *testing.T) { t.Run("nil published date excluded from filters", func(t *testing.T) { since := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - articles, err := db.ListArticles(ctx, false, nil, nil, &since, nil) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, &since, nil) require.NoError(t, err, "list articles with since filter") require.Len(t, articles, 3, "should exclude no-date article") @@ -495,14 +599,14 @@ func TestListArticlesFilterByDate(t *testing.T) { t.Run("after all dates", func(t *testing.T) { since := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC) - articles, err := db.ListArticles(ctx, false, nil, nil, &since, nil) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, &since, nil) require.NoError(t, err, "list articles with since filter after all dates") require.Empty(t, articles, "should return empty result") }) t.Run("before all dates", func(t *testing.T) { before := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) - articles, err := db.ListArticles(ctx, false, nil, nil, nil, &before) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, nil, &before) require.NoError(t, err, "list articles with before filter before all dates") require.Empty(t, articles, "should return empty result") }) @@ -580,7 +684,7 @@ func TestDateFilterRespectsTimezoneEquivalence(t *testing.T) { require.NoError(t, err, "add article") since := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) - articles, err := db.ListArticles(ctx, false, nil, nil, &since, nil) + articles, err := db.ListArticles(ctx, false, nil, nil, nil, &since, nil) require.NoError(t, err, "list articles") require.Empty(t, articles, "JST article published before UTC midnight Jan 15 should be excluded") } From 58456850541c8e8d3f866b1e530903be764a4cce Mon Sep 17 00:00:00 2001 From: phedayat Date: Sun, 26 Jul 2026 09:38:13 -0500 Subject: [PATCH 11/11] Updated e2e tests to include blog groups --- e2e/e2e_test.go | 49 +++++++++++++++++++ e2e/expected/24_add_with_group.txt | 1 + e2e/expected/25_blogs_shows_group.txt | 21 ++++++++ e2e/expected/26_blogs_filter_group.txt | 7 +++ .../27_blogs_filter_group_no_match.txt | 1 + e2e/expected/28_scan_filter_group.txt | 6 +++ .../29_scan_filter_group_no_match.txt | 1 + e2e/expected/30_articles_filter_group.txt | 21 ++++++++ e2e/expected/31_blogs_filter_group_multi.txt | 10 ++++ .../32_blogs_filter_group_prefix_no_match.txt | 1 + 10 files changed, 118 insertions(+) create mode 100644 e2e/expected/24_add_with_group.txt create mode 100644 e2e/expected/25_blogs_shows_group.txt create mode 100644 e2e/expected/26_blogs_filter_group.txt create mode 100644 e2e/expected/27_blogs_filter_group_no_match.txt create mode 100644 e2e/expected/28_scan_filter_group.txt create mode 100644 e2e/expected/29_scan_filter_group_no_match.txt create mode 100644 e2e/expected/30_articles_filter_group.txt create mode 100644 e2e/expected/31_blogs_filter_group_multi.txt create mode 100644 e2e/expected/32_blogs_filter_group_prefix_no_match.txt diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 916d0cd..091f17d 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -403,6 +403,55 @@ func TestE2E(t *testing.T) { // ── Remove nonexistent ── _, stderr = c.fail(t, []string{"remove", "nope"}, map[string]string{"yes": ""}) checkOutput(t, "23_remove_nonexistent", stderr, baseURL) + + // ── Add grouped blogs ── + out = c.ok(t, []string{"add", "go-blog-2", baseURL + "/rust/"}, map[string]string{ + "scrape-selector": ".post-list td a[href]", + "group": "Team A", + }) + checkOutput(t, "24_add_with_group", out, baseURL) + + c.ok(t, []string{"add", "go-blog-3", baseURL + "/nowhere/"}, map[string]string{ + "group": "Team B", + }) + + // ── Blogs list shows Group line ── + out = c.ok(t, []string{"blogs"}, nil) + checkOutput(t, "25_blogs_shows_group", out, baseURL) + + // ── Blogs filtered by group, case-insensitive ── + out = c.ok(t, []string{"blogs"}, map[string]string{"group": "team a"}) + checkOutput(t, "26_blogs_filter_group", out, baseURL) + + // ── Blogs filtered by nonexistent group ── + out = c.ok(t, []string{"blogs"}, map[string]string{"group": "nonexistent"}) + checkOutput(t, "27_blogs_filter_group_no_match", out, baseURL) + + // ── Scan filtered by group ── + out = c.ok(t, []string{"scan"}, map[string]string{"group": "Team A"}) + checkOutput(t, "28_scan_filter_group", out, baseURL) + + // ── Scan filtered by nonexistent group ── + out = c.ok(t, []string{"scan"}, map[string]string{"group": "nonexistent"}) + checkOutput(t, "29_scan_filter_group_no_match", out, baseURL) + + // ── Articles filtered by group ── + out = c.ok(t, []string{"articles"}, map[string]string{"group": "Team A"}) + checkOutput(t, "30_articles_filter_group", out, baseURL) + + // ── Group filter returns every blog in the group, not just the first match ── + c.ok(t, []string{"add", "go-blog-4", baseURL + "/nowhere2/"}, map[string]string{ + "group": "Team C", + }) + c.ok(t, []string{"add", "go-blog-5", baseURL + "/nowhere3/"}, map[string]string{ + "group": "Team C", + }) + out = c.ok(t, []string{"blogs"}, map[string]string{"group": "Team C"}) + checkOutput(t, "31_blogs_filter_group_multi", out, baseURL) + + // ── Group filter is an exact match, not a prefix match ── + out = c.ok(t, []string{"blogs"}, map[string]string{"group": "Team"}) + checkOutput(t, "32_blogs_filter_group_prefix_no_match", out, baseURL) }) } } diff --git a/e2e/expected/24_add_with_group.txt b/e2e/expected/24_add_with_group.txt new file mode 100644 index 0000000..77bc280 --- /dev/null +++ b/e2e/expected/24_add_with_group.txt @@ -0,0 +1 @@ +Added blog 'go-blog-2' diff --git a/e2e/expected/25_blogs_shows_group.txt b/e2e/expected/25_blogs_shows_group.txt new file mode 100644 index 0000000..e3b1c17 --- /dev/null +++ b/e2e/expected/25_blogs_shows_group.txt @@ -0,0 +1,21 @@ +Tracked blogs (4): + + github-blog + URL: {{SERVER}}/github/ + Feed: {{SERVER}}/github/feed/ + Last scanned: {{TIMESTAMP}} + + go-blog + URL: {{SERVER}}/go/ + Feed: {{SERVER}}/go/feed.atom + Last scanned: {{TIMESTAMP}} + + go-blog-2 + URL: {{SERVER}}/rust/ + Selector: .post-list td a[href] + Group: Team A + + go-blog-3 + URL: {{SERVER}}/nowhere/ + Group: Team B + diff --git a/e2e/expected/26_blogs_filter_group.txt b/e2e/expected/26_blogs_filter_group.txt new file mode 100644 index 0000000..d6057bd --- /dev/null +++ b/e2e/expected/26_blogs_filter_group.txt @@ -0,0 +1,7 @@ +Tracked blogs (1): + + go-blog-2 + URL: {{SERVER}}/rust/ + Selector: .post-list td a[href] + Group: Team A + diff --git a/e2e/expected/27_blogs_filter_group_no_match.txt b/e2e/expected/27_blogs_filter_group_no_match.txt new file mode 100644 index 0000000..c497852 --- /dev/null +++ b/e2e/expected/27_blogs_filter_group_no_match.txt @@ -0,0 +1 @@ +No blogs found in group 'nonexistent'. diff --git a/e2e/expected/28_scan_filter_group.txt b/e2e/expected/28_scan_filter_group.txt new file mode 100644 index 0000000..3f1e015 --- /dev/null +++ b/e2e/expected/28_scan_filter_group.txt @@ -0,0 +1,6 @@ +Scanning 1 blog(s)... + + go-blog-2 + Source: HTML | Found: 5 | New: 5 + +Found 5 new article(s) total! diff --git a/e2e/expected/29_scan_filter_group_no_match.txt b/e2e/expected/29_scan_filter_group_no_match.txt new file mode 100644 index 0000000..c497852 --- /dev/null +++ b/e2e/expected/29_scan_filter_group_no_match.txt @@ -0,0 +1 @@ +No blogs found in group 'nonexistent'. diff --git a/e2e/expected/30_articles_filter_group.txt b/e2e/expected/30_articles_filter_group.txt new file mode 100644 index 0000000..428f316 --- /dev/null +++ b/e2e/expected/30_articles_filter_group.txt @@ -0,0 +1,21 @@ +Unread articles (5): + + [ID] [new] Announcing Rust 1.94.0 + Blog: go-blog-2 + URL: https://blog.rust-lang.org/2026/03/05/Rust-1.94.0/ + + [ID] [new] Announcing Rust 1.94.1 + Blog: go-blog-2 + URL: https://blog.rust-lang.org/2026/03/26/1.94.1-release/ + + [ID] [new] Security advisory for Cargo + Blog: go-blog-2 + URL: https://blog.rust-lang.org/2026/03/21/cve-2026-33056/ + + [ID] [new] What we heard about Rust's challenges + Blog: go-blog-2 + URL: https://blog.rust-lang.org/2026/03/20/rust-challenges/ + + [ID] [new] docs.rs: building fewer targets by default + Blog: go-blog-2 + URL: https://blog.rust-lang.org/2026/04/04/docsrs-only-default-targets/ diff --git a/e2e/expected/31_blogs_filter_group_multi.txt b/e2e/expected/31_blogs_filter_group_multi.txt new file mode 100644 index 0000000..cf6c7ec --- /dev/null +++ b/e2e/expected/31_blogs_filter_group_multi.txt @@ -0,0 +1,10 @@ +Tracked blogs (2): + + go-blog-4 + URL: {{SERVER}}/nowhere2/ + Group: Team C + + go-blog-5 + URL: {{SERVER}}/nowhere3/ + Group: Team C + diff --git a/e2e/expected/32_blogs_filter_group_prefix_no_match.txt b/e2e/expected/32_blogs_filter_group_prefix_no_match.txt new file mode 100644 index 0000000..9500df2 --- /dev/null +++ b/e2e/expected/32_blogs_filter_group_prefix_no_match.txt @@ -0,0 +1 @@ +No blogs found in group 'Team'.