cb3641e7bb
internal/rss parses RSS 2.0 and Atom, and polls each configured feed on its own interval; internal/webfetch is the one door either of them uses to touch the network. The poller writes items as notes with source "rss:<feed>" and nothing else: the answer path reads them back when he asks "что нового в лентах?", and nothing is announced on arrival. A feed that dispatched would be a nag, which is why the plan's breaking-news rule was left out rather than built. webfetch is where the limits live, as code rather than a paragraph: http(s) only, an allowlist (the configured feeds' hosts) and a denylist, a 2 MiB body cap, a 3-redirect cap, one request per host per second, and a refusal to connect to any private address — checked in the dialer's Control hook so it holds for every resolved address and every redirect hop, not just for a literal IP. Off unless configured: no "feeds" block, no poller, no outbound request. How far a feed was read is a config fact (rss:latest:<name>), so a restart does not re-note yesterday's headlines.
211 lines
7.1 KiB
Go
211 lines
7.1 KiB
Go
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 := `<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")
|
|
}
|
|
}
|