package main import ( "context" "fmt" "os" "path/filepath" "strings" "testing" "time" "github.com/kami/maven/internal/email" "github.com/kami/maven/internal/ipc" ) // ---- a fake mailbox ------------------------------------------------------- // // It fakes the READ, not the protocol: internal/email owns the IMAP tests, and // its dialer is unexported precisely so this package cannot substitute a // transport. type fakeIMAP struct { msgs map[uint32]string uids []uint32 cmds []string } // fetch is the read seam the reader exposes: the daemon cannot reach // internal/email's dialer (it is unexported so nothing outside that package can // point the reader at a cleartext transport), so a test fakes the whole read. // The IMAP protocol itself is covered by internal/email's own tests. func (f *fakeIMAP) fetch(r *reader) func(string) ([]email.Message, error) { return func(string) ([]email.Message, error) { var out []email.Message var low uint32 for _, uid := range f.uids { if low == 0 || uid < low { low = uid } } if low > 0 { r.state.retire(low) } for i := len(f.uids) - 1; i >= 0; i-- { uid := f.uids[i] if r.state.seen(uid) { continue } raw, ok := f.msgs[uid] if !ok { continue } f.cmds = append(f.cmds, fmt.Sprintf("UID FETCH %d", uid)) msg, err := email.ParseMessage(uid, []byte(raw)) if err != nil { continue } out = append(out, msg) if r.max > 0 && len(out) >= r.max { break } } return out, nil } } // ---- a fake core ----------------------------------------------------------- type fakeCore struct { got []ipc.IngestMailReq resp ipc.IngestMailResp err error } func (c *fakeCore) IngestMail(_ context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) { c.got = append(c.got, req) if c.err != nil { return ipc.IngestMailResp{}, c.err } return c.resp, nil } func mail(subject, body string, extraHeaders ...string) string { h := "Subject: " + subject + "\r\nFrom: a@b.c\r\nContent-Type: text/plain; charset=utf-8\r\n" for _, e := range extraHeaders { h += e + "\r\n" } return h + "\r\n" + body + "\r\n" } func newTestReader(t *testing.T, f *fakeIMAP, core *fakeCore, statePath string) *reader { t.Helper() r := &reader{ core: core, addr: "mail.example:993", user: "kami", mailbox: "INBOX", lookback: 72 * time.Hour, max: 25, timeout: 5 * time.Second, state: newSeenState(statePath), } r.fetchMail = f.fetch(r) return r } func TestPollHandsMessagesToCore(t *testing.T) { f := &fakeIMAP{ uids: []uint32{1, 2}, msgs: map[uint32]string{ 1: mail("Счёт", "Оплатить до 5 августа."), 2: mail("Скидки", "Sale!", "List-Unsubscribe: "), }, } core := &fakeCore{resp: ipc.IngestMailResp{TaskIDs: []int64{1}, Created: 1}} r := newTestReader(t, f, core, "") r.pollOnce(context.Background(), "secret") // Two calls: the real mail with its text, and the newsletter as a verdict // with no content at all. Core is told about bulk rather than asked, so it // can count it without spending the model. if len(core.got) != 2 { t.Fatalf("core saw %d messages, want 2: %+v", len(core.got), core.got) } var got, bulk ipc.IngestMailReq for _, r := range core.got { if r.Junk { bulk = r } else { got = r } } if bulk.UID != 2 || !bulk.Junk { t.Errorf("bulk req = %+v, want uid 2 flagged junk", bulk) } if bulk.Subject != "" || bulk.Body != "" || bulk.From != "" { t.Errorf("a bulk verdict must carry no mail content: %+v", bulk) } if got.UID != 1 || got.Mailbox != "INBOX" || got.Subject != "Счёт" { t.Errorf("ingest req = %+v", got) } if !strings.Contains(got.Body, "Оплатить") { t.Errorf("body = %q", got.Body) } } // A second poll must not re-send what core already saw — extraction is a // multi-second LLM call per message. func TestPollSkipsSeenUIDs(t *testing.T) { f := &fakeIMAP{uids: []uint32{5}, msgs: map[uint32]string{5: mail("Счёт", "текст")}} core := &fakeCore{} r := newTestReader(t, f, core, "") r.pollOnce(context.Background(), "secret") r.pollOnce(context.Background(), "secret") if len(core.got) != 1 { t.Errorf("core saw %d messages over two polls, want 1", len(core.got)) } } // An ingest that failed is NOT marked seen: the next poll retries it. func TestPollRetriesFailedIngest(t *testing.T) { f := &fakeIMAP{uids: []uint32{5}, msgs: map[uint32]string{5: mail("Счёт", "текст")}} core := &fakeCore{err: fmt.Errorf("llama-server is warming up")} r := newTestReader(t, f, core, "") r.pollOnce(context.Background(), "secret") core.err = nil r.pollOnce(context.Background(), "secret") if len(core.got) != 2 { t.Errorf("core saw %d attempts, want 2 (a failed ingest is retried)", len(core.got)) } } // Core without an email block ⇒ stop, don't hammer the socket. func TestPollStopsWhenCoreRefusesMail(t *testing.T) { f := &fakeIMAP{uids: []uint32{1, 2}, msgs: map[uint32]string{1: mail("a", "b"), 2: mail("c", "d")}} core := &fakeCore{err: fmt.Errorf("call: %w", ipc.ErrUnknownMethod)} r := newTestReader(t, f, core, "") r.pollOnce(context.Background(), "secret") if !r.disabled { t.Error("ErrUnknownMethod must disable the reader") } if len(core.got) != 1 { t.Errorf("core saw %d messages, want 1 — stop at the first refusal", len(core.got)) } } func TestSeenStatePersists(t *testing.T) { path := filepath.Join(t.TempDir(), "state", "seen.json") f := &fakeIMAP{uids: []uint32{9}, msgs: map[uint32]string{9: mail("Счёт", "текст")}} core := &fakeCore{} r := newTestReader(t, f, core, path) r.pollOnce(context.Background(), "secret") fi, err := os.Stat(path) if err != nil { t.Fatalf("state file: %v", err) } // A list of message ids from his mailbox is metadata about his mail. if perm := fi.Mode().Perm(); perm != 0o600 { t.Errorf("state file mode = %v, want 0600", perm) } // A fresh reader with the same state file must not re-read the message. core2 := &fakeCore{} r2 := newTestReader(t, f, core2, path) if err := r2.state.load(); err != nil { t.Fatalf("load: %v", err) } r2.pollOnce(context.Background(), "secret") if len(core2.got) != 0 { t.Errorf("after a restart core saw %d messages, want 0", len(core2.got)) } } func TestSeenStateHighWaterMark(t *testing.T) { s := newSeenState("") s.mark(1) s.mark(3) s.mark(2) if s.high != 3 { t.Errorf("high = %d, want 3 (contiguous run collapses)", s.high) } if len(s.set) != 0 { t.Errorf("explicit set = %v, want empty", s.set) } if !s.seen(2) || s.seen(4) { t.Errorf("seen(2)=%v seen(4)=%v", s.seen(2), s.seen(4)) } } func TestSeenStateCorruptFileIsNotFatal(t *testing.T) { path := filepath.Join(t.TempDir(), "seen.json") if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { t.Fatal(err) } s := newSeenState(path) if err := s.load(); err == nil { t.Error("a corrupt state file should report an error the caller logs") } if s.seen(1) { t.Error("a corrupt state file must leave an empty seen-set, not a poisoned one") } } // Off unless configured, and the credential is never a flag value. func TestRunRequiresConfig(t *testing.T) { if err := run([]string{}); err == nil { t.Error("no -socket must be an error") } if err := run([]string{"-socket", "/tmp/nope.sock"}); err == nil { t.Error("no mailbox configuration must be an error, not a default mailbox") } // There is no -password flag at all: only -password-file. if err := run([]string{"-socket", "/x", "-imap", "h", "-user", "u", "-password", "p"}); err == nil || !strings.Contains(err.Error(), "flag provided but not defined") { t.Errorf("a -password flag must not exist; err = %v", err) } } func TestRunRejectsEmptyPasswordFile(t *testing.T) { path := filepath.Join(t.TempDir(), "pass") if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil { t.Fatal(err) } err := run([]string{"-socket", "/x/y.sock", "-imap", "h", "-user", "u", "-password-file", path}) if err == nil || !strings.Contains(err.Error(), "empty") { t.Errorf("an empty password file must be refused before dialling; err = %v", err) } } // A UID that never ingests pinned the high-water mark forever, because the mark // only advances through a contiguous run. Once that UID falls out of the // lookback window it can never be fetched again, so there is nothing left to // wait for and everything above it can leave the explicit set. func TestSeenStateRetiresAgedOutUIDs(t *testing.T) { s := newSeenState("") s.mark(1000) // 999 failed and was deliberately not marked s.mark(1001) if s.high != 0 || len(s.set) != 2 { t.Fatalf("high = %d, set = %v; want the mark pinned below the gap", s.high, s.set) } // The next SEARCH window starts at 1000: 999 has aged out. s.retire(1000) if s.high != 1001 { t.Errorf("high = %d, want 1001 once the gap is unreachable", s.high) } if len(s.set) != 0 { t.Errorf("explicit set = %v, want empty", s.set) } if !s.seen(999) || !s.seen(1001) || s.seen(1002) { t.Errorf("seen(999)=%v seen(1001)=%v seen(1002)=%v", s.seen(999), s.seen(1001), s.seen(1002)) } } // retire never moves the mark backwards: a UIDVALIDITY reset restarts UIDs low, // and that must not un-see a mailbox or re-see one. func TestSeenStateRetireNeverGoesBackwards(t *testing.T) { s := newSeenState("") s.mark(1) s.mark(2) s.retire(1) if s.high != 2 { t.Errorf("high = %d, want 2 unchanged", s.high) } } // A poll must not leave the state file growing with UIDs that are already // covered by the high-water mark. func TestPollRetiresThroughTheSearchWindow(t *testing.T) { f := &fakeIMAP{uids: []uint32{100, 101}, msgs: map[uint32]string{100: mail("a", "b"), 101: mail("c", "d")}} core := &fakeCore{} r := newTestReader(t, f, core, "") r.pollOnce(context.Background(), "secret") if r.state.high != 101 { t.Errorf("high = %d, want 101 — everything below the search window is unreachable", r.state.high) } if len(r.state.set) != 0 { t.Errorf("explicit set = %v, want empty", r.state.set) } }