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.
113 lines
3.1 KiB
Go
113 lines
3.1 KiB
Go
package rss
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const rss2 = `<?xml version="1.0"?>
|
|
<rss version="2.0">
|
|
<channel>
|
|
<title>Хабр</title>
|
|
<item>
|
|
<title>Новая уязвимость в ядре</title>
|
|
<link>https://example.org/a</link>
|
|
<description><p>Патч уже <b>вышел</b>.</p></description>
|
|
<guid>tag:example.org,a</guid>
|
|
<pubDate>Mon, 28 Jul 2026 10:00:00 +0000</pubDate>
|
|
</item>
|
|
<item>
|
|
<title>Без даты</title>
|
|
<link>https://example.org/b</link>
|
|
</item>
|
|
</channel>
|
|
</rss>`
|
|
|
|
const atom = `<?xml version="1.0" encoding="utf-8"?>
|
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
<title>Example Atom</title>
|
|
<entry>
|
|
<title>Release 2.0</title>
|
|
<link rel="alternate" href="https://example.com/rel"/>
|
|
<link rel="edit" href="https://example.com/edit"/>
|
|
<id>urn:uuid:1</id>
|
|
<updated>2026-07-30T12:30:00Z</updated>
|
|
<summary>Ships & works</summary>
|
|
</entry>
|
|
</feed>`
|
|
|
|
func TestParseRSS2(t *testing.T) {
|
|
f, err := Parse(strings.NewReader(rss2))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.Title != "Хабр" {
|
|
t.Fatalf("title = %q", f.Title)
|
|
}
|
|
if len(f.Items) != 2 {
|
|
t.Fatalf("items = %d, want 2", len(f.Items))
|
|
}
|
|
it := f.Items[0]
|
|
if it.Title != "Новая уязвимость в ядре" {
|
|
t.Errorf("title = %q", it.Title)
|
|
}
|
|
if it.Summary != "Патч уже вышел ." && it.Summary != "Патч уже вышел." {
|
|
t.Errorf("summary = %q — tags must be stripped and entities decoded", it.Summary)
|
|
}
|
|
if it.ID != "tag:example.org,a" {
|
|
t.Errorf("id = %q", it.ID)
|
|
}
|
|
if want := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC); !it.Published.Equal(want) {
|
|
t.Errorf("published = %v, want %v", it.Published, want)
|
|
}
|
|
if !f.Items[1].Published.IsZero() {
|
|
t.Errorf("undated item got a date: %v", f.Items[1].Published)
|
|
}
|
|
if f.Items[1].ID != "https://example.org/b" {
|
|
t.Errorf("id falls back to the link, got %q", f.Items[1].ID)
|
|
}
|
|
}
|
|
|
|
func TestParseAtom(t *testing.T) {
|
|
f, err := Parse(strings.NewReader(atom))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.Title != "Example Atom" || len(f.Items) != 1 {
|
|
t.Fatalf("feed = %+v", f)
|
|
}
|
|
it := f.Items[0]
|
|
if it.Link != "https://example.com/rel" {
|
|
t.Errorf("link = %q, want the alternate link", it.Link)
|
|
}
|
|
if it.Summary != "Ships & works" {
|
|
t.Errorf("summary = %q", it.Summary)
|
|
}
|
|
if want := time.Date(2026, 7, 30, 12, 30, 0, 0, time.UTC); !it.Published.Equal(want) {
|
|
t.Errorf("published = %v, want %v", it.Published, want)
|
|
}
|
|
}
|
|
|
|
func TestParseGarbage(t *testing.T) {
|
|
if _, err := Parse(strings.NewReader("<html><body>not a feed")); err == nil {
|
|
t.Fatal("want an error on a non-feed document")
|
|
}
|
|
// A feed with an item that has neither title nor link contributes nothing
|
|
// rather than an empty note.
|
|
f, err := Parse(strings.NewReader(`<rss><channel><item><description>x</description></item></channel></rss>`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(f.Items) != 0 {
|
|
t.Fatalf("items = %d, want 0", len(f.Items))
|
|
}
|
|
}
|
|
|
|
func TestPlainTextDropsScript(t *testing.T) {
|
|
got := PlainText(`<p>hi</p><script>alert("x")</script><style>b{}</style> there`)
|
|
if got != "hi there" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
}
|