- Created a CI workflow in `.github/workflows/ci.yml` to run Go tests and verify database migrations. - Added unit tests for the mail API in `internal/api/mail/handlers_test.go`, covering message listing, retrieval, sending, and label updating. - Introduced a service interface for the mail handler in `internal/api/mail/service_iface.go`. - Updated mail handler initialization to accept a service API in `internal/api/mail/handlers.go`. - Implemented test authentication middleware for testing purposes in `internal/api/middleware/testauth.go`. - Added various test cases for IMAP and SMTP functionalities, ensuring robust error handling and validation. - Enhanced project documentation with checklist updates for testing and CI integration.
345 lines
9.6 KiB
Go
345 lines
9.6 KiB
Go
package mail
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
|
)
|
|
|
|
const (
|
|
testExternalID = "ext-user-1"
|
|
testUserID = "user-uuid-1"
|
|
)
|
|
|
|
type fakeMailService struct {
|
|
messages map[string]map[string]any
|
|
deleted map[string]bool
|
|
sent []sendMessageRequest
|
|
}
|
|
|
|
func newFakeMailService() *fakeMailService {
|
|
return &fakeMailService{
|
|
messages: map[string]map[string]any{
|
|
"msg-1": {
|
|
"id": "msg-1", "subject": "Hello", "snippet": "Preview text",
|
|
"flags": []string{"unread"}, "labels": []string{"inbox"},
|
|
},
|
|
},
|
|
deleted: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
func (f *fakeMailService) ResolveUserID(_ context.Context, externalID string) (string, error) {
|
|
if externalID != testExternalID {
|
|
return "", ErrUserNotProvisioned
|
|
}
|
|
return testUserID, nil
|
|
}
|
|
|
|
func (f *fakeMailService) ListMessages(_ context.Context, externalID string, _ MessageListFilter, params query.ListParams) (MessagesList, error) {
|
|
if externalID != testExternalID {
|
|
return MessagesList{}, ErrUserNotProvisioned
|
|
}
|
|
msgs := make([]map[string]any, 0, len(f.messages))
|
|
for id, msg := range f.messages {
|
|
if f.deleted[id] {
|
|
continue
|
|
}
|
|
msgs = append(msgs, msg)
|
|
}
|
|
total := int64(len(msgs))
|
|
return MessagesList{
|
|
Messages: msgs,
|
|
Page: params.Page,
|
|
Pagination: params.Meta(&total),
|
|
}, nil
|
|
}
|
|
|
|
func (f *fakeMailService) GetMessage(_ context.Context, externalID, messageID string) (map[string]any, error) {
|
|
if externalID != testExternalID {
|
|
return nil, ErrUserNotProvisioned
|
|
}
|
|
if f.deleted[messageID] {
|
|
return nil, ErrNotFound
|
|
}
|
|
msg, ok := f.messages[messageID]
|
|
if !ok {
|
|
return nil, ErrNotFound
|
|
}
|
|
return msg, nil
|
|
}
|
|
|
|
func (f *fakeMailService) UpdateLabels(_ context.Context, userID, messageID string, labels []string) error {
|
|
if userID != testUserID {
|
|
return ErrNotFound
|
|
}
|
|
if f.deleted[messageID] {
|
|
return ErrNotFound
|
|
}
|
|
msg, ok := f.messages[messageID]
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
msg["labels"] = labels
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeMailService) UpdateFlags(_ context.Context, userID, messageID string, flags []string) error {
|
|
if userID != testUserID {
|
|
return ErrNotFound
|
|
}
|
|
if f.deleted[messageID] {
|
|
return ErrNotFound
|
|
}
|
|
msg, ok := f.messages[messageID]
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
msg["flags"] = flags
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeMailService) DeleteMessage(_ context.Context, externalID, userID, messageID string) error {
|
|
if externalID != testExternalID || userID != testUserID {
|
|
return ErrNotFound
|
|
}
|
|
if _, ok := f.messages[messageID]; !ok || f.deleted[messageID] {
|
|
return ErrNotFound
|
|
}
|
|
f.deleted[messageID] = true
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeMailService) SendMessage(_ context.Context, userID string, req *sendMessageRequest) (string, string, error) {
|
|
if userID != testUserID {
|
|
return "", "", ErrAccountNotFound
|
|
}
|
|
f.sent = append(f.sent, *req)
|
|
return "outbox-1", "queued", nil
|
|
}
|
|
|
|
func (f *fakeMailService) ListAccounts(context.Context, string, query.ListParams) (AccountsList, error) {
|
|
return AccountsList{}, nil
|
|
}
|
|
func (f *fakeMailService) CreateAccount(context.Context, string, *createAccountRequest) (string, error) {
|
|
return "", nil
|
|
}
|
|
func (f *fakeMailService) GetAccount(context.Context, string, string) (map[string]any, error) {
|
|
return nil, ErrNotFound
|
|
}
|
|
func (f *fakeMailService) DeleteAccount(context.Context, string, string) error { return nil }
|
|
func (f *fakeMailService) GetThread(context.Context, string, string) (map[string]any, error) {
|
|
return map[string]any{"messages": []any{}}, nil
|
|
}
|
|
func (f *fakeMailService) ListRules(context.Context, string, query.ListParams) (RulesList, error) {
|
|
return RulesList{}, nil
|
|
}
|
|
func (f *fakeMailService) CreateRule(context.Context, string, *createRuleRequest) (string, error) {
|
|
return "", nil
|
|
}
|
|
func (f *fakeMailService) UpdateRule(context.Context, string, string, *updateRuleRequest) error {
|
|
return nil
|
|
}
|
|
func (f *fakeMailService) DeleteRule(context.Context, string, string, string) error { return nil }
|
|
func (f *fakeMailService) ListWebhooks(context.Context, string, query.ListParams) (WebhooksList, error) {
|
|
return WebhooksList{}, nil
|
|
}
|
|
func (f *fakeMailService) CreateWebhook(context.Context, string, *createWebhookRequest, string) (string, error) {
|
|
return "", nil
|
|
}
|
|
func (f *fakeMailService) DeleteWebhook(context.Context, string, string, string) error { return nil }
|
|
|
|
func newTestMailRouter(svc ServiceAPI) http.Handler {
|
|
h := NewHandlerWithService(svc)
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.WithTestClaims(&auth.Claims{
|
|
Sub: testExternalID,
|
|
Email: "user@example.com",
|
|
}))
|
|
r.Mount("/", h.Routes())
|
|
return r
|
|
}
|
|
|
|
func TestListMessages(t *testing.T) {
|
|
svc := newFakeMailService()
|
|
router := newTestMailRouter(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/messages", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
|
|
var body MessagesList
|
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if len(body.Messages) != 1 {
|
|
t.Fatalf("messages len = %d, want 1", len(body.Messages))
|
|
}
|
|
if body.Messages[0]["id"] != "msg-1" {
|
|
t.Fatalf("message id = %v", body.Messages[0]["id"])
|
|
}
|
|
}
|
|
|
|
func TestGetMessage(t *testing.T) {
|
|
svc := newFakeMailService()
|
|
router := newTestMailRouter(svc)
|
|
|
|
t.Run("found", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/messages/msg-1", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if body["id"] != "msg-1" {
|
|
t.Fatalf("id = %v", body["id"])
|
|
}
|
|
})
|
|
|
|
t.Run("not found", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/messages/missing", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSendMessage(t *testing.T) {
|
|
svc := newFakeMailService()
|
|
router := newTestMailRouter(svc)
|
|
|
|
payload := map[string]any{
|
|
"account_id": "acc-1",
|
|
"to": []string{"recipient@example.com"},
|
|
"subject": "Test subject",
|
|
"body_text": "Hello world",
|
|
}
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("marshal payload: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/send", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusAccepted, rec.Body.String())
|
|
}
|
|
|
|
var resp map[string]string
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if resp["id"] != "outbox-1" || resp["status"] != "queued" {
|
|
t.Fatalf("response = %#v", resp)
|
|
}
|
|
if len(svc.sent) != 1 {
|
|
t.Fatalf("sent count = %d, want 1", len(svc.sent))
|
|
}
|
|
if svc.sent[0].Subject != "Test subject" {
|
|
t.Fatalf("sent subject = %q", svc.sent[0].Subject)
|
|
}
|
|
}
|
|
|
|
func TestUpdateLabels(t *testing.T) {
|
|
svc := newFakeMailService()
|
|
router := newTestMailRouter(svc)
|
|
|
|
body, err := json.Marshal(map[string]any{"labels": []string{"work", "important"}})
|
|
if err != nil {
|
|
t.Fatalf("marshal payload: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/messages/msg-1/labels", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusNoContent, rec.Body.String())
|
|
}
|
|
|
|
labels, ok := svc.messages["msg-1"]["labels"].([]string)
|
|
if !ok {
|
|
t.Fatalf("labels type = %T", svc.messages["msg-1"]["labels"])
|
|
}
|
|
if len(labels) != 2 || labels[0] != "work" || labels[1] != "important" {
|
|
t.Fatalf("labels = %#v", labels)
|
|
}
|
|
}
|
|
|
|
func TestUpdateFlags(t *testing.T) {
|
|
svc := newFakeMailService()
|
|
router := newTestMailRouter(svc)
|
|
|
|
body, err := json.Marshal(map[string]any{"flags": []string{"read", "starred"}})
|
|
if err != nil {
|
|
t.Fatalf("marshal payload: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/messages/msg-1/flags", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusNoContent, rec.Body.String())
|
|
}
|
|
|
|
flags, ok := svc.messages["msg-1"]["flags"].([]string)
|
|
if !ok {
|
|
t.Fatalf("flags type = %T", svc.messages["msg-1"]["flags"])
|
|
}
|
|
if len(flags) != 2 || flags[0] != "read" || flags[1] != "starred" {
|
|
t.Fatalf("flags = %#v", flags)
|
|
}
|
|
}
|
|
|
|
func TestDeleteMessage(t *testing.T) {
|
|
svc := newFakeMailService()
|
|
router := newTestMailRouter(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/messages/msg-1", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusNoContent, rec.Body.String())
|
|
}
|
|
if !svc.deleted["msg-1"] {
|
|
t.Fatal("message was not marked deleted")
|
|
}
|
|
|
|
// Subsequent get returns 404.
|
|
req = httptest.NewRequest(http.MethodGet, "/messages/msg-1", nil)
|
|
rec = httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("get after delete status = %d, want %d", rec.Code, http.StatusNotFound)
|
|
}
|
|
}
|