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: "", 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: "", InReplyTo: "", References: "", 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: "", 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: "", 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: "", SentAt: time.Now().UTC()}, nil }