feat: mail service
This commit is contained in:
@@ -0,0 +1,598 @@
|
||||
// Package acceptgenericdelivery implements durable asynchronous acceptance of
|
||||
// generic delivery commands consumed from Redis Streams.
|
||||
package acceptgenericdelivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"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"
|
||||
"galaxy/mail/internal/logging"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
oteltrace "go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrConflict reports that the idempotency scope already belongs to a
|
||||
// different normalized generic request.
|
||||
ErrConflict = errors.New("accept generic delivery conflict")
|
||||
|
||||
// ErrServiceUnavailable reports that durable generic acceptance could not
|
||||
// be completed or recovered safely.
|
||||
ErrServiceUnavailable = errors.New("accept generic delivery service unavailable")
|
||||
)
|
||||
|
||||
const tracerName = "galaxy/mail/acceptgenericdelivery"
|
||||
|
||||
// Outcome identifies the coarse generic-delivery acceptance outcome.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
// OutcomeAccepted reports that the command was durably accepted into the
|
||||
// internal delivery pipeline.
|
||||
OutcomeAccepted Outcome = "accepted"
|
||||
|
||||
// OutcomeDuplicate reports that the command matched an already accepted
|
||||
// idempotent request and therefore became a no-op replay.
|
||||
OutcomeDuplicate Outcome = "duplicate"
|
||||
)
|
||||
|
||||
// IsKnown reports whether outcome belongs to the supported generic-acceptance
|
||||
// outcome surface.
|
||||
func (outcome Outcome) IsKnown() bool {
|
||||
switch outcome {
|
||||
case OutcomeAccepted, OutcomeDuplicate:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Result stores the coarse generic-delivery acceptance outcome.
|
||||
type Result struct {
|
||||
// Outcome stores the stable generic-acceptance result.
|
||||
Outcome Outcome
|
||||
}
|
||||
|
||||
// Validate reports whether result contains a supported generic-acceptance
|
||||
// outcome.
|
||||
func (result Result) Validate() error {
|
||||
if !result.Outcome.IsKnown() {
|
||||
return fmt.Errorf("accept generic delivery outcome %q is unsupported", result.Outcome)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachmentPayload stores one durably persisted raw attachment payload owned
|
||||
// by a generic delivery.
|
||||
type AttachmentPayload struct {
|
||||
// Filename stores the user-facing attachment filename.
|
||||
Filename string
|
||||
|
||||
// ContentType stores the MIME media type used for SMTP body construction.
|
||||
ContentType string
|
||||
|
||||
// ContentBase64 stores the exact accepted inline base64 payload.
|
||||
ContentBase64 string
|
||||
|
||||
// SizeBytes stores the decoded attachment size in bytes.
|
||||
SizeBytes int64
|
||||
}
|
||||
|
||||
// Validate reports whether payload contains a complete attachment body.
|
||||
func (payload AttachmentPayload) Validate() error {
|
||||
metadata := common.AttachmentMetadata{
|
||||
Filename: payload.Filename,
|
||||
ContentType: payload.ContentType,
|
||||
SizeBytes: payload.SizeBytes,
|
||||
}
|
||||
if err := metadata.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(payload.ContentBase64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("attachment content_base64 must be valid base64: %w", err)
|
||||
}
|
||||
if int64(len(decoded)) != payload.SizeBytes {
|
||||
return fmt.Errorf(
|
||||
"attachment size bytes must match decoded content size: got %d, want %d",
|
||||
payload.SizeBytes,
|
||||
len(decoded),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeliveryPayload stores the raw attachment payloads that must survive stream
|
||||
// offset advancement.
|
||||
type DeliveryPayload struct {
|
||||
// DeliveryID identifies the owning accepted delivery.
|
||||
DeliveryID common.DeliveryID
|
||||
|
||||
// Attachments stores the raw inline attachment payloads.
|
||||
Attachments []AttachmentPayload
|
||||
}
|
||||
|
||||
// Validate reports whether payload contains a complete attachment bundle.
|
||||
func (payload DeliveryPayload) Validate() error {
|
||||
if err := payload.DeliveryID.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery payload delivery id: %w", err)
|
||||
}
|
||||
if len(payload.Attachments) == 0 {
|
||||
return fmt.Errorf("delivery payload attachments must not be empty")
|
||||
}
|
||||
for index, attachment := range payload.Attachments {
|
||||
if err := attachment.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery payload attachments[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateAcceptanceInput stores the durable write set required for one
|
||||
// generic-delivery acceptance attempt.
|
||||
type CreateAcceptanceInput struct {
|
||||
// Delivery stores the accepted delivery record.
|
||||
Delivery deliverydomain.Delivery
|
||||
|
||||
// FirstAttempt stores the first scheduled attempt.
|
||||
FirstAttempt attempt.Attempt
|
||||
|
||||
// DeliveryPayload stores the optional raw attachment payload bundle.
|
||||
DeliveryPayload *DeliveryPayload
|
||||
|
||||
// Idempotency stores the idempotency reservation bound to Delivery.
|
||||
Idempotency idempotency.Record
|
||||
}
|
||||
|
||||
// Validate reports whether input contains a consistent durable write set.
|
||||
func (input CreateAcceptanceInput) Validate() error {
|
||||
if err := input.Delivery.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery: %w", err)
|
||||
}
|
||||
if err := input.FirstAttempt.Validate(); err != nil {
|
||||
return fmt.Errorf("first attempt: %w", err)
|
||||
}
|
||||
if input.FirstAttempt.DeliveryID != input.Delivery.DeliveryID {
|
||||
return errors.New("first attempt delivery id must match delivery id")
|
||||
}
|
||||
if input.FirstAttempt.Status != attempt.StatusScheduled {
|
||||
return fmt.Errorf("first attempt status must be %q", attempt.StatusScheduled)
|
||||
}
|
||||
if err := input.Idempotency.Validate(); err != nil {
|
||||
return fmt.Errorf("idempotency: %w", err)
|
||||
}
|
||||
if input.Idempotency.DeliveryID != input.Delivery.DeliveryID {
|
||||
return errors.New("idempotency delivery id must match delivery id")
|
||||
}
|
||||
if input.Idempotency.Source != input.Delivery.Source {
|
||||
return errors.New("idempotency source must match delivery source")
|
||||
}
|
||||
if input.Idempotency.IdempotencyKey != input.Delivery.IdempotencyKey {
|
||||
return errors.New("idempotency key must match delivery idempotency key")
|
||||
}
|
||||
if input.DeliveryPayload != nil {
|
||||
if err := input.DeliveryPayload.Validate(); err != nil {
|
||||
return fmt.Errorf("delivery payload: %w", err)
|
||||
}
|
||||
if input.DeliveryPayload.DeliveryID != input.Delivery.DeliveryID {
|
||||
return errors.New("delivery payload delivery id must match delivery id")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store describes the durable storage required by the generic-delivery use
|
||||
// case.
|
||||
type Store interface {
|
||||
// CreateAcceptance stores the complete durable write set for one generic
|
||||
// acceptance attempt. Implementations must wrap ErrConflict when the write
|
||||
// set races with an already accepted idempotency scope or delivery key.
|
||||
CreateAcceptance(context.Context, CreateAcceptanceInput) error
|
||||
|
||||
// GetIdempotency loads the idempotency reservation for one generic-delivery
|
||||
// scope.
|
||||
GetIdempotency(context.Context, deliverydomain.Source, common.IdempotencyKey) (idempotency.Record, bool, error)
|
||||
|
||||
// GetDelivery loads one accepted delivery by its identifier.
|
||||
GetDelivery(context.Context, common.DeliveryID) (deliverydomain.Delivery, bool, error)
|
||||
}
|
||||
|
||||
// Clock provides the current wall-clock time.
|
||||
type Clock interface {
|
||||
// Now returns the current time.
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
// Telemetry records low-cardinality generic-delivery outcomes.
|
||||
type Telemetry interface {
|
||||
// RecordGenericDeliveryOutcome records one coarse generic-acceptance
|
||||
// outcome.
|
||||
RecordGenericDeliveryOutcome(context.Context, string)
|
||||
|
||||
// RecordAcceptedGenericDelivery records one newly accepted generic
|
||||
// delivery.
|
||||
RecordAcceptedGenericDelivery(context.Context)
|
||||
|
||||
// RecordDeliveryStatusTransition records one durable delivery status
|
||||
// transition.
|
||||
RecordDeliveryStatusTransition(context.Context, string, string)
|
||||
}
|
||||
|
||||
// Config stores the dependencies and policy used by Service.
|
||||
type Config struct {
|
||||
// Store owns the durable accepted state.
|
||||
Store Store
|
||||
|
||||
// Clock provides wall-clock timestamps.
|
||||
Clock Clock
|
||||
|
||||
// Telemetry records low-cardinality acceptance outcomes.
|
||||
Telemetry Telemetry
|
||||
|
||||
// TracerProvider constructs the application span recorder used by the
|
||||
// generic acceptance flow.
|
||||
TracerProvider oteltrace.TracerProvider
|
||||
|
||||
// Logger writes structured generic acceptance logs.
|
||||
Logger *slog.Logger
|
||||
|
||||
// IdempotencyTTL stores how long accepted idempotency scopes remain valid.
|
||||
IdempotencyTTL time.Duration
|
||||
}
|
||||
|
||||
// Service durably accepts generic asynchronous delivery commands.
|
||||
type Service struct {
|
||||
store Store
|
||||
clock Clock
|
||||
telemetry Telemetry
|
||||
tracerProvider oteltrace.TracerProvider
|
||||
logger *slog.Logger
|
||||
idempotencyTTL time.Duration
|
||||
}
|
||||
|
||||
// New constructs Service from cfg.
|
||||
func New(cfg Config) (*Service, error) {
|
||||
switch {
|
||||
case cfg.Store == nil:
|
||||
return nil, errors.New("new accept generic delivery service: nil store")
|
||||
case cfg.Clock == nil:
|
||||
return nil, errors.New("new accept generic delivery service: nil clock")
|
||||
case cfg.IdempotencyTTL <= 0:
|
||||
return nil, errors.New("new accept generic delivery service: non-positive idempotency ttl")
|
||||
default:
|
||||
tracerProvider := cfg.TracerProvider
|
||||
if tracerProvider == nil {
|
||||
tracerProvider = otel.GetTracerProvider()
|
||||
}
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &Service{
|
||||
store: cfg.Store,
|
||||
clock: cfg.Clock,
|
||||
telemetry: cfg.Telemetry,
|
||||
tracerProvider: tracerProvider,
|
||||
logger: logger.With("component", "accept_generic_delivery"),
|
||||
idempotencyTTL: cfg.IdempotencyTTL,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute accepts one normalized generic-delivery command.
|
||||
func (service *Service) Execute(ctx context.Context, command streamcommand.Command) (Result, error) {
|
||||
if ctx == nil {
|
||||
return Result{}, errors.New("accept generic delivery: nil context")
|
||||
}
|
||||
if service == nil {
|
||||
return Result{}, errors.New("accept generic delivery: nil service")
|
||||
}
|
||||
if err := command.Validate(); err != nil {
|
||||
return Result{}, fmt.Errorf("accept generic delivery: %w", err)
|
||||
}
|
||||
|
||||
ctx, span := service.tracerProvider.Tracer(tracerName).Start(ctx, "mail.accept_generic_delivery")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("mail.delivery_id", command.DeliveryID.String()),
|
||||
attribute.String("mail.source", string(command.Source)),
|
||||
attribute.String("mail.payload_mode", string(command.PayloadMode)),
|
||||
)
|
||||
if strings.TrimSpace(command.TraceID) != "" {
|
||||
span.SetAttributes(attribute.String("mail.command_trace_id", command.TraceID))
|
||||
}
|
||||
if !command.TemplateID.IsZero() {
|
||||
span.SetAttributes(attribute.String("mail.template_id", command.TemplateID.String()))
|
||||
}
|
||||
|
||||
fingerprint, err := command.Fingerprint()
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("accept generic delivery: %w", err)
|
||||
}
|
||||
|
||||
if result, handled, err := service.resolveReplay(ctx, command, fingerprint); handled {
|
||||
if err != nil {
|
||||
service.recordOutcome(ctx, replayOutcomeForError(err))
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
service.recordOutcome(ctx, string(result.Outcome))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
createInput, result, err := service.buildCreateInput(command, fingerprint)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("accept generic delivery: %w", err)
|
||||
}
|
||||
|
||||
if err := service.store.CreateAcceptance(ctx, createInput); err != nil {
|
||||
if !errors.Is(err, ErrConflict) {
|
||||
service.recordOutcome(ctx, "service_unavailable")
|
||||
return Result{}, fmt.Errorf("%w: create acceptance: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
|
||||
if replayResult, handled, replayErr := service.resolveReplay(ctx, command, fingerprint); handled {
|
||||
if replayErr != nil {
|
||||
service.recordOutcome(ctx, replayOutcomeForError(replayErr))
|
||||
return Result{}, replayErr
|
||||
}
|
||||
|
||||
service.recordOutcome(ctx, string(replayResult.Outcome))
|
||||
return replayResult, nil
|
||||
}
|
||||
|
||||
service.recordOutcome(ctx, "service_unavailable")
|
||||
return Result{}, fmt.Errorf("%w: create acceptance conflict without replay state", ErrServiceUnavailable)
|
||||
}
|
||||
|
||||
service.recordOutcome(ctx, string(result.Outcome))
|
||||
service.recordAcceptedDelivery(ctx)
|
||||
service.recordStatusTransition(ctx, createInput.Delivery)
|
||||
span.SetAttributes(
|
||||
attribute.String("mail.status", string(createInput.Delivery.Status)),
|
||||
attribute.String("mail.outcome", string(result.Outcome)),
|
||||
)
|
||||
logArgs := logging.CommandAttrs(command)
|
||||
logArgs = append(logArgs,
|
||||
"status", string(createInput.Delivery.Status),
|
||||
"outcome", string(result.Outcome),
|
||||
"payload_mode", string(command.PayloadMode),
|
||||
)
|
||||
logArgs = append(logArgs, logging.TraceAttrsFromContext(ctx)...)
|
||||
service.logger.Info("generic delivery accepted", logArgs...)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (service *Service) buildCreateInput(command streamcommand.Command, fingerprint string) (CreateAcceptanceInput, Result, error) {
|
||||
now := service.clock.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
deliveryRecord := deliverydomain.Delivery{
|
||||
DeliveryID: command.DeliveryID,
|
||||
Source: command.Source,
|
||||
PayloadMode: command.PayloadMode,
|
||||
Envelope: command.Envelope,
|
||||
Attachments: attachmentMetadata(command.Attachments),
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
Status: deliverydomain.StatusQueued,
|
||||
AttemptCount: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
switch command.PayloadMode {
|
||||
case deliverydomain.PayloadModeRendered:
|
||||
deliveryRecord.Content = deliverydomain.Content{
|
||||
Subject: command.Subject,
|
||||
TextBody: command.TextBody,
|
||||
HTMLBody: command.HTMLBody,
|
||||
}
|
||||
case deliverydomain.PayloadModeTemplate:
|
||||
deliveryRecord.TemplateID = command.TemplateID
|
||||
deliveryRecord.Locale = command.Locale
|
||||
deliveryRecord.TemplateVariables = cloneJSONObject(command.Variables)
|
||||
default:
|
||||
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic delivery record: unsupported payload mode %q", command.PayloadMode)
|
||||
}
|
||||
|
||||
if err := deliveryRecord.Validate(); err != nil {
|
||||
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic delivery record: %w", err)
|
||||
}
|
||||
|
||||
firstAttempt := attempt.Attempt{
|
||||
DeliveryID: command.DeliveryID,
|
||||
AttemptNo: 1,
|
||||
ScheduledFor: now,
|
||||
Status: attempt.StatusScheduled,
|
||||
}
|
||||
if err := firstAttempt.Validate(); err != nil {
|
||||
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic first attempt: %w", err)
|
||||
}
|
||||
|
||||
createInput := CreateAcceptanceInput{
|
||||
Delivery: deliveryRecord,
|
||||
FirstAttempt: firstAttempt,
|
||||
Idempotency: idempotency.Record{
|
||||
Source: command.Source,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
DeliveryID: command.DeliveryID,
|
||||
RequestFingerprint: fingerprint,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(service.idempotencyTTL),
|
||||
},
|
||||
}
|
||||
if len(command.Attachments) > 0 {
|
||||
createInput.DeliveryPayload = &DeliveryPayload{
|
||||
DeliveryID: command.DeliveryID,
|
||||
Attachments: attachmentPayloads(command.Attachments),
|
||||
}
|
||||
}
|
||||
if err := createInput.Validate(); err != nil {
|
||||
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic create input: %w", err)
|
||||
}
|
||||
|
||||
result := Result{Outcome: OutcomeAccepted}
|
||||
if err := result.Validate(); err != nil {
|
||||
return CreateAcceptanceInput{}, Result{}, fmt.Errorf("build generic delivery result: %w", err)
|
||||
}
|
||||
|
||||
return createInput, result, nil
|
||||
}
|
||||
|
||||
func (service *Service) resolveReplay(ctx context.Context, command streamcommand.Command, fingerprint string) (Result, bool, error) {
|
||||
record, found, err := service.store.GetIdempotency(ctx, command.Source, command.IdempotencyKey)
|
||||
if err != nil {
|
||||
return Result{}, true, fmt.Errorf("%w: load idempotency: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
if !found {
|
||||
return Result{}, false, nil
|
||||
}
|
||||
if record.RequestFingerprint != fingerprint {
|
||||
return Result{}, true, fmt.Errorf("%w: request conflicts with current state", ErrConflict)
|
||||
}
|
||||
|
||||
deliveryRecord, found, err := service.store.GetDelivery(ctx, record.DeliveryID)
|
||||
if err != nil {
|
||||
return Result{}, true, fmt.Errorf("%w: load delivery: %v", ErrServiceUnavailable, err)
|
||||
}
|
||||
if !found {
|
||||
return Result{}, true, fmt.Errorf("%w: delivery %q is missing for idempotency scope", ErrServiceUnavailable, record.DeliveryID)
|
||||
}
|
||||
|
||||
if deliveryRecord.DeliveryID != command.DeliveryID {
|
||||
return Result{}, true, fmt.Errorf("%w: idempotency delivery %q mismatches command delivery %q", ErrServiceUnavailable, deliveryRecord.DeliveryID, command.DeliveryID)
|
||||
}
|
||||
|
||||
return deriveReplayResult(deliveryRecord)
|
||||
}
|
||||
|
||||
func deriveReplayResult(record deliverydomain.Delivery) (Result, bool, error) {
|
||||
switch record.Status {
|
||||
case deliverydomain.StatusAccepted,
|
||||
deliverydomain.StatusQueued,
|
||||
deliverydomain.StatusRendered,
|
||||
deliverydomain.StatusSending,
|
||||
deliverydomain.StatusSent,
|
||||
deliverydomain.StatusSuppressed,
|
||||
deliverydomain.StatusFailed,
|
||||
deliverydomain.StatusDeadLetter:
|
||||
return Result{Outcome: OutcomeDuplicate}, true, nil
|
||||
default:
|
||||
return Result{}, true, fmt.Errorf("%w: unsupported replay delivery status %q", ErrServiceUnavailable, record.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) recordAcceptedDelivery(ctx context.Context) {
|
||||
if service == nil || service.telemetry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
service.telemetry.RecordAcceptedGenericDelivery(ctx)
|
||||
}
|
||||
|
||||
func (service *Service) recordStatusTransition(ctx context.Context, record deliverydomain.Delivery) {
|
||||
if service == nil || service.telemetry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
service.telemetry.RecordDeliveryStatusTransition(ctx, string(record.Status), string(record.Source))
|
||||
}
|
||||
|
||||
func (service *Service) recordOutcome(ctx context.Context, outcome string) {
|
||||
if service == nil || service.telemetry == nil || strings.TrimSpace(outcome) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
service.telemetry.RecordGenericDeliveryOutcome(ctx, outcome)
|
||||
}
|
||||
|
||||
func replayOutcomeForError(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrConflict):
|
||||
return "conflict"
|
||||
case errors.Is(err, ErrServiceUnavailable):
|
||||
return "service_unavailable"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func attachmentMetadata(values []streamcommand.Attachment) []common.AttachmentMetadata {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]common.AttachmentMetadata, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = common.AttachmentMetadata{
|
||||
Filename: value.Filename,
|
||||
ContentType: value.ContentType,
|
||||
SizeBytes: value.SizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func attachmentPayloads(values []streamcommand.Attachment) []AttachmentPayload {
|
||||
result := make([]AttachmentPayload, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = AttachmentPayload{
|
||||
Filename: value.Filename,
|
||||
ContentType: value.ContentType,
|
||||
ContentBase64: value.ContentBase64,
|
||||
SizeBytes: value.SizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneJSONObject(value map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make(map[string]any, len(value))
|
||||
for key, item := range value {
|
||||
cloned[key] = cloneJSONValue(item)
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneJSONValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
cloned := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
cloned[key] = cloneJSONValue(item)
|
||||
}
|
||||
return cloned
|
||||
case []any:
|
||||
cloned := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
cloned[index] = cloneJSONValue(item)
|
||||
}
|
||||
return cloned
|
||||
default:
|
||||
return typed
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
Reference in New Issue
Block a user