320 lines
9.9 KiB
Go
320 lines
9.9 KiB
Go
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{}
|