Files
Pete/internal/feedwatch/feedwatch_test.go
T
prosolis c6c107b936 feedwatch: tell the owner when a feed breaks, comes back, or goes quiet
Every poll's outcome has always been written to source_health and shown on
/status, but only to someone who went and looked. Nobody did: Siliconera has
been refused on 3,769 polls in a row and Digital Foundry on 1,900, and
neither has ever fetched successfully. SendAdminWarning existed and nothing
called it.

A watcher now reads source_health every 15 minutes and sends one notice to
matrix.admin_room covering everything that changed since it last spoke:
feeds that have failed 5 polls in a row, feeds that have fetched since they
were reported down, and feeds that fetch fine but have kept nothing new for 7
days (every healthy source produces something inside about 4). What has been
said is stored on the row (down_alerted_at, quiet_alerted_at), so each
episode is reported once and a restart doesn't repeat it. A notice that
fails to send is not marked, and goes out on the next pass.

last_new_at is new, moved only when a poll keeps a story. Existing rows start
from their newest retained story, or from now if they have none, and a new
source starts at its first poll: nothing on disk says whether a source with
no stories is long dead or was added yesterday, so it gets a full window
before it can be called quiet.

With no admin room configured the watcher doesn't run at all, rather than
marking alerts as delivered to nowhere.
2026-09-22 23:14:12 -07:00

180 lines
4.9 KiB
Go

package feedwatch
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
func setup(t *testing.T) {
t.Helper()
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "feedwatch.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
}
func src(names ...string) []config.SourceConfig {
var out []config.SourceConfig
for _, n := range names {
out = append(out, config.SourceConfig{Name: n, Enabled: true})
}
return out
}
func fail(name string, n int) {
for range n {
storage.RecordPollResult(name, false, 0, errors.New("feed fetch https://x/feed: status 403"))
}
}
// setLastNew backdates a source's last new story.
func setLastNew(t *testing.T, name string, at time.Time) {
t.Helper()
if _, err := storage.Get().Exec(`UPDATE source_health SET last_new_at = ? WHERE source = ?`, at.Unix(), name); err != nil {
t.Fatal(err)
}
}
// capture is a Send that records what it was asked to deliver.
type capture struct {
msgs []string
err error
}
func (c *capture) send(_ context.Context, msg string) error {
if c.err != nil {
return c.err
}
c.msgs = append(c.msgs, msg)
return nil
}
func TestDownIsReportedOnceAndBackIsReported(t *testing.T) {
setup(t)
sources := src("Broken", "Fine")
now := time.Now()
storage.RecordPollResult("Fine", true, 10, nil)
fail("Broken", DownAfter-1)
c := &capture{}
pass(context.Background(), now, sources, c.send)
if len(c.msgs) != 0 {
t.Fatalf("alerted below the threshold: %q", c.msgs)
}
fail("Broken", 1)
pass(context.Background(), now, sources, c.send)
if len(c.msgs) != 1 || !strings.Contains(c.msgs[0], "Broken: 5 failed polls in a row, has never fetched successfully") {
t.Fatalf("down alert = %q", c.msgs)
}
if strings.Contains(c.msgs[0], "Fine") {
t.Errorf("a healthy feed was mentioned: %q", c.msgs[0])
}
// Still down on the next pass: nothing new to say.
fail("Broken", 3)
pass(context.Background(), now.Add(Every), sources, c.send)
if len(c.msgs) != 1 {
t.Fatalf("down alert repeated: %q", c.msgs[1:])
}
// It comes back. The success has to be stamped after the alert, so step
// past the second the alert was recorded in.
time.Sleep(1100 * time.Millisecond)
storage.RecordPollResult("Broken", true, 5, nil)
pass(context.Background(), time.Now(), sources, c.send)
if len(c.msgs) != 2 || !strings.Contains(c.msgs[1], "Broken is fetching again") {
t.Fatalf("recovery = %q", c.msgs)
}
pass(context.Background(), time.Now(), sources, c.send)
if len(c.msgs) != 2 {
t.Fatalf("recovery repeated: %q", c.msgs[2:])
}
}
func TestUndeliveredAlertIsRetried(t *testing.T) {
setup(t)
sources := src("Broken")
fail("Broken", DownAfter)
c := &capture{err: errors.New("homeserver unreachable")}
pass(context.Background(), time.Now(), sources, c.send)
c.err = nil
pass(context.Background(), time.Now(), sources, c.send)
if len(c.msgs) != 1 || !strings.Contains(c.msgs[0], "Broken") {
t.Fatalf("alert after a failed send = %q, want it delivered on the retry", c.msgs)
}
}
func TestQuietFeedIsReportedOnce(t *testing.T) {
setup(t)
sources := src("Sleepy", "Busy")
now := time.Now()
storage.RecordPollResult("Sleepy", true, 10, nil)
storage.RecordPollResult("Busy", true, 10, nil)
setLastNew(t, "Sleepy", now.Add(-9*24*time.Hour))
c := &capture{}
pass(context.Background(), now, sources, c.send)
if len(c.msgs) != 1 || !strings.Contains(c.msgs[0], "Sleepy fetches fine but hasn't had anything new in 9 days") {
t.Fatalf("quiet alert = %q", c.msgs)
}
if strings.Contains(c.msgs[0], "Busy") {
t.Errorf("a busy feed was called quiet: %q", c.msgs[0])
}
pass(context.Background(), now.Add(Every), sources, c.send)
if len(c.msgs) != 1 {
t.Fatalf("quiet alert repeated: %q", c.msgs[1:])
}
// New stories end the quiet spell without a message, and a later quiet
// spell is a new episode that gets reported again.
storage.RecordNewStories("Sleepy", 2)
pass(context.Background(), now, sources, c.send)
if len(c.msgs) != 1 {
t.Fatalf("end of a quiet spell was announced: %q", c.msgs[1:])
}
setLastNew(t, "Sleepy", now.Add(-8*24*time.Hour))
pass(context.Background(), now, sources, c.send)
if len(c.msgs) != 2 {
t.Fatalf("second quiet spell not reported: %q", c.msgs)
}
}
// A feed that has just been added has no history, so it must not be called
// quiet before it has had a full window to produce something.
func TestNewSourceGetsAGracePeriod(t *testing.T) {
setup(t)
storage.RecordPollResult("Fresh", true, 0, nil)
r, err := Check(time.Now().Add(QuietAfter-time.Hour), src("Fresh"))
if err != nil {
t.Fatal(err)
}
if !r.Empty() {
t.Fatalf("new source reported inside its grace period: %+v", r)
}
}
func TestDisabledSourcesNeverAlert(t *testing.T) {
setup(t)
fail("Retired", DownAfter*2)
sources := []config.SourceConfig{{Name: "Retired", Enabled: false}}
r, err := Check(time.Now(), sources)
if err != nil {
t.Fatal(err)
}
if !r.Empty() {
t.Fatalf("disabled source alerted: %+v", r)
}
}