Files
galaxy-game/mail/internal/service/acceptauthdelivery/service_test.go
T
2026-04-17 18:39:16 +02:00

321 lines
9.5 KiB
Go

package acceptauthdelivery
import (
"bytes"
"context"
"errors"
"log/slog"
"testing"
"time"
"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 TestServiceExecuteAcceptsQueuedDelivery(t *testing.T) {
t.Parallel()
store := &stubStore{}
telemetry := &stubTelemetry{}
service := newTestService(t, Config{
Store: store,
DeliveryIDGenerator: stubIDGenerator{ids: []common.DeliveryID{"delivery-queued"}},
Clock: stubClock{now: fixedNow()},
Telemetry: telemetry,
IdempotencyTTL: 7 * 24 * time.Hour,
})
result, err := service.Execute(context.Background(), validInput())
require.NoError(t, err)
require.Equal(t, Result{Outcome: OutcomeSent}, result)
require.Len(t, store.createInputs, 1)
require.NotNil(t, store.createInputs[0].FirstAttempt)
require.Equal(t, deliverydomain.StatusQueued, store.createInputs[0].Delivery.Status)
require.Equal(t, []string{"sent"}, telemetry.outcomes)
require.Equal(t, 1, telemetry.accepted)
require.Equal(t, []string{"authsession:queued"}, telemetry.statuses)
}
func TestServiceExecuteAcceptsSuppressedDelivery(t *testing.T) {
t.Parallel()
store := &stubStore{}
telemetry := &stubTelemetry{}
service := newTestService(t, Config{
Store: store,
DeliveryIDGenerator: stubIDGenerator{ids: []common.DeliveryID{"delivery-suppressed"}},
Clock: stubClock{now: fixedNow()},
Telemetry: telemetry,
IdempotencyTTL: 7 * 24 * time.Hour,
SuppressOutbound: true,
})
result, err := service.Execute(context.Background(), validInput())
require.NoError(t, err)
require.Equal(t, Result{Outcome: OutcomeSuppressed}, result)
require.Len(t, store.createInputs, 1)
require.Nil(t, store.createInputs[0].FirstAttempt)
require.Equal(t, deliverydomain.StatusSuppressed, store.createInputs[0].Delivery.Status)
require.Equal(t, []string{"suppressed"}, telemetry.outcomes)
require.Equal(t, 1, telemetry.accepted)
require.Equal(t, []string{"authsession:suppressed"}, telemetry.statuses)
}
func TestServiceExecuteReturnsStableDuplicateResult(t *testing.T) {
t.Parallel()
input := validInput()
fingerprint, err := input.Fingerprint()
require.NoError(t, err)
store := &stubStore{
idempotencyRecord: &idempotency.Record{
Source: deliverydomain.SourceAuthSession,
IdempotencyKey: input.IdempotencyKey,
DeliveryID: common.DeliveryID("delivery-existing"),
RequestFingerprint: fingerprint,
CreatedAt: fixedNow(),
ExpiresAt: fixedNow().Add(7 * 24 * time.Hour),
},
deliveryRecord: &deliverydomain.Delivery{
DeliveryID: common.DeliveryID("delivery-existing"),
Source: deliverydomain.SourceAuthSession,
PayloadMode: deliverydomain.PayloadModeTemplate,
TemplateID: AuthTemplateID,
Envelope: deliverydomain.Envelope{
To: []common.Email{input.Email},
},
Locale: input.Locale,
TemplateVariables: map[string]any{
"code": input.Code,
},
IdempotencyKey: input.IdempotencyKey,
Status: deliverydomain.StatusSuppressed,
CreatedAt: fixedNow(),
UpdatedAt: fixedNow(),
SuppressedAt: ptrTime(fixedNow()),
},
}
require.NoError(t, store.idempotencyRecord.Validate())
require.NoError(t, store.deliveryRecord.Validate())
telemetry := &stubTelemetry{}
service := newTestService(t, Config{
Store: store,
DeliveryIDGenerator: stubIDGenerator{},
Clock: stubClock{now: fixedNow()},
Telemetry: telemetry,
IdempotencyTTL: 7 * 24 * time.Hour,
})
result, err := service.Execute(context.Background(), input)
require.NoError(t, err)
require.Equal(t, Result{Outcome: OutcomeSuppressed}, result)
require.Empty(t, store.createInputs)
require.Equal(t, []string{"duplicate"}, telemetry.outcomes)
}
func TestServiceExecuteRejectsConflictingReplay(t *testing.T) {
t.Parallel()
input := validInput()
store := &stubStore{
idempotencyRecord: &idempotency.Record{
Source: deliverydomain.SourceAuthSession,
IdempotencyKey: input.IdempotencyKey,
DeliveryID: common.DeliveryID("delivery-existing"),
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,
DeliveryIDGenerator: stubIDGenerator{},
Clock: stubClock{now: fixedNow()},
Telemetry: telemetry,
IdempotencyTTL: 7 * 24 * time.Hour,
})
_, err := service.Execute(context.Background(), input)
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"),
},
DeliveryIDGenerator: stubIDGenerator{ids: []common.DeliveryID{"delivery-queued"}},
Clock: stubClock{now: fixedNow()},
Telemetry: telemetry,
IdempotencyTTL: 7 * 24 * time.Hour,
})
_, err := service.Execute(context.Background(), validInput())
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))
service := newTestService(t, Config{
Store: store,
DeliveryIDGenerator: stubIDGenerator{ids: []common.DeliveryID{"delivery-queued"}},
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(), validInput())
require.NoError(t, err)
require.Contains(t, loggerBuffer.String(), "\"delivery_id\":\"delivery-queued\"")
require.Contains(t, loggerBuffer.String(), "\"source\":\"authsession\"")
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"auth.login_code\"")
require.Contains(t, loggerBuffer.String(), "\"otel_trace_id\":")
require.True(t, hasSpanNamed(recorder.Ended(), "mail.accept_auth_delivery"))
}
func TestInputFingerprintStableForEquivalentInput(t *testing.T) {
t.Parallel()
first := validInput()
second := validInput()
firstFingerprint, err := first.Fingerprint()
require.NoError(t, err)
secondFingerprint, err := second.Fingerprint()
require.NoError(t, err)
require.Equal(t, firstFingerprint, secondFingerprint)
}
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 stubIDGenerator struct {
ids []common.DeliveryID
}
func (generator stubIDGenerator) NewDeliveryID() (common.DeliveryID, error) {
if len(generator.ids) == 0 {
return "", errors.New("no delivery ids left")
}
return generator.ids[0], 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) RecordAuthDeliveryOutcome(_ context.Context, outcome string) {
telemetry.outcomes = append(telemetry.outcomes, outcome)
}
func (telemetry *stubTelemetry) RecordAcceptedAuthDelivery(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 validInput() Input {
locale, err := common.ParseLocale("en")
if err != nil {
panic(err)
}
return Input{
IdempotencyKey: common.IdempotencyKey("challenge-123"),
Email: common.Email("pilot@example.com"),
Code: "123456",
Locale: locale,
}
}
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{}