package acceptgenericdelivery import ( "bytes" "context" "encoding/base64" "errors" "log/slog" "testing" "time" "galaxy/mail/internal/api/streamcommand" "galaxy/mail/internal/domain/attempt" "galaxy/mail/internal/domain/common" deliverydomain "galaxy/mail/internal/domain/delivery" "galaxy/mail/internal/domain/idempotency" "github.com/stretchr/testify/require" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" ) func TestServiceExecuteAcceptsRenderedDelivery(t *testing.T) { t.Parallel() store := &stubStore{} telemetry := &stubTelemetry{} service := newTestService(t, Config{ Store: store, Clock: stubClock{now: fixedNow()}, Telemetry: telemetry, IdempotencyTTL: 7 * 24 * time.Hour, }) result, err := service.Execute(context.Background(), validRenderedCommand(t)) require.NoError(t, err) require.Equal(t, Result{Outcome: OutcomeAccepted}, result) require.Len(t, store.createInputs, 1) require.Equal(t, deliverydomain.StatusQueued, store.createInputs[0].Delivery.Status) require.Equal(t, deliverydomain.PayloadModeRendered, store.createInputs[0].Delivery.PayloadMode) require.Equal(t, "Turn ready", store.createInputs[0].Delivery.Content.Subject) require.NotNil(t, store.createInputs[0].DeliveryPayload) require.Equal(t, []string{"accepted"}, telemetry.outcomes) require.Equal(t, 1, telemetry.accepted) require.Equal(t, []string{"notification:queued"}, telemetry.statuses) } func TestServiceExecuteAcceptsTemplateDelivery(t *testing.T) { t.Parallel() store := &stubStore{} telemetry := &stubTelemetry{} service := newTestService(t, Config{ Store: store, Clock: stubClock{now: fixedNow()}, Telemetry: telemetry, IdempotencyTTL: 7 * 24 * time.Hour, }) result, err := service.Execute(context.Background(), validTemplateCommand(t)) require.NoError(t, err) require.Equal(t, Result{Outcome: OutcomeAccepted}, result) require.Len(t, store.createInputs, 1) require.Nil(t, store.createInputs[0].DeliveryPayload) require.Equal(t, common.TemplateID("game.turn.ready"), store.createInputs[0].Delivery.TemplateID) require.Equal(t, map[string]any{ "turn_number": float64(54), "player": map[string]any{ "name": "Pilot", }, }, store.createInputs[0].Delivery.TemplateVariables) require.Equal(t, []string{"accepted"}, telemetry.outcomes) require.Equal(t, 1, telemetry.accepted) require.Equal(t, []string{"notification:queued"}, telemetry.statuses) } func TestServiceExecuteReturnsStableDuplicateResult(t *testing.T) { t.Parallel() command := validTemplateCommand(t) fingerprint, err := command.Fingerprint() require.NoError(t, err) store := &stubStore{ idempotencyRecord: &idempotency.Record{ Source: deliverydomain.SourceNotification, IdempotencyKey: command.IdempotencyKey, DeliveryID: command.DeliveryID, RequestFingerprint: fingerprint, CreatedAt: fixedNow(), ExpiresAt: fixedNow().Add(7 * 24 * time.Hour), }, deliveryRecord: &deliverydomain.Delivery{ DeliveryID: command.DeliveryID, Source: deliverydomain.SourceNotification, PayloadMode: deliverydomain.PayloadModeTemplate, TemplateID: command.TemplateID, Envelope: command.Envelope, Locale: command.Locale, TemplateVariables: map[string]any{ "turn_number": float64(54), "player": map[string]any{ "name": "Pilot", }, }, IdempotencyKey: command.IdempotencyKey, Status: deliverydomain.StatusQueued, AttemptCount: 1, CreatedAt: fixedNow(), UpdatedAt: fixedNow(), }, } require.NoError(t, store.idempotencyRecord.Validate()) require.NoError(t, store.deliveryRecord.Validate()) telemetry := &stubTelemetry{} service := newTestService(t, Config{ Store: store, Clock: stubClock{now: fixedNow()}, Telemetry: telemetry, IdempotencyTTL: 7 * 24 * time.Hour, }) result, err := service.Execute(context.Background(), command) require.NoError(t, err) require.Equal(t, Result{Outcome: OutcomeDuplicate}, result) require.Empty(t, store.createInputs) require.Equal(t, []string{"duplicate"}, telemetry.outcomes) } func TestServiceExecuteRejectsConflictingReplay(t *testing.T) { t.Parallel() command := validRenderedCommand(t) store := &stubStore{ idempotencyRecord: &idempotency.Record{ Source: deliverydomain.SourceNotification, IdempotencyKey: command.IdempotencyKey, DeliveryID: command.DeliveryID, RequestFingerprint: "sha256:other", CreatedAt: fixedNow(), ExpiresAt: fixedNow().Add(7 * 24 * time.Hour), }, } require.NoError(t, store.idempotencyRecord.Validate()) telemetry := &stubTelemetry{} service := newTestService(t, Config{ Store: store, Clock: stubClock{now: fixedNow()}, Telemetry: telemetry, IdempotencyTTL: 7 * 24 * time.Hour, }) _, err := service.Execute(context.Background(), command) require.Error(t, err) require.ErrorIs(t, err, ErrConflict) require.Equal(t, []string{"conflict"}, telemetry.outcomes) } func TestServiceExecuteReturnsServiceUnavailableOnCreateFailure(t *testing.T) { t.Parallel() telemetry := &stubTelemetry{} service := newTestService(t, Config{ Store: &stubStore{ createErr: errors.New("redis unavailable"), }, Clock: stubClock{now: fixedNow()}, Telemetry: telemetry, IdempotencyTTL: 7 * 24 * time.Hour, }) _, err := service.Execute(context.Background(), validRenderedCommand(t)) require.Error(t, err) require.ErrorIs(t, err, ErrServiceUnavailable) require.Equal(t, []string{"service_unavailable"}, telemetry.outcomes) } func TestServiceExecuteLogsAcceptedDeliveryAndCreatesSpan(t *testing.T) { t.Parallel() store := &stubStore{} telemetry := &stubTelemetry{} loggerBuffer := &bytes.Buffer{} recorder := tracetest.NewSpanRecorder() tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) command := validTemplateCommand(t) command.TraceID = "trace-123" service := newTestService(t, Config{ Store: store, Clock: stubClock{now: fixedNow()}, Telemetry: telemetry, TracerProvider: tracerProvider, Logger: slog.New(slog.NewJSONHandler(loggerBuffer, nil)), IdempotencyTTL: 7 * 24 * time.Hour, }) _, err := service.Execute(context.Background(), command) require.NoError(t, err) require.Contains(t, loggerBuffer.String(), "\"delivery_id\":\"mail-124\"") require.Contains(t, loggerBuffer.String(), "\"source\":\"notification\"") require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn.ready\"") require.Contains(t, loggerBuffer.String(), "\"trace_id\":\"trace-123\"") require.Contains(t, loggerBuffer.String(), "\"otel_trace_id\":") require.True(t, hasSpanNamed(recorder.Ended(), "mail.accept_generic_delivery")) } type stubStore struct { createInputs []CreateAcceptanceInput createErr error idempotencyRecord *idempotency.Record deliveryRecord *deliverydomain.Delivery } func (store *stubStore) CreateAcceptance(_ context.Context, input CreateAcceptanceInput) error { store.createInputs = append(store.createInputs, input) return store.createErr } func (store *stubStore) GetIdempotency(_ context.Context, _ deliverydomain.Source, _ common.IdempotencyKey) (idempotency.Record, bool, error) { if store.idempotencyRecord == nil { return idempotency.Record{}, false, nil } return *store.idempotencyRecord, true, nil } func (store *stubStore) GetDelivery(_ context.Context, _ common.DeliveryID) (deliverydomain.Delivery, bool, error) { if store.deliveryRecord == nil { return deliverydomain.Delivery{}, false, nil } return *store.deliveryRecord, true, nil } type stubClock struct { now time.Time } func (clock stubClock) Now() time.Time { return clock.now } type stubTelemetry struct { outcomes []string accepted int statuses []string } func (telemetry *stubTelemetry) RecordGenericDeliveryOutcome(_ context.Context, outcome string) { telemetry.outcomes = append(telemetry.outcomes, outcome) } func (telemetry *stubTelemetry) RecordAcceptedGenericDelivery(context.Context) { telemetry.accepted++ } func (telemetry *stubTelemetry) RecordDeliveryStatusTransition(_ context.Context, status string, source string) { telemetry.statuses = append(telemetry.statuses, source+":"+status) } func newTestService(t *testing.T, cfg Config) *Service { t.Helper() service, err := New(cfg) require.NoError(t, err) return service } func validRenderedCommand(t *testing.T) streamcommand.Command { t.Helper() command, err := streamcommand.DecodeCommand(map[string]any{ "delivery_id": "mail-123", "source": "notification", "payload_mode": "rendered", "idempotency_key": "notification:mail-123", "requested_at_ms": "1775121700000", "payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":["noreply@example.com"],"subject":"Turn ready","text_body":"Turn 54 is ready.","html_body":"
Turn 54 is ready.
","attachments":[{"filename":"report.txt","content_type":"text/plain","content_base64":"` + base64.StdEncoding.EncodeToString([]byte("report")) + `"}]}`, }) require.NoError(t, err) return command } func validTemplateCommand(t *testing.T) streamcommand.Command { t.Helper() command, err := streamcommand.DecodeCommand(map[string]any{ "delivery_id": "mail-124", "source": "notification", "payload_mode": "template", "idempotency_key": "notification:mail-124", "requested_at_ms": "1775121700001", "payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn.ready","locale":"fr-FR","variables":{"turn_number":54,"player":{"name":"Pilot"}},"attachments":[]}`, }) require.NoError(t, err) return command } func fixedNow() time.Time { return time.Unix(1_775_121_700, 0).UTC() } func hasSpanNamed(spans []sdktrace.ReadOnlySpan, name string) bool { for _, span := range spans { if span.Name() == name { return true } } return false } var _ = attempt.Attempt{}