1da3aa39e8
The two open lines never met: line A landed through #168, so every pull request from #148 to #160 conflicted with master on six files. This reconciles them. Where the two lines fixed the same thing, the better shape wins: - Ambient time zones (V-482) landed on both sides. Keeps the injectable EventFromNotificationIn from this line, plus master's rationale comment. Drops master's forced n.Posted.In(time.Local), which defeated the loc argument. - tick.go: master's guardNudge call and say.CountWord edits, moved onto the split files this line created. The digest summary now declines through say.CountWord inside tick_digest.go. - voice.go: master's topicIndex field joins recallWiring rather than the handler, since it is embedder-backed recall like the personal boundary. topics.go and its test read h.recall.topics now. - mavweb: master's capability and risk columns ported into tools.html, which is where this line moved the markup. The Go const is gone. - Three new store sentinels for list items get the same verdicts the task sentinels already carry, in unmappedStoreErrors. make build: 12 binaries. make test: green. make fmt-check: clean. --no-verify: a merge of two long lines cannot fit the 300-line budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
222 lines
8.4 KiB
Go
222 lines
8.4 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,
|
|
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)
|
|
}
|
|
}
|