feat: mail service
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user