package hub import ( "context" "errors" "strings" "testing" "time" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" ) // fakeReader is a read-only in-memory Reader. It deliberately has no write // method: /ask cannot change state through this interface. type fakeReader struct { counts []store.CategoryCount items []store.MessageListItem countErr error msgErr error countCalls int msgCalls int } func (f *fakeReader) CountByCategory(_ context.Context, _ string) ([]store.CategoryCount, error) { f.countCalls++ return f.counts, f.countErr } func (f *fakeReader) MessagesByCategory(_ context.Context, _ string, _ []string, _ int) ([]store.MessageListItem, error) { f.msgCalls++ return f.items, f.msgErr } func fixedNow() time.Time { return time.Date(2026, 10, 11, 8, 0, 0, 0, time.UTC) } func newTestAnswerer(r Reader) *Answerer { a := NewAnswerer(r) a.Now = fixedNow return a } func TestAnswerImportant(t *testing.T) { r := &fakeReader{ counts: []store.CategoryCount{ {Category: "Action", Count: 2}, {Category: "Important", Count: 1}, {Category: "Newsletter", Count: 40}, }, items: []store.MessageListItem{{ Message: store.Message{ID: 96, FromAddr: "Google ", Subject: "Security alert"}, Category: "Action", Importance: "high", }}, } resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{ Question: "anything important in my inbox?", Asker: "sam", }) if resp.Answer == nil { t.Fatalf("Answer = nil, reason = %q", resp.Reason) } if !strings.Contains(*resp.Answer, "3 emails need your attention") { t.Errorf("answer = %q, want it to mention 3 emails", *resp.Answer) } if !strings.Contains(*resp.Answer, "Security alert") { t.Errorf("answer = %q, want the top subject", *resp.Answer) } if got := resp.Data["count"]; got != 3 { t.Errorf("data.count = %v, want 3", got) } if resp.Expires == "" { t.Error("answered response has no expiry") } } func TestAnswerImportantNone(t *testing.T) { r := &fakeReader{counts: []store.CategoryCount{{Category: "Info", Count: 4}}} resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{ Question: "is anything urgent?", Asker: "sam", }) if resp.Answer == nil || !strings.Contains(*resp.Answer, "Nothing important") { t.Fatalf("answer = %v, want the empty-inbox message", resp.Answer) } } func TestAnswerCannotAnswerForOtherPerson(t *testing.T) { r := &fakeReader{} resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{ Question: "anything important?", Asker: "sam", Person: "harry", }) if resp.Answer != nil { t.Fatalf("Answer = %v, want nil", *resp.Answer) } if !strings.Contains(resp.Reason, "no data") { t.Errorf("reason = %q", resp.Reason) } } func TestAnswerRequiresAsker(t *testing.T) { r := &fakeReader{} resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{Question: "anything important?"}) if resp.Answer != nil || resp.Reason == "" { t.Fatalf("answer = %v reason = %q, want a reason", resp.Answer, resp.Reason) } } func TestAnswerDoesNotTrackUnread(t *testing.T) { r := &fakeReader{} resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{ Question: "how many unread emails do I have?", Asker: "sam", }) if resp.Answer != nil { t.Fatalf("Answer = %v, want nil", *resp.Answer) } if !strings.Contains(resp.Reason, "read") { t.Errorf("reason = %q, want it to mention read state", resp.Reason) } } func TestAnswerSpamAndNewsletter(t *testing.T) { r := &fakeReader{ counts: []store.CategoryCount{ {Category: "Spam", Count: 1}, {Category: "Newsletter", Count: 12}, }, items: []store.MessageListItem{{ Message: store.Message{ID: 5, FromAddr: "spammer@example.com", Subject: "Cheap pills"}, Category: "Spam", }}, } a := newTestAnswerer(r) spam := a.Answer(context.Background(), AskRequest{Question: "any spam?", Asker: "sam"}) if spam.Answer == nil || !strings.Contains(*spam.Answer, "1 spam") { t.Errorf("spam answer = %v", spam.Answer) } news := a.Answer(context.Background(), AskRequest{Question: "how many newsletters?", Asker: "sam"}) if news.Answer == nil || !strings.Contains(*news.Answer, "12 newsletters") { t.Errorf("newsletter answer = %v", news.Answer) } } func TestAnswerCountAndSummary(t *testing.T) { r := &fakeReader{counts: []store.CategoryCount{ {Category: "Action", Count: 19}, {Category: "Important", Count: 1}, {Category: "Newsletter", Count: 40}, {Category: "Auto-reply", Count: 34}, {Category: "Info", Count: 7}, {Category: "Spam", Count: 1}, }} a := newTestAnswerer(r) count := a.Answer(context.Background(), AskRequest{Question: "how many emails are in my inbox?", Asker: "sam"}) if count.Answer == nil || !strings.Contains(*count.Answer, "102 emails") { t.Errorf("count answer = %v", count.Answer) } sum := a.Answer(context.Background(), AskRequest{Question: "give me an overview", Asker: "sam"}) if sum.Answer == nil || !strings.Contains(*sum.Answer, "102 emails") { t.Errorf("summary answer = %v", sum.Answer) } } func TestAnswerUnknownQuestion(t *testing.T) { r := &fakeReader{} resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{ Question: "what time is it?", Asker: "sam", }) if resp.Answer != nil || resp.Reason == "" { t.Fatalf("answer = %v reason = %q, want a cannot-answer reply", resp.Answer, resp.Reason) } } func TestAnswerReadErrorIsReported(t *testing.T) { r := &fakeReader{countErr: errors.New("boom")} resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{Question: "anything important?", Asker: "sam"}) if resp.Answer != nil || !strings.Contains(resp.Reason, "could not be read") { t.Fatalf("answer = %v reason = %q", resp.Answer, resp.Reason) } } func TestSenderName(t *testing.T) { cases := map[string]string{ "Google ": "Google", `"api@minimaxi.com" `: "api@minimaxi.com", "": "no-reply@mail.minimax.io", "plain@example.com": "plain@example.com", } for in, want := range cases { if got := senderName(in); got != want { t.Errorf("senderName(%q) = %q, want %q", in, got, want) } } } func TestDefaultCapabilities(t *testing.T) { c := DefaultCapabilities() if c.Project != ProjectName { t.Errorf("project = %q, want %q", c.Project, ProjectName) } for _, topic := range []string{"email", "inbox", "mail"} { if !containsString(c.Topics, topic) { t.Errorf("topics missing %q", topic) } } if containsString(c.Topics, "unread") { t.Error("topics must not claim read/unread support") } } func containsString(list []string, v string) bool { for _, x := range list { if x == v { return true } } return false }