- internal/hub: project side of the frozen ask-contract. GET /capabilities, read-only POST /ask answerer (no LLM, <3s), and the outbound POST /notify client. - internal/apprise: daily-summary delivery through the house Apprise API. - internal/summary: deterministic plain-text daily summary builder. - internal/store: CountByCategory, MessagesByCategory, and summary CRUD. - internal/server: /capabilities and /ask (bearer-token protected), plus the Reports screen (/reports, build, send, delete). - main.go: -summary, -summary-send, and -notify CLI entry points for n8n. Co-Authored-By: Paperclip <noreply@paperclip.ing>
225 lines
6.7 KiB
Go
225 lines
6.7 KiB
Go
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 <no-reply@accounts.google.com>", 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 <no-reply@accounts.google.com>": "Google",
|
|
`"api@minimaxi.com" <api@minimaxi.com>`: "api@minimaxi.com",
|
|
"<no-reply@mail.minimax.io>": "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
|
|
}
|