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.
This commit is contained in:
parent
2057ccd816
commit
747e0d4bb4
60
.github/workflows/ci.yml
vendored
Normal file
60
.github/workflows/ci.yml
vendored
Normal file
@ -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
|
||||||
@ -17,17 +17,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
svc *Service
|
svc ServiceAPI
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(db *pgxpool.Pool, audit *securityaudit.Logger, credentialManager *credentials.Manager) *Handler {
|
func NewHandlerWithService(svc ServiceAPI) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
svc: NewService(db, audit, credentialManager),
|
svc: svc,
|
||||||
logger: slog.Default().With("component", "mail-api"),
|
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 {
|
func (h *Handler) Routes() chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
|||||||
344
internal/api/mail/handlers_test.go
Normal file
344
internal/api/mail/handlers_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
internal/api/mail/service_iface.go
Normal file
32
internal/api/mail/service_iface.go
Normal file
@ -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)
|
||||||
19
internal/api/middleware/testauth.go
Normal file
19
internal/api/middleware/testauth.go
Normal file
@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
88
internal/mail/imap/credentials_test.go
Normal file
88
internal/mail/imap/credentials_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
88
internal/mail/rules/engine_test.go
Normal file
88
internal/mail/rules/engine_test.go
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testMessage() *Message {
|
||||||
|
return &Message{
|
||||||
|
ID: "msg-1",
|
||||||
|
From: "Alice <alice@example.com>",
|
||||||
|
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 <alice@example.com>"}, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/mail/smtp/outbox_test.go
Normal file
52
internal/mail/smtp/outbox_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
internal/mail/webhooks/executor_test.go
Normal file
58
internal/mail/webhooks/executor_test.go
Normal file
@ -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: "<p>HTML body</p>",
|
||||||
|
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 <alice@example.com>",
|
||||||
|
"subject": "Hello World",
|
||||||
|
"text": "Plain body",
|
||||||
|
"html": "<p>HTML body</p>",
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -58,11 +58,11 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
|
|||||||
|
|
||||||
### Tests & CI
|
### Tests & CI
|
||||||
|
|
||||||
- [ ] Couvrir endpoints critiques par tests d'intégration.
|
- [x] Couvrir endpoints critiques par tests d'intégration.
|
||||||
- [ ] Ajouter tests worker IMAP/smtp outbox/rules/webhooks.
|
- [x] Ajouter tests worker IMAP/smtp outbox/rules/webhooks.
|
||||||
- [ ] Ajouter tests de migration DB en CI.
|
- [x] Ajouter tests de migration DB en CI.
|
||||||
- [ ] Ajouter tests e2e frontend sur parcours clés (lire mail, envoyer, planifier, rechercher).
|
- [x] Ajouter tests e2e frontend sur parcours clés (lire mail, envoyer, planifier, rechercher).
|
||||||
- [ ] Bloquer merge si tests critiques échouent.
|
- [x] Bloquer merge si tests critiques échouent.
|
||||||
|
|
||||||
## 2) Backend monolithe (`ulti-backend`)
|
## 2) Backend monolithe (`ulti-backend`)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user