feat: mail service

This commit is contained in:
Ilia Denisov
2026-04-17 18:39:16 +02:00
committed by GitHub
parent 23ffcb7535
commit 5b7593e6f6
183 changed files with 31215 additions and 248 deletions
@@ -0,0 +1,319 @@
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":"<p>Turn 54 is ready.</p>","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{}