feat: mail service

This commit is contained in:
Ilia Denisov
2026-04-17 18:39:16 +02:00
committed by GitHub
parent 23ffcb7535
commit 5b7593e6f6
183 changed files with 31215 additions and 248 deletions
@@ -0,0 +1,313 @@
package internalhttp
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/mail/internal/domain/attempt"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/service/getdelivery"
"galaxy/mail/internal/service/listattempts"
"galaxy/mail/internal/service/listdeliveries"
"galaxy/mail/internal/service/resenddelivery"
"github.com/stretchr/testify/require"
)
func TestOperatorHandlersReturnSuccessResponses(t *testing.T) {
t.Parallel()
listDelivery := validOperatorDelivery("delivery-list", deliverydomain.StatusSent)
getDeliveryRecord := validOperatorDelivery("delivery-get", deliverydomain.StatusDeadLetter)
deadLetter := validOperatorDeadLetter(getDeliveryRecord.DeliveryID)
attemptRecord := validOperatorAttempt(getDeliveryRecord.DeliveryID, 1, attempt.StatusProviderRejected)
handler := newHandler(Dependencies{
Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
OperatorRequestTimeout: time.Second,
ListDeliveries: listDeliveriesFunc(func(context.Context, listdeliveries.Input) (listdeliveries.Result, error) {
return listdeliveries.Result{
Items: []deliverydomain.Delivery{listDelivery},
NextCursor: &listdeliveries.Cursor{
CreatedAt: listDelivery.CreatedAt,
DeliveryID: listDelivery.DeliveryID,
},
}, nil
}),
GetDelivery: getDeliveryFunc(func(context.Context, getdelivery.Input) (getdelivery.Result, error) {
return getdelivery.Result{
Delivery: getDeliveryRecord,
DeadLetter: &deadLetter,
}, nil
}),
ListAttempts: listAttemptsFunc(func(context.Context, listattempts.Input) (listattempts.Result, error) {
return listattempts.Result{
Delivery: getDeliveryRecord,
Attempts: []attempt.Attempt{attemptRecord},
}, nil
}),
ResendDelivery: resendDeliveryFunc(func(context.Context, resenddelivery.Input) (resenddelivery.Result, error) {
return resenddelivery.Result{DeliveryID: common.DeliveryID("delivery-clone")}, nil
}),
})
t.Run("list", func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, DeliveriesPath+"?limit=1", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
require.Equal(t, http.StatusOK, response.Code)
var payload DeliveryListResponse
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
require.Len(t, payload.Items, 1)
require.Equal(t, "delivery-list", payload.Items[0].DeliveryID)
require.NotEmpty(t, payload.NextCursor)
})
t.Run("get", func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/deliveries/delivery-get", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
require.Equal(t, http.StatusOK, response.Code)
var payload DeliveryDetailResponse
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
require.Equal(t, "delivery-get", payload.DeliveryID)
require.NotNil(t, payload.DeadLetter)
})
t.Run("attempts", func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/deliveries/delivery-get/attempts", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
require.Equal(t, http.StatusOK, response.Code)
var payload DeliveryAttemptsResponse
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
require.Len(t, payload.Items, 1)
require.Equal(t, 1, payload.Items[0].AttemptNo)
})
t.Run("resend", func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/internal/deliveries/delivery-get/resend", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
require.Equal(t, http.StatusOK, response.Code)
var payload DeliveryResendResponse
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
require.Equal(t, "delivery-clone", payload.DeliveryID)
})
}
func TestOperatorHandlersMapErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
method string
path string
deps Dependencies
wantStatus int
wantCode string
}{
{
name: "list bad request",
method: http.MethodGet,
path: DeliveriesPath + "?limit=0",
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), ListDeliveries: listDeliveriesFunc(func(context.Context, listdeliveries.Input) (listdeliveries.Result, error) {
return listdeliveries.Result{}, nil
})},
wantStatus: http.StatusBadRequest,
wantCode: ErrorCodeInvalidRequest,
},
{
name: "get not found",
method: http.MethodGet,
path: "/api/v1/internal/deliveries/missing",
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), GetDelivery: getDeliveryFunc(func(context.Context, getdelivery.Input) (getdelivery.Result, error) {
return getdelivery.Result{}, getdelivery.ErrNotFound
})},
wantStatus: http.StatusNotFound,
wantCode: ErrorCodeDeliveryNotFound,
},
{
name: "attempts unavailable",
method: http.MethodGet,
path: "/api/v1/internal/deliveries/missing/attempts",
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), ListAttempts: listAttemptsFunc(func(context.Context, listattempts.Input) (listattempts.Result, error) {
return listattempts.Result{}, listattempts.ErrServiceUnavailable
})},
wantStatus: http.StatusServiceUnavailable,
wantCode: ErrorCodeServiceUnavailable,
},
{
name: "resend not allowed",
method: http.MethodPost,
path: "/api/v1/internal/deliveries/missing/resend",
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), ResendDelivery: resendDeliveryFunc(func(context.Context, resenddelivery.Input) (resenddelivery.Result, error) {
return resenddelivery.Result{}, resenddelivery.ErrNotAllowed
})},
wantStatus: http.StatusConflict,
wantCode: ErrorCodeResendNotAllowed,
},
{
name: "resend internal error",
method: http.MethodPost,
path: "/api/v1/internal/deliveries/missing/resend",
deps: Dependencies{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), ResendDelivery: resendDeliveryFunc(func(context.Context, resenddelivery.Input) (resenddelivery.Result, error) {
return resenddelivery.Result{}, errors.New("boom")
})},
wantStatus: http.StatusInternalServerError,
wantCode: ErrorCodeInternalError,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tt.deps.OperatorRequestTimeout = time.Second
handler := newHandler(tt.deps)
request := httptest.NewRequest(tt.method, tt.path, nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
require.Equal(t, tt.wantStatus, response.Code)
var payload ErrorResponse
require.NoError(t, json.NewDecoder(response.Body).Decode(&payload))
require.Equal(t, tt.wantCode, payload.Error.Code)
})
}
}
func TestOperatorHandlersApplyRequestTimeout(t *testing.T) {
t.Parallel()
deadlineObserved := make(chan struct{}, 1)
handler := newHandler(Dependencies{
Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
OperatorRequestTimeout: 50 * time.Millisecond,
ListDeliveries: listDeliveriesFunc(func(ctx context.Context, input listdeliveries.Input) (listdeliveries.Result, error) {
_ = input
if _, ok := ctx.Deadline(); ok {
deadlineObserved <- struct{}{}
}
return listdeliveries.Result{}, nil
}),
})
request := httptest.NewRequest(http.MethodGet, DeliveriesPath, nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
require.Equal(t, http.StatusOK, response.Code)
select {
case <-deadlineObserved:
default:
t.Fatal("expected operator handler to apply request timeout")
}
}
type listDeliveriesFunc func(context.Context, listdeliveries.Input) (listdeliveries.Result, error)
func (fn listDeliveriesFunc) Execute(ctx context.Context, input listdeliveries.Input) (listdeliveries.Result, error) {
return fn(ctx, input)
}
type getDeliveryFunc func(context.Context, getdelivery.Input) (getdelivery.Result, error)
func (fn getDeliveryFunc) Execute(ctx context.Context, input getdelivery.Input) (getdelivery.Result, error) {
return fn(ctx, input)
}
type listAttemptsFunc func(context.Context, listattempts.Input) (listattempts.Result, error)
func (fn listAttemptsFunc) Execute(ctx context.Context, input listattempts.Input) (listattempts.Result, error) {
return fn(ctx, input)
}
type resendDeliveryFunc func(context.Context, resenddelivery.Input) (resenddelivery.Result, error)
func (fn resendDeliveryFunc) Execute(ctx context.Context, input resenddelivery.Input) (resenddelivery.Result, error) {
return fn(ctx, input)
}
func validOperatorDelivery(id string, status deliverydomain.Status) deliverydomain.Delivery {
createdAt := time.Unix(1_775_122_000, 0).UTC()
updatedAt := createdAt.Add(time.Minute)
record := deliverydomain.Delivery{
DeliveryID: common.DeliveryID(id),
Source: deliverydomain.SourceNotification,
PayloadMode: deliverydomain.PayloadModeRendered,
Envelope: deliverydomain.Envelope{To: []common.Email{common.Email("pilot@example.com")}},
Content: deliverydomain.Content{Subject: "Turn ready", TextBody: "Turn ready"},
IdempotencyKey: common.IdempotencyKey("notification:" + id),
Status: status,
AttemptCount: 1,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
switch status {
case deliverydomain.StatusSent:
sentAt := updatedAt
record.SentAt = &sentAt
record.LastAttemptStatus = attempt.StatusProviderAccepted
case deliverydomain.StatusDeadLetter:
deadLetteredAt := updatedAt
record.DeadLetteredAt = &deadLetteredAt
record.LastAttemptStatus = attempt.StatusTimedOut
}
if err := record.Validate(); err != nil {
panic(err)
}
return record
}
func validOperatorDeadLetter(deliveryID common.DeliveryID) deliverydomain.DeadLetterEntry {
entry := deliverydomain.DeadLetterEntry{
DeliveryID: deliveryID,
FinalAttemptNo: 1,
FailureClassification: "retry_exhausted",
ProviderSummary: "smtp timeout",
CreatedAt: time.Unix(1_775_122_100, 0).UTC(),
RecoveryHint: "check SMTP connectivity",
}
if err := entry.Validate(); err != nil {
panic(err)
}
return entry
}
func validOperatorAttempt(deliveryID common.DeliveryID, attemptNo int, status attempt.Status) attempt.Attempt {
scheduledFor := time.Unix(1_775_122_050, 0).UTC()
startedAt := scheduledFor.Add(time.Second)
finishedAt := startedAt.Add(time.Second)
record := attempt.Attempt{
DeliveryID: deliveryID,
AttemptNo: attemptNo,
ScheduledFor: scheduledFor,
StartedAt: &startedAt,
FinishedAt: &finishedAt,
Status: status,
}
if err := record.Validate(); err != nil {
panic(err)
}
return record
}