Files
sgmail-skill/internal/service/service_test.go
2026-06-06 22:28:36 +08:00

211 lines
5.3 KiB
Go

package service
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"internal-mail-skill/internal/db"
"internal-mail-skill/internal/model"
mailsmtp "internal-mail-skill/internal/smtp"
)
func TestThreadAssemblyLinksReferences(t *testing.T) {
ctx := context.Background()
store, err := db.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.Migrate(ctx); err != nil {
t.Fatal(err)
}
svc := New(store, nil, nil, "default")
root, err := svc.SaveParsedEmail(ctx, SaveEmailInput{
POP3UID: "uid-root",
Parsed: model.ParsedEmail{
MessageIDHeader: "<root@example.com>",
Subject: "Root",
SenderEmail: "a@example.com",
},
})
if err != nil {
t.Fatal(err)
}
reply, err := svc.SaveParsedEmail(ctx, SaveEmailInput{
POP3UID: "uid-reply",
Parsed: model.ParsedEmail{
MessageIDHeader: "<reply@example.com>",
InReplyTo: "<root@example.com>",
References: "<root@example.com>",
Subject: "Re: Root",
SenderEmail: "b@example.com",
},
})
if err != nil {
t.Fatal(err)
}
thread, err := svc.GetThread(ctx, reply)
if err != nil {
t.Fatal(err)
}
if len(thread) != 2 || thread[0].ID != root || thread[1].ID != reply {
t.Fatalf("thread = %#v", thread)
}
}
func TestSearchMarkLabelsAndDraftLifecycle(t *testing.T) {
ctx := context.Background()
store, err := db.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.Migrate(ctx); err != nil {
t.Fatal(err)
}
svc := New(store, nil, nil, "default")
emailID, err := svc.SaveParsedEmail(ctx, SaveEmailInput{
POP3UID: "uid-1",
Parsed: model.ParsedEmail{
MessageIDHeader: "<m@example.com>",
Subject: "Quarterly Plan",
BodyText: "Please review",
SenderEmail: "boss@example.com",
},
})
if err != nil {
t.Fatal(err)
}
if err := svc.MarkEmail(ctx, emailID, MarkInput{Read: boolPtr(true), Processed: boolPtr(true)}); err != nil {
t.Fatal(err)
}
label, err := svc.CreateLabel(ctx, "work", "#336699")
if err != nil {
t.Fatal(err)
}
if err := svc.ApplyLabels(ctx, []int64{emailID}, []int64{label.ID}, "add"); err != nil {
t.Fatal(err)
}
results, err := svc.SearchEmails(ctx, SearchFilters{Q: "Quarterly", Read: boolPtr(true), Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Labels[0].Name != "work" {
t.Fatalf("search results = %#v", results)
}
draft, err := svc.CreateDraft(ctx, DraftInput{To: []string{"x@example.com"}, Subject: "Draft", Body: "Hi"})
if err != nil {
t.Fatal(err)
}
updated, err := svc.UpdateDraft(ctx, draft.ID, DraftInput{To: []string{"x@example.com"}, Subject: "Updated", Body: "Hello"})
if err != nil {
t.Fatal(err)
}
if updated.Subject != "Updated" {
t.Fatalf("updated subject = %q", updated.Subject)
}
}
func TestSaveParsedEmailRollsBackWhenAttachmentStorageFails(t *testing.T) {
ctx := context.Background()
store, err := db.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.Migrate(ctx); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
blocker := filepath.Join(dir, "attachments-blocker")
if err := os.WriteFile(blocker, []byte("not a directory"), 0600); err != nil {
t.Fatal(err)
}
svc := NewWithOptions(store, nil, nil, "default", Options{AttachmentDir: blocker})
_, err = svc.SaveParsedEmail(ctx, SaveEmailInput{
POP3UID: "uid-broken-attachment",
Parsed: model.ParsedEmail{
MessageIDHeader: "<broken@example.com>",
Subject: "Broken Attachment",
SenderEmail: "a@example.com",
Attachments: []model.ParsedAttachment{{
Filename: "note.txt",
ContentType: "text/plain",
SizeBytes: 4,
Data: []byte("nope"),
}},
},
})
if err == nil {
t.Fatal("SaveParsedEmail() error = nil, want attachment storage failure")
}
results, err := svc.SearchEmails(ctx, SearchFilters{Q: "Broken Attachment", Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(results) != 0 {
t.Fatalf("failed save left searchable message: %#v", results)
}
}
func TestSendDraftIsIdempotentAfterSuccess(t *testing.T) {
ctx := context.Background()
store, err := db.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.Migrate(ctx); err != nil {
t.Fatal(err)
}
mailer := &fakeMailer{}
svc := NewWithOptions(store, nil, mailer, "default", Options{FromAddress: "sender@example.com"})
draft, err := svc.CreateDraft(ctx, DraftInput{To: []string{"x@example.com"}, Subject: "Once", Body: "hello"})
if err != nil {
t.Fatal(err)
}
first, err := svc.SendDraft(ctx, draft.ID)
if err != nil {
t.Fatal(err)
}
second, err := svc.SendDraft(ctx, draft.ID)
if err != nil {
t.Fatal(err)
}
if mailer.calls != 1 {
t.Fatalf("mailer calls = %d, want 1", mailer.calls)
}
if second.SentMessage.ID != first.SentMessage.ID {
t.Fatalf("second send message id = %d, want %d", second.SentMessage.ID, first.SentMessage.ID)
}
}
func boolPtr(v bool) *bool { return &v }
type fakeMailer struct {
calls int
err error
}
func (f *fakeMailer) Send(ctx context.Context, req mailsmtp.SendRequest) (mailsmtp.SendResult, error) {
f.calls++
if f.err != nil {
return mailsmtp.SendResult{}, f.err
}
if req.FromAddress == "" {
return mailsmtp.SendResult{}, errors.New("from required")
}
return mailsmtp.SendResult{MessageIDHeader: "<sent@example.com>", SentAt: time.Now().UTC()}, nil
}