321 lines
9.5 KiB
Go
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{}
|