feat: mail service
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
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{}
|
||||
Reference in New Issue
Block a user