package delivery import ( "testing" "time" "galaxy/mail/internal/domain/attempt" "galaxy/mail/internal/domain/common" "github.com/stretchr/testify/require" ) func TestStatusCanTransitionTo(t *testing.T) { t.Parallel() tests := []struct { name string from Status to Status want bool }{ {name: "accepted to queued", from: StatusAccepted, to: StatusQueued, want: true}, {name: "accepted to suppressed", from: StatusAccepted, to: StatusSuppressed, want: true}, {name: "accepted to sent", from: StatusAccepted, to: StatusSent, want: false}, {name: "queued to rendered", from: StatusQueued, to: StatusRendered, want: true}, {name: "queued to sending", from: StatusQueued, to: StatusSending, want: true}, {name: "queued to failed", from: StatusQueued, to: StatusFailed, want: true}, {name: "rendered to sending", from: StatusRendered, to: StatusSending, want: true}, {name: "rendered to failed", from: StatusRendered, to: StatusFailed, want: true}, {name: "sending to sent", from: StatusSending, to: StatusSent, want: true}, {name: "sending to dead letter", from: StatusSending, to: StatusDeadLetter, want: true}, {name: "failed terminal", from: StatusFailed, to: StatusDeadLetter, want: false}, {name: "dead letter terminal", from: StatusDeadLetter, to: StatusQueued, want: false}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() require.Equal(t, tt.want, tt.from.CanTransitionTo(tt.to)) }) } } func TestStatusTerminalAndResend(t *testing.T) { t.Parallel() require.False(t, StatusAccepted.IsTerminal()) require.False(t, StatusQueued.AllowsResend()) require.True(t, StatusSent.IsTerminal()) require.True(t, StatusSent.AllowsResend()) require.True(t, StatusSuppressed.AllowsResend()) require.True(t, StatusFailed.AllowsResend()) require.True(t, StatusDeadLetter.AllowsResend()) } func TestDeliveryValidate(t *testing.T) { t.Parallel() base := validRenderedDelivery(t) templateQueued := validTemplateQueuedDelivery(t) tests := []struct { name string record Delivery wantErr bool }{ {name: "valid rendered delivery", record: base}, {name: "valid template queued delivery", record: templateQueued}, { name: "operator resend requires parent id", record: func() Delivery { record := base record.Source = SourceOperatorResend record.ResendParentDeliveryID = "" return record }(), wantErr: true, }, { name: "non resend must not carry parent id", record: func() Delivery { record := base record.ResendParentDeliveryID = common.DeliveryID("delivery-parent") return record }(), wantErr: true, }, { name: "rendered status requires template mode", record: func() Delivery { record := base record.Status = StatusRendered record.UpdatedAt = record.CreatedAt.Add(time.Minute) record.SentAt = nil return record }(), wantErr: true, }, { name: "rendered payload requires materialized content", record: func() Delivery { record := base record.Content = Content{} return record }(), wantErr: true, }, { name: "template mode requires template id", record: func() Delivery { record := templateQueued record.TemplateID = "" return record }(), wantErr: true, }, { name: "template mode requires locale", record: func() Delivery { record := templateQueued record.Locale = "" return record }(), wantErr: true, }, { name: "template mode requires template variables", record: func() Delivery { record := templateQueued record.TemplateVariables = nil return record }(), wantErr: true, }, { name: "template rendered requires content", record: func() Delivery { record := templateQueued record.Status = StatusRendered record.UpdatedAt = record.CreatedAt.Add(2 * time.Minute) record.Content = Content{} return record }(), wantErr: true, }, { name: "non terminal must not carry terminal timestamps", record: func() Delivery { record := templateQueued record.FailedAt = ptrTime(record.CreatedAt.Add(time.Minute)) return record }(), wantErr: true, }, { name: "rendered delivery must not contain template variables", record: func() Delivery { record := base record.TemplateVariables = map[string]any{"code": "123456"} return record }(), wantErr: true, }, { name: "template variables must be json serializable", record: func() Delivery { record := templateQueued record.TemplateVariables = map[string]any{"invalid": func() {}} return record }(), wantErr: true, }, { name: "failed requires failed at", record: func() Delivery { record := templateQueued record.Status = StatusFailed record.UpdatedAt = record.CreatedAt.Add(2 * time.Minute) return record }(), wantErr: true, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() err := tt.record.Validate() if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) }) } } func TestValidateDeadLetterState(t *testing.T) { t.Parallel() record := validDeadLetterDelivery(t) entry := validDeadLetterEntry(t, record) require.NoError(t, ValidateDeadLetterState(record, &entry)) err := ValidateDeadLetterState(record, nil) require.Error(t, err) failed := validTemplateQueuedDelivery(t) failed.Status = StatusFailed failed.UpdatedAt = failed.CreatedAt.Add(2 * time.Minute) failed.FailedAt = ptrTime(failed.CreatedAt.Add(2 * time.Minute)) require.NoError(t, ValidateDeadLetterState(failed, nil)) require.Error(t, ValidateDeadLetterState(failed, &entry)) mismatched := entry mismatched.DeliveryID = common.DeliveryID("delivery-other") require.Error(t, ValidateDeadLetterState(record, &mismatched)) } func validRenderedDelivery(t *testing.T) Delivery { t.Helper() createdAt := time.Unix(1_775_121_700, 0).UTC() sentAt := createdAt.Add(5 * time.Minute) record := Delivery{ DeliveryID: common.DeliveryID("delivery-123"), Source: SourceNotification, PayloadMode: PayloadModeRendered, Envelope: validEnvelope(), Content: Content{Subject: "Turn ready", TextBody: "Turn 54 is ready."}, Attachments: []common.AttachmentMetadata{{Filename: "report.txt", ContentType: "text/plain", SizeBytes: 64}}, TemplateVariables: nil, IdempotencyKey: common.IdempotencyKey("notification:delivery-123"), Status: StatusSent, AttemptCount: 1, LastAttemptStatus: attempt.StatusProviderAccepted, ProviderSummary: "queued by provider", CreatedAt: createdAt, UpdatedAt: sentAt, SentAt: &sentAt, } require.NoError(t, record.Validate()) return record } func validTemplateQueuedDelivery(t *testing.T) Delivery { t.Helper() createdAt := time.Unix(1_775_121_700, 0).UTC() locale, err := common.ParseLocale("fr-fr") require.NoError(t, err) record := Delivery{ DeliveryID: common.DeliveryID("delivery-124"), Source: SourceNotification, PayloadMode: PayloadModeTemplate, TemplateID: common.TemplateID("game.turn.ready"), Envelope: validEnvelope(), Locale: locale, TemplateVariables: map[string]any{ "turn_number": float64(54), }, IdempotencyKey: common.IdempotencyKey("notification:delivery-124"), Status: StatusQueued, CreatedAt: createdAt, UpdatedAt: createdAt.Add(time.Minute), } require.NoError(t, record.Validate()) return record } func validDeadLetterDelivery(t *testing.T) Delivery { t.Helper() record := validTemplateQueuedDelivery(t) record.Status = StatusDeadLetter record.AttemptCount = 3 record.LastAttemptStatus = attempt.StatusTimedOut record.UpdatedAt = record.CreatedAt.Add(10 * time.Minute) record.DeadLetteredAt = ptrTime(record.CreatedAt.Add(10 * time.Minute)) require.NoError(t, record.Validate()) return record } func validDeadLetterEntry(t *testing.T, record Delivery) DeadLetterEntry { t.Helper() entry := DeadLetterEntry{ DeliveryID: record.DeliveryID, FinalAttemptNo: 3, FailureClassification: "retry_exhausted", ProviderSummary: "smtp timeout", CreatedAt: record.DeadLetteredAt.Add(time.Second), RecoveryHint: "check SMTP connectivity", } require.NoError(t, entry.ValidateFor(record)) return entry } func validEnvelope() Envelope { return Envelope{ To: []common.Email{"pilot@example.com"}, } } func ptrTime(value time.Time) *time.Time { return &value }