f7b76c572f
rss.Poller.seen held every undated item ever seen, one entry per id, for as long as mavend ran. fresh() added and nothing removed. A feed that ships items with no <pubDate> grew it forever. seenIDs is the same set with a bound: the map answers the lookup, a slice remembers insertion order, and the oldest id falls out past 512. The cap has to stay above any one feed's front page or an item still listed there would be written a second time, and a few hundred covers the largest page anyone publishes. The set only ever had to span one poll window plus the resync guard, not all of history. Dedupe behaviour is unchanged. The comment at fresh() explains why the set does not survive a restart; it never bounded it within one run.
236 lines
8.0 KiB
Go
236 lines
8.0 KiB
Go
package rss
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"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 := `<rss><channel><item><title>Старое</title><link>l</link>` +
|
|
`<pubDate>Mon, 01 Jun 2026 10:00:00 +0000</pubDate></item></channel></rss>`
|
|
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("<rss><channel>")
|
|
for i := 0; i < 10; i++ {
|
|
b.WriteString("<item><title>t")
|
|
b.WriteByte(byte('0' + i))
|
|
b.WriteString("</title><link>https://example.org/")
|
|
b.WriteByte(byte('0' + i))
|
|
b.WriteString("</link></item>")
|
|
}
|
|
b.WriteString("</channel></rss>")
|
|
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")
|
|
}
|
|
}
|
|
|
|
// An undated feed used to grow p.seen for as long as mavend ran. The set is
|
|
// bounded now, and the bound must not cost the dedupe an item still on the
|
|
// front page — only ids far older than any page fall out.
|
|
func TestSeenIDsBounded(t *testing.T) {
|
|
var s seenIDs
|
|
for i := 0; i < maxSeenPerFeed*3; i++ {
|
|
if !s.add(fmt.Sprintf("item-%d", i)) {
|
|
t.Fatalf("item-%d read as already seen", i)
|
|
}
|
|
if len(s.ids) > maxSeenPerFeed || len(s.order) > maxSeenPerFeed {
|
|
t.Fatalf("after %d inserts: ids=%d order=%d, cap is %d",
|
|
i+1, len(s.ids), len(s.order), maxSeenPerFeed)
|
|
}
|
|
}
|
|
// The newest insert is still deduped; the oldest was evicted.
|
|
last := fmt.Sprintf("item-%d", maxSeenPerFeed*3-1)
|
|
if s.add(last) {
|
|
t.Fatalf("%s read as new, so the most recent id was dropped", last)
|
|
}
|
|
if !s.add("item-0") {
|
|
t.Fatal("item-0 survived, so nothing was evicted")
|
|
}
|
|
}
|