57161fb762
Nothing at read time told a feed item or a crawled page apart from his own notes. QueryNotes ranked every note by cosine and the notes answer handed the nearest five to the phraser, so "что я говорил про переезд" could be answered out of a stranger's web page, prefixed with "вот что я нашла: ". Recall now excludes the read sources, rss: and crawl:, and the list is one place. The feed answer needed a different read as a result, and it needed one anyway: it scanned the last 200 notes of any source, so a busy day of voice notes pushed the newest headline out of the window and she said "в лентах пока ничего нового" while the poller was working fine. RecentNotesFromSource asks for feed notes by source, so the window holds 200 of them. Found in review of #66 and #67.
197 lines
7.1 KiB
Go
197 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/config"
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/phraser"
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/rss"
|
|
"github.com/kami/maven/internal/voice"
|
|
)
|
|
|
|
// buildFeedHandler — a handler with the given feed notes already stored. No
|
|
// embedder: the feed source answers from recent notes by source, which is what
|
|
// makes it work for notes written before an embedder existed.
|
|
func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHandler {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
now := time.Now()
|
|
for i, n := range notes {
|
|
ts := now.Add(time.Duration(i) * time.Minute)
|
|
if _, err := st.WriteNote(ctx, ts, n.Text, nil, n.Source); err != nil {
|
|
t.Fatalf("WriteNote: %v", err)
|
|
}
|
|
}
|
|
return &reactiveHandler{
|
|
api: ipc.NewStoreAPI(st),
|
|
replier: voice.NewStubReplier(),
|
|
phraser: phraser.NewStub(),
|
|
now: func() time.Time { return now },
|
|
feedsOn: feedsOn,
|
|
embedder: nil,
|
|
}
|
|
}
|
|
|
|
func askFeeds(t *testing.T, h *reactiveHandler, q string) (string, bool) {
|
|
t.Helper()
|
|
return h.queryFeeds(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
|
})
|
|
}
|
|
|
|
func TestQueryFeedsReadsFeedNotes(t *testing.T) {
|
|
h := buildFeedHandler(t, true,
|
|
ipc.Note{Text: "Новая уязвимость в ядре [технологии]\nпатч вышел\nhttps://example.org/a", Source: "rss:habr"},
|
|
ipc.Note{Text: "что-то он сам сказал", Source: "tap:voice"},
|
|
)
|
|
reply, ok := askFeeds(t, h, "что нового в лентах?")
|
|
if !ok {
|
|
t.Fatal("the feed source did not claim the question")
|
|
}
|
|
if !strings.Contains(reply, "уязвимость") {
|
|
t.Errorf("reply = %q, want the headline", reply)
|
|
}
|
|
if strings.Contains(reply, "он сам сказал") {
|
|
t.Errorf("a note he dictated leaked into the feed answer: %q", reply)
|
|
}
|
|
// She reads the headline, not the summary and not the URL.
|
|
if strings.Contains(reply, "https://") || strings.Contains(reply, "патч вышел") {
|
|
t.Errorf("reply = %q, want the title line only", reply)
|
|
}
|
|
}
|
|
|
|
func TestQueryFeedsByCategory(t *testing.T) {
|
|
h := buildFeedHandler(t, true,
|
|
ipc.Note{Text: "Релиз ядра [технологии]", Source: "rss:habr"},
|
|
ipc.Note{Text: "Выборы отложены [политика]", Source: "rss:news"},
|
|
)
|
|
reply, ok := askFeeds(t, h, "что нового по технологиям?")
|
|
if !ok {
|
|
t.Fatal("not claimed")
|
|
}
|
|
if !strings.Contains(reply, "ядра") || strings.Contains(reply, "Выборы") {
|
|
t.Fatalf("reply = %q, want only the технологии item", reply)
|
|
}
|
|
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
|
if !strings.Contains(reply, "ничего") {
|
|
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
|
}
|
|
}
|
|
|
|
// "не настроены" and "ничего нового" are different truths, and neither may be
|
|
// answered by the model inventing a bulletin.
|
|
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
|
off := buildFeedHandler(t, false)
|
|
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
|
if !ok || !strings.Contains(reply, "не настроены") {
|
|
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
|
}
|
|
on := buildFeedHandler(t, true)
|
|
reply, ok = askFeeds(t, on, "что нового в лентах?")
|
|
if !ok || !strings.Contains(reply, "ничего нового") {
|
|
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
|
}
|
|
}
|
|
|
|
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
|
h := buildFeedHandler(t, true)
|
|
if reply, ok := askFeeds(t, h, "напомни полить цветы"); ok {
|
|
t.Fatalf("claimed an unrelated question with %q", reply)
|
|
}
|
|
// The bare greeting is not a request for headlines. It used to be answered
|
|
// with a configuration status.
|
|
if reply, ok := askFeeds(t, h, "что нового?"); ok {
|
|
t.Fatalf("claimed a greeting with %q", reply)
|
|
}
|
|
}
|
|
|
|
// A busy day of his own notes must not push the newest headline out of the
|
|
// window the feed answer scans.
|
|
func TestQueryFeedsIsNotCrowdedOutByHisOwnNotes(t *testing.T) {
|
|
notes := []ipc.Note{{Text: "Релиз ядра [технологии]", Source: "rss:habr"}}
|
|
for i := 0; i < feedNoteWindow+10; i++ {
|
|
notes = append(notes, ipc.Note{Text: "мысль вслух", Source: "tap:voice"})
|
|
}
|
|
h := buildFeedHandler(t, true, notes...)
|
|
reply, ok := askFeeds(t, h, "что нового в лентах?")
|
|
if !ok || !strings.Contains(reply, "ядра") {
|
|
t.Fatalf("reply = %q, ok = %v; the headline fell out of the window", reply, ok)
|
|
}
|
|
}
|
|
|
|
// The mark is what stops a restart from re-noting yesterday's headlines, so the
|
|
// fact round-trip is worth a test of its own.
|
|
func TestFactMarksRoundTrip(t *testing.T) {
|
|
st := newTestStore(t)
|
|
m := &factMarks{api: ipc.NewStoreAPI(st)}
|
|
ctx := context.Background()
|
|
|
|
at, err := m.LastMark(ctx, "habr")
|
|
if err != nil || !at.IsZero() {
|
|
t.Fatalf("no mark yet: got %v, %v — want zero time and no error", at, err)
|
|
}
|
|
want := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC)
|
|
if err := m.SetMark(ctx, "habr", want); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := m.LastMark(ctx, "habr")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !got.Equal(want) {
|
|
t.Fatalf("mark = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// Off unless configured, checked at the wiring seam: no `feeds` block ⇒ no
|
|
// worker ⇒ no outbound request is possible.
|
|
func TestNewFeedWorkerOffByDefault(t *testing.T) {
|
|
st := newTestStore(t)
|
|
api := ipc.NewStoreAPI(st)
|
|
if w := newFeedWorker(api, nil, &config.Config{}); w != nil {
|
|
t.Fatal("a config with no feeds block wired a feed worker")
|
|
}
|
|
// An empty sources list is normalised to "off" by config.Load; the worker
|
|
// refuses it too, so a hand-built Config cannot switch it on by accident.
|
|
if w := newFeedWorker(api, nil, &config.Config{Feeds: &config.FeedsConfig{}}); w != nil {
|
|
t.Fatal("an empty sources list wired a feed worker")
|
|
}
|
|
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
|
|
{Name: "habr", URL: "https://example.org/rss"},
|
|
}}}
|
|
w := newFeedWorker(api, nil, cfg)
|
|
if w == nil {
|
|
t.Fatal("a configured feed did not wire a worker")
|
|
}
|
|
if got := w.poller.Feeds(); len(got) != 1 || got[0].Name != "habr" {
|
|
t.Fatalf("feeds = %+v", got)
|
|
}
|
|
}
|
|
|
|
// The fetcher the worker builds must be allowlisted to the configured feeds and
|
|
// nothing else — the crawler's SSRF guards are only worth as much as the
|
|
// allowlist handed to them.
|
|
func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
|
|
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
|
|
{Name: "habr", URL: "https://feeds.example.org/rss"},
|
|
}}}
|
|
w := newFeedWorker(ipc.NewStoreAPI(newTestStore(t)), nil, cfg)
|
|
if w == nil {
|
|
t.Fatal("no worker")
|
|
}
|
|
// PollFeed goes through the guarded fetcher; a feed URL pointing at the box
|
|
// itself must fail rather than be read.
|
|
_, err := w.poller.PollFeed(context.Background(), rss.FeedConfig{
|
|
Name: "evil", URL: "http://127.0.0.1:9100/mcp",
|
|
}, time.Now())
|
|
if err == nil {
|
|
t.Fatal("the poller fetched a private address")
|
|
}
|
|
}
|