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") } } // The mailbox name becomes the provenance string, which is the vocabulary the // loop's rules trust. "email:" is not a source and neither is "email:anything // he could post at the socket". func TestIngestRejectsBadMailbox(t *testing.T) { for _, name := range []string{"", " ", "IN BOX", "IN\nBOX", "IN\x00BOX", strings.Repeat("щ", maxMailboxChars+1)} { mi, st, fake := newTestIntake(t, `[{"text":"дело","due":""}]`) req := ingestReq() req.Mailbox = name if _, err := mi.ingest(context.Background(), req); err == nil { t.Errorf("mailbox %q was accepted", name) } if fake.calls != 0 { t.Errorf("mailbox %q reached the model", name) } if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 { t.Errorf("mailbox %q wrote %d tasks", name, len(tasks)) } } } // slowLLM burns most of the extraction budget before answering, the way a // Thinking 1.7B does on a long mail. type slowLLM struct { reply string delay time.Duration } func (s *slowLLM) Complete(ctx context.Context, _ llm.Req) (string, error) { select { case <-time.After(s.delay): return s.reply, nil case <-ctx.Done(): return "", ctx.Err() } } // The extraction budget must not also bound the writes. It used to be one // context, so a model answering near the deadline lost the candidates it had // just produced. func TestIngestCapturesAfterASlowExtraction(t *testing.T) { st := newTestStore(t) mi := &mailIntake{ st: st, ex: email.NewExtractor(&slowLLM{reply: `[{"text":"оплатить счёт","due":""}]`, delay: 90 * time.Millisecond}, 0, nil), timeout: 100 * time.Millisecond, now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) }, } resp, err := mi.ingest(context.Background(), ingestReq()) if err != nil { t.Fatalf("ingest: %v", err) } if resp.Created != 1 { t.Fatalf("resp = %+v, want the candidate captured", resp) } if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 1 { t.Errorf("got %d tasks, want 1", len(tasks)) } }