aboutsummaryrefslogtreecommitdiff
path: root/cmds/feeds_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmds/feeds_test.go')
-rw-r--r--cmds/feeds_test.go278
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)
+ }
+ }
+}