314 lines
11 KiB
Go
314 lines
11 KiB
Go
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
|
|
}
|