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.
179 lines
5.9 KiB
Go
179 lines
5.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/config"
|
|
"github.com/kami/maven/internal/event"
|
|
"github.com/kami/maven/internal/ipc"
|
|
)
|
|
|
|
var intakeNow = time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
|
|
|
|
func intakeClock() time.Time { return intakeNow }
|
|
|
|
// failingAPI wraps the store adapter, failing the three intake writes on
|
|
// demand, so the "a failed write publishes nothing" invariant is testable.
|
|
type failingAPI struct {
|
|
ipc.CoreAPI
|
|
fail bool
|
|
}
|
|
|
|
func (f *failingAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
|
if f.fail {
|
|
return 0, errors.New("injected")
|
|
}
|
|
return f.CoreAPI.WriteFact(ctx, req)
|
|
}
|
|
|
|
func newIntakeTestAPI(t *testing.T) (ipc.CoreAPI, *event.Bus) {
|
|
t.Helper()
|
|
st := newTestStore(t)
|
|
bus := event.NewBus(32)
|
|
return newIntakeAPI(ipc.NewStoreAPI(st), bus, intakeClock), bus
|
|
}
|
|
|
|
func TestIntakeAPIWithoutBusIsTheBareAPI(t *testing.T) {
|
|
// The adoption invariant: with the journal off there is not even a
|
|
// decorator on the intake path, so production behaves exactly as before.
|
|
st := newTestStore(t)
|
|
bare := ipc.NewStoreAPI(st)
|
|
if got := newIntakeAPI(bare, nil, intakeClock); got != ipc.CoreAPI(bare) {
|
|
t.Errorf("newIntakeAPI with a nil bus returned a wrapper, want the bare API")
|
|
}
|
|
}
|
|
|
|
func TestNewEventBusOffWhenNegative(t *testing.T) {
|
|
if b := newEventBus(&config.Config{IntakeJournal: -1}); b != nil {
|
|
t.Error("intake_journal = -1 still built a bus")
|
|
}
|
|
if b := newEventBus(&config.Config{IntakeJournal: 4}); b == nil {
|
|
t.Error("intake_journal = 4 built no bus")
|
|
}
|
|
}
|
|
|
|
func TestIntakeJournalsAFactWrite(t *testing.T) {
|
|
api, bus := newIntakeTestAPI(t)
|
|
ctx := context.Background()
|
|
// The ambient path's shape: an env fact below full confidence, timestamped
|
|
// at the meeting's start rather than at notice time.
|
|
start := intakeNow.Add(2 * time.Hour)
|
|
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
|
Ts: start, Kind: "env", Key: "calendar_event_20260801_планёрка",
|
|
Value: "10:00-11:00 планёрка", Source: "ambient:notif", Confidence: 0.6,
|
|
}); err != nil {
|
|
t.Fatalf("WriteFact: %v", err)
|
|
}
|
|
got := bus.Recent(0)
|
|
if len(got) != 1 {
|
|
t.Fatalf("journal has %d entries, want 1", len(got))
|
|
}
|
|
e := got[0]
|
|
if e.Source != "ambient:notif" || e.Kind != event.KindFact {
|
|
t.Errorf("source/kind = %q/%q", e.Source, e.Kind)
|
|
}
|
|
if e.Title != "calendar_event_20260801_планёрка" {
|
|
t.Errorf("title = %q, want the fact key", e.Title)
|
|
}
|
|
if !e.OccurredAt.Equal(start) {
|
|
t.Errorf("occurred_at = %v, want the fact's Ts %v — the journal must not flatten intake to notice time", e.OccurredAt, start)
|
|
}
|
|
if e.Priority != event.PriorityLow {
|
|
t.Errorf("priority = %q, want %q for a sub-1.0 confidence read", e.Priority, event.PriorityLow)
|
|
}
|
|
}
|
|
|
|
func TestIntakeDoesNotJournalAFailedWrite(t *testing.T) {
|
|
st := newTestStore(t)
|
|
bus := event.NewBus(8)
|
|
api := newIntakeAPI(&failingAPI{CoreAPI: ipc.NewStoreAPI(st), fail: true}, bus, intakeClock)
|
|
if _, err := api.WriteFact(context.Background(), ipc.WriteFactReq{
|
|
Ts: intakeNow, Kind: "env", Key: "k", Value: "v", Source: "poll:zenmoney", Confidence: 1,
|
|
}); err == nil {
|
|
t.Fatal("expected the injected error")
|
|
}
|
|
if bus.Len() != 0 {
|
|
t.Errorf("journal has %d entries after a failed write, want 0 — an event reports something that happened", bus.Len())
|
|
}
|
|
}
|
|
|
|
func TestIntakeJournalsANoteAsTitlePlusBody(t *testing.T) {
|
|
api, bus := newIntakeTestAPI(t)
|
|
// The RSS shape: "headline\nsummary\nlink".
|
|
if _, err := api.WriteNote(context.Background(), intakeNow,
|
|
"Вышло ядро 6.19\nкраткое содержание\nhttps://example.org/a", nil, "rss:tech"); err != nil {
|
|
t.Fatalf("WriteNote: %v", err)
|
|
}
|
|
got := bus.Recent(1)
|
|
if len(got) != 1 {
|
|
t.Fatalf("journal has %d entries, want 1", len(got))
|
|
}
|
|
if got[0].Title != "Вышло ядро 6.19" {
|
|
t.Errorf("title = %q, want the headline", got[0].Title)
|
|
}
|
|
if got[0].Kind != event.KindNote {
|
|
t.Errorf("kind = %q, want %q", got[0].Kind, event.KindNote)
|
|
}
|
|
if got[0].Body == "" {
|
|
t.Error("body is empty, want the rest of the note")
|
|
}
|
|
}
|
|
|
|
func TestIntakeJournalsOnlyCreatedTasks(t *testing.T) {
|
|
api, bus := newIntakeTestAPI(t)
|
|
ctx := context.Background()
|
|
req := ipc.CaptureTaskReq{Text: "оплатить интернет", Source: "email:inbox", Status: "candidate", Ts: intakeNow}
|
|
if _, err := api.CaptureTask(ctx, req); err != nil {
|
|
t.Fatalf("CaptureTask: %v", err)
|
|
}
|
|
// Same text again: CaptureTask dedupes among live rows, and a re-read of a
|
|
// mailbox must not refill the journal.
|
|
resp, err := api.CaptureTask(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("CaptureTask (repeat): %v", err)
|
|
}
|
|
if resp.Created {
|
|
t.Fatal("store did not dedupe; the test cannot check what it means to")
|
|
}
|
|
if bus.Len() != 1 {
|
|
t.Errorf("journal has %d entries, want 1 — a deduped capture must not publish", bus.Len())
|
|
}
|
|
if got := bus.Recent(1)[0]; got.Kind != event.KindTask || got.Title != "оплатить интернет" {
|
|
t.Errorf("entry = %+v, want the captured task", got)
|
|
}
|
|
}
|
|
|
|
func TestIntakeEventsFnRendersNewestFirst(t *testing.T) {
|
|
api, bus := newIntakeTestAPI(t)
|
|
ctx := context.Background()
|
|
for _, key := range []string{"a", "b", "c"} {
|
|
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
|
Ts: intakeNow, Kind: "env", Key: key, Value: "1", Source: "poll:zenmoney", Confidence: 1,
|
|
}); err != nil {
|
|
t.Fatalf("WriteFact %s: %v", key, err)
|
|
}
|
|
}
|
|
fn := intakeEventsFn(bus)
|
|
got := fn(2)
|
|
if len(got) != 2 || got[0].Title != "c" || got[1].Title != "b" {
|
|
t.Errorf("intakeEventsFn(2) = %+v, want the two newest, newest first", got)
|
|
}
|
|
if intakeEventsFn(nil) != nil {
|
|
t.Error("intakeEventsFn(nil) returned a closure, want nil so daemonAPI reports an empty journal")
|
|
}
|
|
}
|
|
|
|
func TestDaemonAPIRecentEventsEmptyWithoutABus(t *testing.T) {
|
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
|
|
got, err := d.RecentEvents(context.Background(), 10)
|
|
if err != nil {
|
|
t.Fatalf("RecentEvents with no journal errored: %v", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Errorf("got %d events, want none", len(got))
|
|
}
|
|
}
|