package renderdelivery import ( "bytes" "context" "errors" "log/slog" "os" "path/filepath" "testing" "time" templatedir "galaxy/mail/internal/adapters/templates" "galaxy/mail/internal/domain/attempt" "galaxy/mail/internal/domain/common" deliverydomain "galaxy/mail/internal/domain/delivery" "github.com/stretchr/testify/require" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" ) func TestServiceExecuteRendersExactLocale(t *testing.T) { t.Parallel() catalog := newTestCatalog(t, map[string]string{ filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code", filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}", filepath.Join("game.turn_ready", "fr-fr", "subject.tmpl"): "Tour {{.turn_number}}", filepath.Join("game.turn_ready", "fr-fr", "text.tmpl"): "Bonjour {{with .player}}{{.name}}{{end}}", filepath.Join("game.turn_ready", "fr-fr", "html.tmpl"): "
{{.player.name}}
", }) store := &stubStore{} service := newTestService(t, Config{ Catalog: catalog, Store: store, Clock: stubClock{now: fixedNow()}, }) result, err := service.Execute(context.Background(), validInput(t, "fr-FR")) require.NoError(t, err) require.Equal(t, OutcomeRendered, result.Outcome) require.Equal(t, common.Locale("fr-FR"), result.ResolvedLocale) require.False(t, result.LocaleFallbackUsed) require.NotEmpty(t, result.TemplateVersion) require.Nil(t, result.Attempt) require.Equal(t, deliverydomain.StatusRendered, result.Delivery.Status) require.Equal(t, deliverydomain.Content{ Subject: "Tour 54", TextBody: "Bonjour Pilot", HTMLBody: "Pilot
", }, result.Delivery.Content) require.Len(t, store.renderedInputs, 1) require.Empty(t, store.failedInputs) } func TestServiceExecuteFallsBackToEnglish(t *testing.T) { t.Parallel() catalog := newTestCatalog(t, map[string]string{ filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code", filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}", filepath.Join("game.turn_ready", "en", "subject.tmpl"): "Turn {{.turn_number}}", filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}", }) store := &stubStore{} service := newTestService(t, Config{ Catalog: catalog, Store: store, Clock: stubClock{now: fixedNow()}, }) result, err := service.Execute(context.Background(), validInput(t, "fr-FR")) require.NoError(t, err) require.Equal(t, OutcomeRendered, result.Outcome) require.Equal(t, common.Locale("en"), result.ResolvedLocale) require.True(t, result.LocaleFallbackUsed) require.True(t, result.Delivery.LocaleFallbackUsed) } func TestServiceExecuteRecordsLocaleFallbackAndLogsFields(t *testing.T) { t.Parallel() catalog := newTestCatalog(t, map[string]string{ filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code", filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}", filepath.Join("game.turn_ready", "en", "subject.tmpl"): "Turn {{.turn_number}}", filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}", }) telemetry := &stubTelemetry{} loggerBuffer := &bytes.Buffer{} recorder := tracetest.NewSpanRecorder() tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) service := newTestService(t, Config{ Catalog: catalog, Store: &stubStore{}, Clock: stubClock{now: fixedNow()}, Telemetry: telemetry, TracerProvider: tracerProvider, Logger: slog.New(slog.NewJSONHandler(loggerBuffer, nil)), }) _, err := service.Execute(context.Background(), validInput(t, "fr-FR")) require.NoError(t, err) require.Equal(t, []string{"notification:rendered"}, telemetry.statuses) require.Equal(t, []string{"game.turn_ready:fr-FR:en"}, telemetry.fallbacks) require.Contains(t, loggerBuffer.String(), "\"delivery_id\":\"delivery-123\"") require.Contains(t, loggerBuffer.String(), "\"source\":\"notification\"") require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn_ready\"") require.Contains(t, loggerBuffer.String(), "\"attempt_no\":1") require.Contains(t, loggerBuffer.String(), "\"otel_trace_id\":") require.True(t, hasRenderSpanNamed(recorder.Ended(), "mail.render_delivery")) } func TestServiceExecuteFailsOnMissingRequiredVariable(t *testing.T) { t.Parallel() catalog := newTestCatalog(t, map[string]string{ filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code", filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}", filepath.Join("game.turn_ready", "en", "subject.tmpl"): "Turn {{.turn_number}}", filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}", }) store := &stubStore{} service := newTestService(t, Config{ Catalog: catalog, Store: store, Clock: stubClock{now: fixedNow()}, }) input := validInput(t, "en") delete(input.Delivery.TemplateVariables, "player") result, err := service.Execute(context.Background(), input) require.NoError(t, err) require.Equal(t, OutcomeFailed, result.Outcome) require.Equal(t, FailureMissingRequiredVariable, result.FailureClassification) require.NotNil(t, result.Attempt) require.Equal(t, attempt.StatusRenderFailed, result.Attempt.Status) require.Equal(t, "missing required variables: player.name", result.Attempt.ProviderSummary) require.Len(t, store.failedInputs, 1) require.Empty(t, store.renderedInputs) } func TestServiceExecuteFailsOnTemplateExecutionError(t *testing.T) { t.Parallel() catalog := newTestCatalog(t, map[string]string{ filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code", filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}", filepath.Join("game.turn_ready", "en", "subject.tmpl"): "{{call .callable}}", filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}", }) store := &stubStore{} service := newTestService(t, Config{ Catalog: catalog, Store: store, Clock: stubClock{now: fixedNow()}, }) input := validInput(t, "en") input.Delivery.TemplateVariables["callable"] = "not-a-func" result, err := service.Execute(context.Background(), input) require.NoError(t, err) require.Equal(t, OutcomeFailed, result.Outcome) require.Equal(t, FailureTemplateExecuteFailed, result.FailureClassification) require.Equal(t, "template execution failed", result.Attempt.ProviderSummary) } func TestServiceExecuteClassifiesTemplateNotFound(t *testing.T) { t.Parallel() service := newTestService(t, Config{ Catalog: stubCatalog{ lookupErr: templatedir.ErrTemplateNotFound, }, Store: &stubStore{}, Clock: stubClock{now: fixedNow()}, }) result, err := service.Execute(context.Background(), validInput(t, "en")) require.NoError(t, err) require.Equal(t, OutcomeFailed, result.Outcome) require.Equal(t, FailureTemplateNotFound, result.FailureClassification) } func TestServiceExecuteClassifiesFallbackMissing(t *testing.T) { t.Parallel() service := newTestService(t, Config{ Catalog: stubCatalog{ lookupErr: templatedir.ErrFallbackMissing, }, Store: &stubStore{}, Clock: stubClock{now: fixedNow()}, }) result, err := service.Execute(context.Background(), validInput(t, "fr-FR")) require.NoError(t, err) require.Equal(t, OutcomeFailed, result.Outcome) require.Equal(t, FailureFallbackMissing, result.FailureClassification) } func TestServiceExecuteClassifiesTemplateParseFailure(t *testing.T) { t.Parallel() service := newTestService(t, Config{ Catalog: stubCatalog{ lookupErr: templatedir.ErrTemplateParseFailed, }, Store: &stubStore{}, Clock: stubClock{now: fixedNow()}, }) result, err := service.Execute(context.Background(), validInput(t, "en")) require.NoError(t, err) require.Equal(t, OutcomeFailed, result.Outcome) require.Equal(t, FailureTemplateParseFailed, result.FailureClassification) } func TestServiceExecuteReturnsServiceUnavailableOnStoreFailure(t *testing.T) { t.Parallel() catalog := newTestCatalog(t, map[string]string{ filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code", filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}", filepath.Join("game.turn_ready", "en", "subject.tmpl"): "Turn {{.turn_number}}", filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}", }) service := newTestService(t, Config{ Catalog: catalog, Store: &stubStore{ markRenderedErr: errors.New("redis unavailable"), }, Clock: stubClock{now: fixedNow()}, }) _, err := service.Execute(context.Background(), validInput(t, "en")) require.Error(t, err) require.ErrorIs(t, err, ErrServiceUnavailable) } type stubStore struct { renderedInputs []MarkRenderedInput failedInputs []MarkRenderFailedInput markRenderedErr error markFailedErr error } func (store *stubStore) MarkRendered(_ context.Context, input MarkRenderedInput) error { store.renderedInputs = append(store.renderedInputs, input) return store.markRenderedErr } func (store *stubStore) MarkRenderFailed(_ context.Context, input MarkRenderFailedInput) error { store.failedInputs = append(store.failedInputs, input) return store.markFailedErr } type stubCatalog struct { lookupResult templatedir.ResolvedTemplate lookupErr error } func (catalog stubCatalog) Lookup(common.TemplateID, common.Locale) (templatedir.ResolvedTemplate, error) { return catalog.lookupResult, catalog.lookupErr } type stubClock struct { now time.Time } func (clock stubClock) Now() time.Time { return clock.now } func newTestService(t *testing.T, cfg Config) *Service { t.Helper() service, err := New(cfg) require.NoError(t, err) return service } func newTestCatalog(t *testing.T, files map[string]string) *templatedir.Catalog { t.Helper() rootDir := t.TempDir() for path, contents := range files { absolutePath := filepath.Join(rootDir, path) require.NoError(t, os.MkdirAll(filepath.Dir(absolutePath), 0o755)) require.NoError(t, os.WriteFile(absolutePath, []byte(contents), 0o644)) } catalog, err := templatedir.NewCatalog(rootDir) require.NoError(t, err) return catalog } type stubTelemetry struct { statuses []string attempts []string fallbacks []string } func (telemetry *stubTelemetry) RecordDeliveryStatusTransition(_ context.Context, status string, source string) { telemetry.statuses = append(telemetry.statuses, source+":"+status) } func (telemetry *stubTelemetry) RecordAttemptOutcome(_ context.Context, status string, source string) { telemetry.attempts = append(telemetry.attempts, source+":"+status) } func (telemetry *stubTelemetry) RecordLocaleFallback(_ context.Context, templateID string, requestedLocale string, resolvedLocale string) { telemetry.fallbacks = append(telemetry.fallbacks, templateID+":"+requestedLocale+":"+resolvedLocale) } func hasRenderSpanNamed(spans []sdktrace.ReadOnlySpan, name string) bool { for _, span := range spans { if span.Name() == name { return true } } return false } func validInput(t *testing.T, localeValue string) Input { t.Helper() locale, err := common.ParseLocale(localeValue) require.NoError(t, err) createdAt := fixedNow().Add(-time.Minute) deliveryRecord := deliverydomain.Delivery{ DeliveryID: common.DeliveryID("delivery-123"), Source: deliverydomain.SourceNotification, PayloadMode: deliverydomain.PayloadModeTemplate, TemplateID: common.TemplateID("game.turn_ready"), Envelope: deliverydomain.Envelope{ To: []common.Email{common.Email("pilot@example.com")}, }, Locale: locale, TemplateVariables: map[string]any{ "turn_number": float64(54), "player": map[string]any{ "name": "Pilot", }, }, IdempotencyKey: common.IdempotencyKey("notification:delivery-123"), Status: deliverydomain.StatusQueued, AttemptCount: 1, CreatedAt: createdAt, UpdatedAt: createdAt, } require.NoError(t, deliveryRecord.Validate()) scheduledFor := createdAt attemptRecord := attempt.Attempt{ DeliveryID: deliveryRecord.DeliveryID, AttemptNo: 1, ScheduledFor: scheduledFor, Status: attempt.StatusScheduled, } require.NoError(t, attemptRecord.Validate()) return Input{ Delivery: deliveryRecord, Attempt: attemptRecord, } } func fixedNow() time.Time { return time.Unix(1_775_121_700, 0).UTC() }