feat: mail service
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
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"): "<p>{{.player.name}}</p>",
|
||||
})
|
||||
|
||||
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: "<p>Pilot</p>",
|
||||
}, 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()
|
||||
}
|
||||
Reference in New Issue
Block a user