diff options
Diffstat (limited to 'cmds/feeds_test.go')
| -rw-r--r-- | cmds/feeds_test.go | 278 |
1 files changed, 278 insertions, 0 deletions
diff --git a/cmds/feeds_test.go b/cmds/feeds_test.go new file mode 100644 index 0000000..60151cc --- /dev/null +++ b/cmds/feeds_test.go @@ -0,0 +1,278 @@ +// 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) + } + } +} |
