45b5e16eff
Things arrive at Maven from eight directions — a relayed Android notification on POST /api/ambient, mail candidates from mavmaild, RSS items, changed pages from the crawler, zenmoney and wg reads from mavpoll, CalDAV events, presence probes, meeting transcripts and image descriptions. Each grew its own shape and its own log line, and nothing could answer "what came in today, from where". internal/event is that answer: a flat source-agnostic envelope (Source, Kind, EntityIDs, Title, Body, Priority, OccurredAt, Payload) plus a bounded in-memory journal. Both are pure — Publish and Normalize take `now` as a parameter, so no clock read sits on a path a replay would drive. Adopting it did not touch eight callers, because every intake path already converges on three ipc.CoreAPI methods: WriteFact, WriteNote and CaptureTask. cmd/mavend/intake.go decorates that ONE interface, so mavweb, mavcaldav, mavpoll, mavmaild and the in-core feed/crawl/capture/ vision workers publish envelopes without knowing events exist. The lone exception is cmd/mavend/mail.go, which captures through the store directly and now publishes explicitly. Nothing dispatches on an event. It is a report that something arrived, never an instruction to speak — "a feed item appeared" becoming a notification is the nag this repo refuses. Digestion may read the journal later; it will still go through internal/loop's rules and the severity/presence routing table. Read surface: ipc.MethodRecentEvents (AuthRead, daemon-cached like TickTrace — a bare store cannot serve a ring) and a read-only /events page in mavweb. Production is unchanged when nobody is watching: a nil *event.Bus makes Publish a no-op and newIntakeAPI returns the wrapped API untouched, so config.intake_journal < 0 leaves no decorator on the call path at all. The default is 512 entries; the "off unless configured" rule is for capabilities that reach out, and a bounded in-memory log of writes core already performed reaches nowhere. Verified: make build, make test (go test -race) both clean. New tests cover the envelope and ring (internal/event, 95.7%), the decorator's invariants — a failed write publishes nothing, a deduped capture publishes nothing, OccurredAt is the fact's Ts and not notice time — and the /events page including escaping of feed-supplied titles.
183 lines
5.8 KiB
Go
183 lines
5.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/config"
|
|
"github.com/kami/maven/internal/email"
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/llm"
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// mailLLM — a canned extraction reply.
|
|
type mailLLM struct {
|
|
reply string
|
|
calls int
|
|
}
|
|
|
|
func (m *mailLLM) Complete(_ context.Context, _ llm.Req) (string, error) {
|
|
m.calls++
|
|
return m.reply, nil
|
|
}
|
|
|
|
func newTestIntake(t *testing.T, reply string) (*mailIntake, *store.Store, *mailLLM) {
|
|
t.Helper()
|
|
st := newTestStore(t)
|
|
fake := &mailLLM{reply: reply}
|
|
return &mailIntake{
|
|
st: st,
|
|
ex: email.NewExtractor(fake, 0, nil),
|
|
timeout: 5 * time.Second,
|
|
now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) },
|
|
}, st, fake
|
|
}
|
|
|
|
func ingestReq() ipc.IngestMailReq {
|
|
return ipc.IngestMailReq{
|
|
Mailbox: "INBOX", UID: 42,
|
|
From: "billing@isp.example",
|
|
Subject: "Счёт за интернет",
|
|
Body: "Оплатите счёт до 5 августа.",
|
|
}
|
|
}
|
|
|
|
// The one property that matters: a mail-derived task is a candidate, attributed
|
|
// to the mailbox, with the subject as reviewable evidence — and nothing else is
|
|
// written.
|
|
func TestIngestCapturesCandidates(t *testing.T) {
|
|
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт за интернет","due":"2026-08-05"}]`)
|
|
resp, err := mi.ingest(context.Background(), ingestReq())
|
|
if err != nil {
|
|
t.Fatalf("ingest: %v", err)
|
|
}
|
|
if resp.Created != 1 || len(resp.TaskIDs) != 1 {
|
|
t.Fatalf("resp = %+v, want one created task", resp)
|
|
}
|
|
tasks, err := st.ListTasks(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(tasks) != 1 {
|
|
t.Fatalf("got %d tasks, want 1", len(tasks))
|
|
}
|
|
got := tasks[0]
|
|
if got.Status != store.TaskCandidate {
|
|
t.Errorf("status = %q, want %q — mail may only produce candidates", got.Status, store.TaskCandidate)
|
|
}
|
|
if got.Source != "email:INBOX" {
|
|
t.Errorf("source = %q, want email:INBOX", got.Source)
|
|
}
|
|
if got.Evidence != "Счёт за интернет" {
|
|
t.Errorf("evidence = %q, want the subject line", got.Evidence)
|
|
}
|
|
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
|
|
t.Errorf("due = %v, want 2026-08-05", got.Due)
|
|
}
|
|
// Nothing else may have been written: no reminder, no fact.
|
|
rem, err := st.ListReminders(context.Background(), 10)
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rem) != 0 {
|
|
t.Errorf("mail created %d reminders; a misread mail must never be able to fire", len(rem))
|
|
}
|
|
}
|
|
|
|
// Re-reading a mailbox must not grow the list — CaptureTask dedupes among live
|
|
// rows, and the intake relies on exactly that.
|
|
func TestIngestSameMailTwiceIsIdempotent(t *testing.T) {
|
|
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт","due":""}]`)
|
|
if _, err := mi.ingest(context.Background(), ingestReq()); err != nil {
|
|
t.Fatalf("first ingest: %v", err)
|
|
}
|
|
resp, err := mi.ingest(context.Background(), ingestReq())
|
|
if err != nil {
|
|
t.Fatalf("second ingest: %v", err)
|
|
}
|
|
if resp.Created != 0 || len(resp.TaskIDs) != 1 {
|
|
t.Errorf("resp = %+v, want the existing row and Created=0", resp)
|
|
}
|
|
tasks, _ := st.ListTasks(context.Background(), "")
|
|
if len(tasks) != 1 {
|
|
t.Errorf("got %d tasks after two reads, want 1", len(tasks))
|
|
}
|
|
}
|
|
|
|
func TestIngestJunkSkipsTheModel(t *testing.T) {
|
|
mi, st, fake := newTestIntake(t, `[{"text":"купить со скидкой","due":""}]`)
|
|
req := ingestReq()
|
|
req.Junk = true
|
|
resp, err := mi.ingest(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("ingest: %v", err)
|
|
}
|
|
if !resp.Skipped || resp.Created != 0 {
|
|
t.Errorf("resp = %+v, want skipped", resp)
|
|
}
|
|
if fake.calls != 0 {
|
|
t.Errorf("model called %d times for junk, want 0", fake.calls)
|
|
}
|
|
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
|
t.Errorf("junk produced %d tasks, want 0", len(tasks))
|
|
}
|
|
}
|
|
|
|
func TestIngestEmptyMessageSkipped(t *testing.T) {
|
|
mi, _, fake := newTestIntake(t, "[]")
|
|
resp, err := mi.ingest(context.Background(), ipc.IngestMailReq{Mailbox: "INBOX", UID: 1})
|
|
if err != nil || !resp.Skipped {
|
|
t.Fatalf("resp = %+v, err = %v; want skipped", resp, err)
|
|
}
|
|
if fake.calls != 0 {
|
|
t.Errorf("model called %d times for an empty message, want 0", fake.calls)
|
|
}
|
|
}
|
|
|
|
func TestIngestNoTasksWritesNothing(t *testing.T) {
|
|
mi, st, _ := newTestIntake(t, "[]")
|
|
resp, err := mi.ingest(context.Background(), ingestReq())
|
|
if err != nil {
|
|
t.Fatalf("ingest: %v", err)
|
|
}
|
|
if resp.Created != 0 || len(resp.TaskIDs) != 0 || resp.Skipped {
|
|
t.Errorf("resp = %+v, want nothing captured and not skipped", resp)
|
|
}
|
|
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
|
t.Errorf("got %d tasks, want 0", len(tasks))
|
|
}
|
|
}
|
|
|
|
func TestIngestTruncatesEvidence(t *testing.T) {
|
|
mi, st, _ := newTestIntake(t, `[{"text":"дело","due":""}]`)
|
|
req := ingestReq()
|
|
req.Subject = strings.Repeat("щ", 400)
|
|
if _, err := mi.ingest(context.Background(), req); err != nil {
|
|
t.Fatalf("ingest: %v", err)
|
|
}
|
|
tasks, _ := st.ListTasks(context.Background(), "")
|
|
if len(tasks) != 1 {
|
|
t.Fatalf("got %d tasks, want 1", len(tasks))
|
|
}
|
|
if n := len([]rune(tasks[0].Evidence)); n > evidenceMaxChars+1 {
|
|
t.Errorf("evidence kept %d runes, want ≤ %d", n, evidenceMaxChars)
|
|
}
|
|
}
|
|
|
|
// Off unless configured: no email block ⇒ no intake, so the IPC method does not
|
|
// exist at all.
|
|
func TestNewMailIntakeOffWithoutConfig(t *testing.T) {
|
|
st := newTestStore(t)
|
|
if mi := newMailIntake(st, nil, &config.Config{}, nil); mi != nil {
|
|
t.Error("no email block must mean no mail intake")
|
|
}
|
|
// Configured but with a non-LLM phraser: still off — there is no fallback
|
|
// extraction, by design.
|
|
if mi := newMailIntake(st, nil, &config.Config{Email: &config.EmailConfig{}}, nil); mi != nil {
|
|
t.Error("without a llama-server phraser there is nothing to extract with")
|
|
}
|
|
}
|