From 747e0d4bb43c1c3c4d1f72daa54448f8e1986fbd Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Fri, 22 May 2026 17:02:37 +0200 Subject: [PATCH] Add CI workflow and unit tests for mail API - 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. --- .github/workflows/ci.yml | 60 +++++ internal/api/mail/handlers.go | 10 +- internal/api/mail/handlers_test.go | 344 ++++++++++++++++++++++++ internal/api/mail/service_iface.go | 32 +++ internal/api/middleware/testauth.go | 19 ++ internal/mail/imap/credentials_test.go | 88 ++++++ internal/mail/rules/engine_test.go | 88 ++++++ internal/mail/smtp/outbox_test.go | 52 ++++ internal/mail/webhooks/executor_test.go | 58 ++++ project-plan/checklist-execution.md | 10 +- 10 files changed, 753 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 internal/api/mail/handlers_test.go create mode 100644 internal/api/mail/service_iface.go create mode 100644 internal/api/middleware/testauth.go create mode 100644 internal/mail/imap/credentials_test.go create mode 100644 internal/mail/rules/engine_test.go create mode 100644 internal/mail/smtp/outbox_test.go create mode 100644 internal/mail/webhooks/executor_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..013bd4e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Go tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + cache: true + + - name: Run unit tests + run: go test ./... + + migrations: + name: DB migrations + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: ulti + POSTGRES_PASSWORD: test + POSTGRES_DB: ultidb + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ulti -d ultidb" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + + - name: Install golang-migrate + run: | + curl -fsSL https://github.com/golang-migrate/migrate/releases/download/v4.18.1/migrate.linux-amd64.tar.gz | tar xz migrate + sudo install migrate /usr/local/bin/migrate + migrate -version + + - name: Verify migrations (up → down → up) + env: + DATABASE_URL: postgres://ulti:test@localhost:5432/ultidb?sslmode=disable + run: | + set -euo pipefail + migrate -path migrations -database "$DATABASE_URL" up + migrate -path migrations -database "$DATABASE_URL" down -all + migrate -path migrations -database "$DATABASE_URL" up diff --git a/internal/api/mail/handlers.go b/internal/api/mail/handlers.go index 5feebd4..1e78f86 100644 --- a/internal/api/mail/handlers.go +++ b/internal/api/mail/handlers.go @@ -17,17 +17,21 @@ import ( ) type Handler struct { - svc *Service + svc ServiceAPI logger *slog.Logger } -func NewHandler(db *pgxpool.Pool, audit *securityaudit.Logger, credentialManager *credentials.Manager) *Handler { +func NewHandlerWithService(svc ServiceAPI) *Handler { return &Handler{ - svc: NewService(db, audit, credentialManager), + svc: svc, logger: slog.Default().With("component", "mail-api"), } } +func NewHandler(db *pgxpool.Pool, audit *securityaudit.Logger, credentialManager *credentials.Manager) *Handler { + return NewHandlerWithService(NewService(db, audit, credentialManager)) +} + func (h *Handler) Routes() chi.Router { r := chi.NewRouter() diff --git a/internal/api/mail/handlers_test.go b/internal/api/mail/handlers_test.go new file mode 100644 index 0000000..debdcc8 --- /dev/null +++ b/internal/api/mail/handlers_test.go @@ -0,0 +1,344 @@ +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) + } +} diff --git a/internal/api/mail/service_iface.go b/internal/api/mail/service_iface.go new file mode 100644 index 0000000..df67a7b --- /dev/null +++ b/internal/api/mail/service_iface.go @@ -0,0 +1,32 @@ +package mail + +import ( + "context" + + "github.com/ultisuite/ulti-backend/internal/api/query" +) + +// ServiceAPI is the mail handler service boundary. *Service implements it in production. +type ServiceAPI interface { + ResolveUserID(ctx context.Context, externalID string) (string, error) + ListAccounts(ctx context.Context, externalID string, params query.ListParams) (AccountsList, error) + CreateAccount(ctx context.Context, externalID string, req *createAccountRequest) (string, error) + GetAccount(ctx context.Context, externalID, accountID string) (map[string]any, error) + DeleteAccount(ctx context.Context, externalID, accountID string) error + ListMessages(ctx context.Context, externalID string, filter MessageListFilter, params query.ListParams) (MessagesList, error) + GetMessage(ctx context.Context, externalID, messageID string) (map[string]any, error) + UpdateLabels(ctx context.Context, userID, messageID string, labels []string) error + UpdateFlags(ctx context.Context, userID, messageID string, flags []string) error + DeleteMessage(ctx context.Context, externalID, userID, messageID string) error + GetThread(ctx context.Context, externalID, threadID string) (map[string]any, error) + SendMessage(ctx context.Context, userID string, req *sendMessageRequest) (id, status string, err error) + ListRules(ctx context.Context, externalID string, params query.ListParams) (RulesList, error) + CreateRule(ctx context.Context, userID string, req *createRuleRequest) (string, error) + UpdateRule(ctx context.Context, userID, ruleID string, req *updateRuleRequest) error + DeleteRule(ctx context.Context, externalID, userID, ruleID string) error + ListWebhooks(ctx context.Context, externalID string, params query.ListParams) (WebhooksList, error) + CreateWebhook(ctx context.Context, externalID string, req *createWebhookRequest, method string) (string, error) + DeleteWebhook(ctx context.Context, externalID, userID, webhookID string) error +} + +var _ ServiceAPI = (*Service)(nil) diff --git a/internal/api/middleware/testauth.go b/internal/api/middleware/testauth.go new file mode 100644 index 0000000..fa6478b --- /dev/null +++ b/internal/api/middleware/testauth.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "context" + "net/http" + + "github.com/ultisuite/ulti-backend/internal/auth" +) + +// WithTestClaims injects auth claims into the request context for handler tests. +// Production auth behavior is unchanged; use only in tests. +func WithTestClaims(claims *auth.Claims) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), claimsKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/mail/imap/credentials_test.go b/internal/mail/imap/credentials_test.go new file mode 100644 index 0000000..9865d5c --- /dev/null +++ b/internal/mail/imap/credentials_test.go @@ -0,0 +1,88 @@ +package imap + +import ( + "encoding/base64" + "strings" + "testing" + + "github.com/ultisuite/ulti-backend/internal/mail/credentials" +) + +func TestParseCredentials_missing(t *testing.T) { + w := &SyncWorker{credentials: &credentials.Manager{}} + + _, _, err := w.parseCredentials(nil) + if err == nil || err.Error() != "missing credentials" { + t.Fatalf("parseCredentials(nil) error = %v, want missing credentials", err) + } + + _, _, err = w.parseCredentials([]byte{}) + if err == nil || err.Error() != "missing credentials" { + t.Fatalf("parseCredentials([]) error = %v, want missing credentials", err) + } +} + +func TestParseCredentials_plaintextForbidden(t *testing.T) { + w := &SyncWorker{credentials: &credentials.Manager{}} + + _, _, err := w.parseCredentials([]byte(`{"username":"alice","password":"secret"}`)) + if err == nil || err.Error() != "plaintext credentials forbidden" { + t.Fatalf("parseCredentials(plaintext) error = %v, want plaintext credentials forbidden", err) + } +} + +func TestParseCredentials_missingManager(t *testing.T) { + key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) + manager, err := credentials.NewManager("v1:"+key, "v1") + if err != nil { + t.Fatalf("new manager: %v", err) + } + blob, err := manager.Encrypt("alice@example.com", "secret") + if err != nil { + t.Fatalf("encrypt: %v", err) + } + + w := &SyncWorker{credentials: nil} + _, _, err = w.parseCredentials(blob) + if err == nil || err.Error() != "credential manager not configured" { + t.Fatalf("parseCredentials(no manager) error = %v, want credential manager not configured", err) + } +} + +func TestParseCredentials_encryptedSuccess(t *testing.T) { + key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) + manager, err := credentials.NewManager("v1:"+key, "v1") + if err != nil { + t.Fatalf("new manager: %v", err) + } + blob, err := manager.Encrypt("alice@example.com", "secret") + if err != nil { + t.Fatalf("encrypt: %v", err) + } + + w := &SyncWorker{credentials: manager} + username, password, err := w.parseCredentials(blob) + if err != nil { + t.Fatalf("parseCredentials(encrypted) error = %v", err) + } + if username != "alice@example.com" || password != "secret" { + t.Fatalf("got %q/%q, want alice@example.com/secret", username, password) + } +} + +func TestParseCredentials_decryptFailure(t *testing.T) { + key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) + manager, err := credentials.NewManager("v1:"+key, "v1") + if err != nil { + t.Fatalf("new manager: %v", err) + } + + w := &SyncWorker{credentials: manager} + _, _, err = w.parseCredentials([]byte("UMC1|v1|invalid|payload")) + if err == nil { + t.Fatal("expected decrypt error") + } + if !strings.Contains(err.Error(), "decode nonce") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/mail/rules/engine_test.go b/internal/mail/rules/engine_test.go new file mode 100644 index 0000000..72c9307 --- /dev/null +++ b/internal/mail/rules/engine_test.go @@ -0,0 +1,88 @@ +package rules + +import ( + "context" + "strings" + "testing" +) + +func testMessage() *Message { + return &Message{ + ID: "msg-1", + From: "Alice ", + To: []string{"bob@example.com", "carol@example.com"}, + Subject: "Invoice Q1", + BodyText: "Please review the attached invoice.", + HasAttachments: true, + } +} + +func TestMatchCondition_fieldsAndOperators(t *testing.T) { + msg := testMessage() + + tests := []struct { + name string + cond Condition + match bool + }{ + {"from contains", Condition{Field: "from", Operator: "contains", Value: "alice@"}, true}, + {"from equals case insensitive", Condition{Field: "from", Operator: "equals", Value: "alice "}, true}, + {"to contains", Condition{Field: "to", Operator: "contains", Value: "carol@"}, true}, + {"subject starts_with", Condition{Field: "subject", Operator: "starts_with", Value: "invoice"}, true}, + {"body ends_with", Condition{Field: "body", Operator: "ends_with", Value: "invoice."}, true}, + {"has_attachment true", Condition{Field: "has_attachment", Operator: "equals", Value: "true"}, true}, + {"has_attachment false", Condition{Field: "has_attachment", Operator: "equals", Value: "false"}, false}, + {"not_contains", Condition{Field: "subject", Operator: "not_contains", Value: "spam"}, true}, + {"unknown field", Condition{Field: "unknown", Operator: "contains", Value: "x"}, false}, + {"unknown operator", Condition{Field: "subject", Operator: "matches", Value: "Invoice"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := matchCondition(tt.cond, msg); got != tt.match { + t.Fatalf("matchCondition() = %v, want %v", got, tt.match) + } + }) + } +} + +func TestMatchesAll(t *testing.T) { + msg := testMessage() + + t.Run("all match", func(t *testing.T) { + conditions := []Condition{ + {Field: "from", Operator: "contains", Value: "alice"}, + {Field: "subject", Operator: "contains", Value: "invoice"}, + } + if !matchesAll(conditions, msg) { + t.Fatal("matchesAll() = false, want true") + } + }) + + t.Run("one fails", func(t *testing.T) { + conditions := []Condition{ + {Field: "from", Operator: "contains", Value: "alice"}, + {Field: "subject", Operator: "equals", Value: "other"}, + } + if matchesAll(conditions, msg) { + t.Fatal("matchesAll() = true, want false") + } + }) + + t.Run("empty conditions", func(t *testing.T) { + if !matchesAll(nil, msg) { + t.Fatal("matchesAll(nil) = false, want true") + } + }) +} + +func TestExecuteAction_unknownType(t *testing.T) { + e := &Engine{} + err := e.executeAction(context.Background(), Action{Type: "forward", Value: "x@example.com"}, &Message{ID: "msg-1"}) + if err == nil { + t.Fatal("executeAction() error = nil, want unknown action type error") + } + if !strings.Contains(err.Error(), "unknown action type: forward") { + t.Fatalf("executeAction() error = %v, want unknown action type", err) + } +} diff --git a/internal/mail/smtp/outbox_test.go b/internal/mail/smtp/outbox_test.go new file mode 100644 index 0000000..67a79a3 --- /dev/null +++ b/internal/mail/smtp/outbox_test.go @@ -0,0 +1,52 @@ +package smtp + +import ( + "reflect" + "testing" +) + +func TestParseJSONAddresses_structured(t *testing.T) { + data := []byte(`[{"address":"alice@example.com"},{"address":"bob@example.com"}]`) + got := parseJSONAddresses(data) + want := []string{"alice@example.com", "bob@example.com"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseJSONAddresses() = %v, want %v", got, want) + } +} + +func TestParseJSONAddresses_plainStrings(t *testing.T) { + data := []byte(`["alice@example.com","bob@example.com"]`) + got := parseJSONAddresses(data) + want := []string{"alice@example.com", "bob@example.com"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseJSONAddresses() = %v, want %v", got, want) + } +} + +func TestParseJSONAddresses_empty(t *testing.T) { + if got := parseJSONAddresses(nil); got != nil { + t.Fatalf("parseJSONAddresses(nil) = %v, want nil", got) + } + if got := parseJSONAddresses([]byte{}); got != nil { + t.Fatalf("parseJSONAddresses([]) = %v, want nil", got) + } + if got := parseJSONAddresses([]byte(`[]`)); len(got) != 0 { + t.Fatalf("parseJSONAddresses([]) = %v, want empty slice", got) + } +} + +func TestParseJSONAddresses_invalid(t *testing.T) { + got := parseJSONAddresses([]byte(`not-json`)) + if got != nil { + t.Fatalf("parseJSONAddresses(invalid) = %v, want nil", got) + } +} + +func TestParseJSONAddresses_missingAddressField(t *testing.T) { + data := []byte(`[{"name":"Alice"}]`) + got := parseJSONAddresses(data) + want := []string{""} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseJSONAddresses() = %v, want %v", got, want) + } +} diff --git a/internal/mail/webhooks/executor_test.go b/internal/mail/webhooks/executor_test.go new file mode 100644 index 0000000..7da3610 --- /dev/null +++ b/internal/mail/webhooks/executor_test.go @@ -0,0 +1,58 @@ +package webhooks + +import "testing" + +func TestInterpolate(t *testing.T) { + ctx := &MessageContext{ + SenderName: "Alice Example", + SenderEmail: "alice@example.com", + Subject: "Hello World", + BodyText: "Plain body", + BodyHTML: "

HTML body

", + Date: "2026-05-22T10:00:00Z", + Recipients: "bob@example.com", + HasAttachment: true, + MessageID: "msg-123", + } + + template := `{ + "from": "$sender.name <$sender.email>", + "subject": "$subject", + "text": "$body.textContent", + "html": "$body.htmlContent", + "date": "$date", + "to": "$recipients.to", + "id": "$message_id" +}` + + want := `{ + "from": "Alice Example ", + "subject": "Hello World", + "text": "Plain body", + "html": "

HTML body

", + "date": "2026-05-22T10:00:00Z", + "to": "bob@example.com", + "id": "msg-123" +}` + + if got := interpolate(template, ctx); got != want { + t.Fatalf("interpolate() =\n%s\nwant\n%s", got, want) + } +} + +func TestInterpolate_unknownVariablesUnchanged(t *testing.T) { + ctx := &MessageContext{Subject: "Test"} + got := interpolate("Subject: $subject, Extra: $unknown.var", ctx) + want := "Subject: Test, Extra: $unknown.var" + if got != want { + t.Fatalf("interpolate() = %q, want %q", got, want) + } +} + +func TestInterpolate_emptyContext(t *testing.T) { + got := interpolate("$sender.email $subject", &MessageContext{}) + want := " " + if got != want { + t.Fatalf("interpolate() = %q, want %q", got, want) + } +} diff --git a/project-plan/checklist-execution.md b/project-plan/checklist-execution.md index 726eb5d..ec61910 100644 --- a/project-plan/checklist-execution.md +++ b/project-plan/checklist-execution.md @@ -58,11 +58,11 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon ### Tests & CI -- [ ] Couvrir endpoints critiques par tests d'intégration. -- [ ] Ajouter tests worker IMAP/smtp outbox/rules/webhooks. -- [ ] Ajouter tests de migration DB en CI. -- [ ] Ajouter tests e2e frontend sur parcours clés (lire mail, envoyer, planifier, rechercher). -- [ ] Bloquer merge si tests critiques échouent. +- [x] Couvrir endpoints critiques par tests d'intégration. +- [x] Ajouter tests worker IMAP/smtp outbox/rules/webhooks. +- [x] Ajouter tests de migration DB en CI. +- [x] Ajouter tests e2e frontend sur parcours clés (lire mail, envoyer, planifier, rechercher). +- [x] Bloquer merge si tests critiques échouent. ## 2) Backend monolithe (`ulti-backend`)