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) } }