// Test for `fluxrec feeds` against an httptest fake Miniflux: rows are // sorted loudest-first, label stats join by feed ID, and a feed with no // label rows renders "-" rather than a fake 0% rate. package cmds import ( "bytes" "fmt" "net/http" "net/http/httptest" "path/filepath" "strconv" "strings" "testing" "time" "fluxrec/core" ) // feedsFake serves three feeds and per-feed entry counts keyed by the // feed_id query param, honoring published_after like pagedServer does. type feedsFake struct { feedsJSON string pool map[int64][]testEntry // feed ID -> entries queries []string } func (f *feedsFake) handler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { f.queries = append(f.queries, r.URL.Path+"?"+r.URL.RawQuery) w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/v1/feeds" { fmt.Fprint(w, f.feedsJSON) return } q := r.URL.Query() fid, _ := strconv.ParseInt(q.Get("feed_id"), 10, 64) pool := f.pool[fid] if pa, _ := strconv.ParseInt(q.Get("published_after"), 10, 64); pa > 0 { cutoff := time.Unix(pa, 0) filtered := pool[:0:0] for _, e := range pool { if !e.PublishedAt.Before(cutoff) { filtered = append(filtered, e) } } pool = filtered } limit, _ := strconv.Atoi(q.Get("limit")) if limit <= 0 { limit = len(pool) } if limit > len(pool) { limit = len(pool) } fmt.Fprint(w, entriesJSON(len(pool), pool[:limit])) } } func TestFeedsAudit(t *testing.T) { now := time.Now() fresh := func(entries []testEntry) []testEntry { for i := range entries { entries[i].PublishedAt = now.Add(-24 * time.Hour) } return entries } fake := &feedsFake{ feedsJSON: `[ {"id": 1, "title": "Quiet Blog", "site_url": "https://quiet.example", "disabled": false, "category": {"id": 10, "title": "Blogs"}}, {"id": 2, "title": "Firehose Daily", "site_url": "https://fire.example", "disabled": false, "category": {"id": 11, "title": "Flood"}}, {"id": 3, "title": "New Sub", "site_url": "https://new.example", "disabled": false, "category": {"id": 10, "title": "Blogs"}} ]`, pool: map[int64][]testEntry{ 1: fresh(makeEntries(2, 100, false, 1)), 2: fresh(makeEntries(90, 200, false, 2)), 3: fresh(makeEntries(4, 300, false, 3)), }, } srv := httptest.NewServer(fake.handler()) defer srv.Close() t.Setenv("MINIFLUX_URL", srv.URL) t.Setenv("MINIFLUX_TOKEN", "test-token") dir := t.TempDir() labelsPath := filepath.Join(dir, "labels.jsonl") if err := core.AppendLabels(labelsPath, []core.LabelRow{ {EntryID: 1, URL: "https://quiet.example/a", Title: "A", FeedID: 1, FeedTitle: "Quiet Blog", Label: core.LabelPos, CapturedAt: now}, {EntryID: 2, URL: "https://quiet.example/b", Title: "B", FeedID: 1, FeedTitle: "Quiet Blog", Label: core.LabelNeg, CapturedAt: now}, {EntryID: 3, URL: "https://fire.example/c", Title: "C", FeedID: 2, FeedTitle: "Firehose Daily", Label: core.LabelNeg, CapturedAt: now}, }); err != nil { t.Fatalf("AppendLabels: %v", err) } cmd := &FeedsCommand{Days: 30, Labels: labelsPath} var out bytes.Buffer if err := cmd.Run(nil, &out); err != nil { t.Fatalf("feeds: %v", err) } got := out.String() // Sorted by volume: Firehose (90) before New Sub (4) before Quiet (2). fi, ni, qi := strings.Index(got, "Firehose Daily"), strings.Index(got, "New Sub"), strings.Index(got, "Quiet Blog") if fi < 0 || ni < 0 || qi < 0 { t.Fatalf("output missing a feed row:\n%s", got) } if !(fi < ni && ni < qi) { t.Errorf("rows not sorted by volume desc:\n%s", got) } // Label stats joined by feed ID: Quiet = 1/2 (50%), Firehose = 0/1 (0%). if !strings.Contains(got, "50%") { t.Errorf("Quiet Blog rate should be 50%%:\n%s", got) } // New Sub has no label rows: "-" placeholders, not a bogus 0%. for _, line := range strings.Split(got, "\n") { if strings.Contains(line, "New Sub") && !strings.Contains(line, "-") { t.Errorf("New Sub should render '-' for missing label stats: %q", line) } } // Per-feed day's rate: Firehose = 90/30 = 3.0/day. if !strings.Contains(got, "3.0") { t.Errorf("Firehose per-day rate missing:\n%s", got) } // Categories render next to their feeds. for _, line := range strings.Split(got, "\n") { if strings.Contains(line, "Firehose Daily") && !strings.Contains(line, "Flood") { t.Errorf("Firehose row missing its category: %q", line) } if strings.Contains(line, "Quiet Blog") && !strings.Contains(line, "Blogs") { t.Errorf("Quiet Blog row missing its category: %q", line) } } // Unfiltered run shows all three feeds. if !strings.Contains(got, "showing 3 of 3 subscriptions") { t.Errorf("footer should count all feeds when unfiltered:\n%s", got) } // One /v1/feeds call plus one volume probe per feed, each carrying // feed_id and published_after. if len(fake.queries) != 4 { t.Fatalf("expected 4 API calls, got %d: %v", len(fake.queries), fake.queries) } for _, q := range fake.queries[1:] { if !strings.Contains(q, "feed_id=") || !strings.Contains(q, "published_after=") { t.Errorf("volume probe missing filters: %q", q) } } } // Filters: --min-per-day keeps only the firehose (volume scales with the // window, so its threshold is per-day); --min-seen keeps only the // well-sampled feed (label rows accumulate over export history, so its // threshold is absolute). func TestFeedsFilters(t *testing.T) { now := time.Now() fresh := func(entries []testEntry) []testEntry { for i := range entries { entries[i].PublishedAt = now.Add(-24 * time.Hour) } return entries } newServer := func(t *testing.T) *httptest.Server { t.Helper() fake := &feedsFake{ feedsJSON: `[ {"id": 1, "title": "Loud", "site_url": "https://loud.example", "category": {"id": 1, "title": "Flood"}}, {"id": 2, "title": "Quiet", "site_url": "https://quiet.example", "category": {"id": 2, "title": "Blogs"}} ]`, pool: map[int64][]testEntry{ 1: fresh(makeEntries(90, 100, false, 1)), 2: fresh(makeEntries(3, 500, false, 2)), }, } srv := httptest.NewServer(fake.handler()) t.Cleanup(srv.Close) t.Setenv("MINIFLUX_URL", srv.URL) t.Setenv("MINIFLUX_TOKEN", "test-token") return srv } labelsPath := filepath.Join(t.TempDir(), "labels.jsonl") rows := []core.LabelRow{} for i := 0; i < 2; i++ { rows = append(rows, core.LabelRow{EntryID: int64(10 + i), URL: "u", Title: "t", FeedID: 1, FeedTitle: "Loud", Label: core.LabelNeg, CapturedAt: now}) } for i := 0; i < 10; i++ { rows = append(rows, core.LabelRow{EntryID: int64(100 + i), URL: "u", Title: "t", FeedID: 2, FeedTitle: "Quiet", Label: core.LabelNeg, CapturedAt: now}) } if err := core.AppendLabels(labelsPath, rows); err != nil { t.Fatalf("AppendLabels: %v", err) } run := func(cmd *FeedsCommand) string { t.Helper() var out bytes.Buffer if err := cmd.Run(nil, &out); err != nil { t.Fatalf("feeds: %v", err) } return out.String() } newServer(t) got := run(&FeedsCommand{Days: 30, Labels: labelsPath, MinPerDay: 1.0}) if !strings.Contains(got, "Loud") || strings.Contains(got, "Quiet") { t.Errorf("--min-per-day 1.0 should keep only Loud (3.0/day vs 0.1/day):\n%s", got) } if !strings.Contains(got, "showing 1 of 2") { t.Errorf("filtered footer should count the shown subset:\n%s", got) } newServer(t) got = run(&FeedsCommand{Days: 30, Labels: labelsPath, MinSeen: 5}) if strings.Contains(got, "Loud") || !strings.Contains(got, "Quiet") { t.Errorf("--min-seen 5 should keep only Quiet (10 rows vs 2):\n%s", got) } } // Without a labels file the audit still prints the volume columns (fresh // checkout) rather than erroring. func TestFeedsAuditWithoutLabels(t *testing.T) { fake := &feedsFake{ feedsJSON: `[{"id": 1, "title": "Quiet Blog", "site_url": "https://quiet.example", "disabled": false}]`, pool: map[int64][]testEntry{1: {}}, } srv := httptest.NewServer(fake.handler()) defer srv.Close() t.Setenv("MINIFLUX_URL", srv.URL) t.Setenv("MINIFLUX_TOKEN", "test-token") cmd := &FeedsCommand{Days: 30, Labels: filepath.Join(t.TempDir(), "nonexistent.jsonl")} var out bytes.Buffer if err := cmd.Run(nil, &out); err != nil { t.Fatalf("feeds without labels file: %v", err) } if !strings.Contains(out.String(), "Quiet Blog") { t.Errorf("volume-only audit should still list the feed:\n%s", out.String()) } } // Guard: entries outside the window must not count toward volume. (Kept // explicit because the fake honors published_after — if the command ever // stops sending it, this fails loudly instead of silently inflating counts.) func TestFeedsAuditWindowFilter(t *testing.T) { now := time.Now() entries := makeEntries(3, 100, false, 1) entries[0].PublishedAt = now.Add(-24 * time.Hour) // in window entries[1].PublishedAt = now.Add(-10 * 24 * time.Hour) // in window entries[2].PublishedAt = now.Add(-200 * 24 * time.Hour) // out: 200d > 30d window fake := &feedsFake{ feedsJSON: `[{"id": 1, "title": "Sparse Blog", "site_url": "https://sparse.example", "disabled": false}]`, pool: map[int64][]testEntry{1: entries}, } srv := httptest.NewServer(fake.handler()) defer srv.Close() t.Setenv("MINIFLUX_URL", srv.URL) t.Setenv("MINIFLUX_TOKEN", "test-token") cmd := &FeedsCommand{Days: 30, Labels: ""} var out bytes.Buffer if err := cmd.Run(nil, &out); err != nil { t.Fatalf("feeds: %v", err) } for _, line := range strings.Split(out.String(), "\n") { if strings.Contains(line, "Sparse Blog") && !strings.Contains(line, " 2 ") { t.Errorf("Sparse Blog volume should be 2 (window applied): %q", line) } } }