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 }