package email import ( "context" "strings" "testing" "github.com/kami/maven/internal/llm" ) // fakeLLM returns a canned reply and records the request, so a test can assert // on the grammar and on what of the mail was sent. type fakeLLM struct { reply string err error got llm.Req calls int } func (f *fakeLLM) Complete(_ context.Context, r llm.Req) (string, error) { f.calls++ f.got = r return f.reply, f.err } func msgFor(subject, body string) Message { return Message{UID: 1, From: "anton@example.org", Subject: subject, Body: body} } func TestExtractCandidates(t *testing.T) { f := &fakeLLM{reply: `[{"text":"отправить акт","due":""},{"text":"оплатить счёт","due":"2026-08-05"}]`} e := NewExtractor(f, 0, nil) got, err := e.Extract(context.Background(), msgFor("Акт и счёт", "Надо отправить акт и оплатить счёт до 5 августа.")) if err != nil { t.Fatalf("extract: %v", err) } if len(got) != 2 { t.Fatalf("got %d candidates, want 2: %+v", len(got), got) } if got[0].Text != "отправить акт" || got[1].Due != "2026-08-05" { t.Errorf("candidates = %+v", got) } if f.got.Grammar == "" { t.Error("extraction must be grammar-constrained") } // The subject and body go to the model; nothing else about the message does. if !strings.Contains(f.got.User, "Акт и счёт") || !strings.Contains(f.got.User, "оплатить счёт") { t.Errorf("user turn = %q", f.got.User) } } func TestExtractEmptyArrayIsNotAnError(t *testing.T) { f := &fakeLLM{reply: "[]"} got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("FYI", "Просто к сведению.")) if err != nil || len(got) != 0 { t.Fatalf("got (%v, %v), want (empty, nil) — no task is the normal answer", got, err) } } // Junk must never reach the model: the header filter exists so the resident // model is not spent on newsletters. func TestExtractSkipsJunkWithoutCallingModel(t *testing.T) { f := &fakeLLM{reply: `[{"text":"купить всё со скидкой","due":""}]`} msg := msgFor("Скидки", "Sale!") msg.Junk = true got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msg) if err != nil || got != nil { t.Fatalf("got (%v, %v), want (nil, nil)", got, err) } if f.calls != 0 { t.Errorf("model called %d times for junk, want 0", f.calls) } } func TestExtractEmptyMessageIsNotSent(t *testing.T) { f := &fakeLLM{reply: "[]"} if _, err := NewExtractor(f, 0, nil).Extract(context.Background(), Message{UID: 3}); err != nil { t.Fatalf("extract: %v", err) } if f.calls != 0 { t.Errorf("model called %d times for an empty message, want 0", f.calls) } } func TestExtractCaps(t *testing.T) { f := &fakeLLM{reply: `[{"text":"a","due":""},{"text":"b","due":""},{"text":"c","due":""}]`} got, err := NewExtractor(f, 2, nil).Extract(context.Background(), msgFor("s", "b")) if err != nil { t.Fatalf("extract: %v", err) } if len(got) != 2 { t.Errorf("got %d, want the configured cap of 2", len(got)) } } func TestExtractDropsRepeatsAndBadDates(t *testing.T) { f := &fakeLLM{reply: `[{"text":"Отправить акт","due":"2026-02-31"},{"text":"отправить акт","due":""},{"text":" ","due":""}]`} got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("s", "b")) if err != nil { t.Fatalf("extract: %v", err) } if len(got) != 1 { t.Fatalf("got %d candidates, want 1 (repeat and blank dropped): %+v", len(got), got) } if got[0].Due != "" { t.Errorf("due = %q, want empty — 2026-02-31 is not a date", got[0].Due) } } // A Thinking model sometimes wraps the array; and when it emits something // unparsable the caller must hear about it rather than see "no tasks". func TestParseCandidatesTolerance(t *testing.T) { got, err := parseCandidates("думаю... [{\"text\":\"x\",\"due\":\"\"}] всё") if err != nil || len(got) != 1 || got[0].Text != "x" { t.Fatalf("got (%+v, %v)", got, err) } if _, err := parseCandidates("нет никакого JSON"); err == nil { t.Error("unparsable output must be an error") } } func TestExtractParseErrorHidesMailText(t *testing.T) { f := &fakeLLM{reply: "он просил отправить акт, вот такой ответ"} _, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("Акт", "секретный текст")) if err == nil { t.Fatal("want an error") } if strings.Contains(err.Error(), "акт") || strings.Contains(err.Error(), "секретный") { t.Errorf("error text leaks mail content: %v", err) } } func TestParseDue(t *testing.T) { if _, ok := ParseDue(""); ok { t.Error("empty due must be (zero, false)") } if got, ok := ParseDue("2026-08-05"); !ok || got.Year() != 2026 || got.Month() != 8 || got.Day() != 5 { t.Errorf("ParseDue = (%v, %v)", got, ok) } if _, ok := ParseDue("05.08.2026"); ok { t.Error("a non-ISO date must not parse") } }