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, recall: recallWiring{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 !phraser.IsQ(phraser.QueryFeedsTopic, nil, 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) { // Against the entries, not against a substring: both of these have several // wordings, so "ничего нового" passed only on the turns the picker happened // to choose the first one. off := buildFeedHandler(t, false) reply, ok := askFeeds(t, off, "что нового в лентах?") if !ok || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) { t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok) } on := buildFeedHandler(t, true) reply, ok = askFeeds(t, on, "что нового в лентах?") if !ok || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) { t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok) } if phraser.IsQ(phraser.QueryFeedsOff, nil, reply) { t.Fatalf("an empty feed answered as an unconfigured one: %q", reply) } } 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") } } // TestQueryFeedsPassesWhenTheWorldCanAnswer — the defect (Vikunja #474). The // deployed box has no feeds block and does have SearXNG, and "что происходит // сейчас в новостях про искусственный интеллект?" got a configuration status // instead of the live answer sitting one source below. func TestQueryFeedsPassesWhenTheWorldCanAnswer(t *testing.T) { h := buildFeedHandler(t, false) h.search = &searchWiring{max: 3, runes: 1500} if reply, ok := askFeeds(t, h, "что нового в лентах?"); ok { t.Fatalf("feeds off with a search configured must fall through, got %q", reply) } // With nothing below that reads the world, the honest status is still said: // general knowledge would otherwise answer with an invented bulletin. h.search = nil if reply, ok := askFeeds(t, h, "что нового в лентах?"); !ok || !strings.Contains(reply, "не настроены") { t.Fatalf("no search and no ZIMs: reply = %q, ok = %v", reply, ok) } }