package rss import ( "context" "errors" "strings" "testing" "time" ) type fakeFetch struct { body string err error calls int urls []string } func (f *fakeFetch) Get(_ context.Context, url string) (*Body, error) { f.calls++ f.urls = append(f.urls, url) if f.err != nil { return nil, f.err } return &Body{Bytes: []byte(f.body)}, nil } type writtenNote struct { ts time.Time text string source string vec []float32 } type fakeNotes struct{ notes []writtenNote } func (n *fakeNotes) WriteNote(_ context.Context, ts time.Time, text string, vec []float32, source string) (int64, error) { n.notes = append(n.notes, writtenNote{ts, text, source, vec}) return int64(len(n.notes)), nil } type fakeMarks struct{ m map[string]time.Time } func newMarks() *fakeMarks { return &fakeMarks{m: map[string]time.Time{}} } func (f *fakeMarks) LastMark(_ context.Context, feed string) (time.Time, error) { return f.m[feed], nil } func (f *fakeMarks) SetMark(_ context.Context, feed string, at time.Time) error { f.m[feed] = at return nil } var now = time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) func TestPollWritesNotesWithSource(t *testing.T) { fetch := &fakeFetch{body: rss2} notes := &fakeNotes{} marks := newMarks() p := NewPoller([]FeedConfig{{Name: "habr", URL: "https://example.org/rss", Category: "технологии"}}, fetch, notes, marks, nil, nil, Config{}) if p == nil { t.Fatal("NewPoller returned nil for a configured feed") } n := p.PollDue(context.Background(), now) if n != 2 || len(notes.notes) != 2 { t.Fatalf("wrote %d notes (returned %d), want 2", len(notes.notes), n) } if notes.notes[0].source != "rss:habr" { t.Errorf("source = %q, want rss:habr", notes.notes[0].source) } if !strings.Contains(notes.notes[0].text, "технологии") { t.Errorf("note does not carry its category: %q", notes.notes[0].text) } if !strings.Contains(notes.notes[0].text, "https://example.org/a") { t.Errorf("note does not carry its link: %q", notes.notes[0].text) } // The undated item is stamped with now, the dated one with its own date. if !notes.notes[1].ts.Equal(now) { t.Errorf("undated item ts = %v, want now", notes.notes[1].ts) } } // The whole point of a mark: polling twice must not re-note the same headlines. func TestSecondPollIsQuiet(t *testing.T) { fetch := &fakeFetch{body: rss2} notes := &fakeNotes{} p := NewPoller([]FeedConfig{{Name: "habr", URL: "u", Interval: time.Minute}}, fetch, notes, newMarks(), nil, nil, Config{}) p.PollDue(context.Background(), now) before := len(notes.notes) p.PollDue(context.Background(), now.Add(2*time.Minute)) if len(notes.notes) != before { t.Fatalf("second poll wrote %d extra notes", len(notes.notes)-before) } } // A mark that survives a restart is the durable half; simulate one by building a // fresh poller over the same marks. func TestMarkSurvivesRestart(t *testing.T) { marks := newMarks() fetch := &fakeFetch{body: rss2} notes := &fakeNotes{} feeds := []FeedConfig{{Name: "habr", URL: "u"}} NewPoller(feeds, fetch, notes, marks, nil, nil, Config{}).PollDue(context.Background(), now) if len(notes.notes) != 2 { t.Fatalf("first run wrote %d", len(notes.notes)) } notes2 := &fakeNotes{} NewPoller(feeds, fetch, notes2, marks, nil, nil, Config{}).PollDue(context.Background(), now.Add(time.Hour)) // The dated item is behind the mark. The undated one has no date to compare, // so it comes back — accepted and documented in fresh(): an undated feed is // deduped per process, not forever. for _, n := range notes2.notes { if strings.Contains(n.text, "уязвимость") { t.Fatalf("dated item re-noted after restart: %q", n.text) } } } func TestIntervalIsRespected(t *testing.T) { fetch := &fakeFetch{body: rss2} p := NewPoller([]FeedConfig{{Name: "habr", URL: "u", Interval: time.Hour}}, fetch, &fakeNotes{}, newMarks(), nil, nil, Config{}) p.PollDue(context.Background(), now) p.PollDue(context.Background(), now.Add(time.Minute)) if fetch.calls != 1 { t.Fatalf("fetched %d times inside one interval, want 1", fetch.calls) } p.PollDue(context.Background(), now.Add(2*time.Hour)) if fetch.calls != 2 { t.Fatalf("fetched %d times, want 2 after the interval elapsed", fetch.calls) } } func TestColdStartIgnoresOldItems(t *testing.T) { old := `Староеl` + `Mon, 01 Jun 2026 10:00:00 +0000` notes := &fakeNotes{} p := NewPoller([]FeedConfig{{Name: "f", URL: "u"}}, &fakeFetch{body: old}, notes, newMarks(), nil, nil, Config{MaxAge: 24 * time.Hour}) if n := p.PollDue(context.Background(), now); n != 0 { t.Fatalf("cold start imported %d old items, want 0", n) } } func TestMaxItemsCap(t *testing.T) { var b strings.Builder b.WriteString("") for i := 0; i < 10; i++ { b.WriteString("t") b.WriteByte(byte('0' + i)) b.WriteString("https://example.org/") b.WriteByte(byte('0' + i)) b.WriteString("") } b.WriteString("") notes := &fakeNotes{} p := NewPoller([]FeedConfig{{Name: "f", URL: "u"}}, &fakeFetch{body: b.String()}, notes, newMarks(), nil, nil, Config{MaxItems: 3}) if n := p.PollDue(context.Background(), now); n != 3 { t.Fatalf("wrote %d notes, want the cap of 3", n) } } func TestKeywordFilter(t *testing.T) { f := FeedConfig{Include: []string{"ядр"}, Exclude: []string{"реклама"}} if !Matches(f, Item{Title: "Новое ядро"}) { t.Error("include did not match") } if Matches(f, Item{Title: "Новое ядро", Summary: "Реклама внутри"}) { t.Error("exclude must win over include") } if Matches(f, Item{Title: "Погода"}) { t.Error("non-matching item passed the include filter") } if !Matches(FeedConfig{}, Item{Title: "что угодно"}) { t.Error("an unfiltered feed must keep everything") } } type fakeRanker struct{ keep bool } func (r fakeRanker) Relevant(context.Context, string) (bool, error) { return r.keep, nil } func TestRankerCanDropEverything(t *testing.T) { notes := &fakeNotes{} p := NewPoller([]FeedConfig{{Name: "f", URL: "u"}}, &fakeFetch{body: rss2}, notes, newMarks(), nil, fakeRanker{false}, Config{}) if n := p.PollDue(context.Background(), now); n != 0 { t.Fatalf("ranker rejected everything but %d notes were written", n) } } func TestFetchErrorIsSurvivable(t *testing.T) { notes := &fakeNotes{} p := NewPoller([]FeedConfig{ {Name: "dead", URL: "u1"}, {Name: "live", URL: "u2"}, }, &fakeFetch{err: errors.New("boom")}, notes, newMarks(), nil, nil, Config{}) if n := p.PollDue(context.Background(), now); n != 0 { t.Fatalf("n = %d", n) } // Both feeds were attempted: one dead feed does not abort the round. if p.nextDue["live"].IsZero() { t.Fatal("the second feed was never attempted") } } func TestNoFeedsMeansNoPoller(t *testing.T) { if p := NewPoller(nil, &fakeFetch{}, &fakeNotes{}, nil, nil, nil, Config{}); p != nil { t.Fatal("NewPoller must return nil when nothing is configured") } if p := NewPoller([]FeedConfig{{Name: "", URL: ""}}, &fakeFetch{}, &fakeNotes{}, nil, nil, nil, Config{}); p != nil { t.Fatal("a feed with no name or url is not a configuration") } }