feat: mail service
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user