274 lines
8.3 KiB
Go
274 lines
8.3 KiB
Go
package resenddelivery
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/mail/internal/domain/attempt"
|
|
"galaxy/mail/internal/domain/common"
|
|
deliverydomain "galaxy/mail/internal/domain/delivery"
|
|
"galaxy/mail/internal/service/acceptgenericdelivery"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
|
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
|
)
|
|
|
|
func TestServiceExecuteRejectsNonTerminalStatus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []deliverydomain.Status{
|
|
deliverydomain.StatusAccepted,
|
|
deliverydomain.StatusQueued,
|
|
deliverydomain.StatusRendered,
|
|
deliverydomain.StatusSending,
|
|
}
|
|
|
|
for _, status := range tests {
|
|
status := status
|
|
|
|
t.Run(string(status), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
record := validOriginalDelivery()
|
|
record.Status = status
|
|
record.SentAt = nil
|
|
record.FailedAt = nil
|
|
record.DeadLetteredAt = nil
|
|
record.SuppressedAt = nil
|
|
require.NoError(t, record.Validate())
|
|
|
|
store := &stubStore{delivery: &record}
|
|
service := newTestService(t, Config{
|
|
Store: store,
|
|
DeliveryIDGenerator: &stubIDGenerator{ids: []common.DeliveryID{"clone-1"}},
|
|
Clock: stubClock{now: fixedNow()},
|
|
})
|
|
|
|
_, err := service.Execute(context.Background(), Input{DeliveryID: record.DeliveryID})
|
|
require.ErrorIs(t, err, ErrNotAllowed)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServiceExecuteCreatesLinkedClone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
original := validOriginalDelivery()
|
|
originalCopy := original
|
|
payload := validPayload(original.DeliveryID)
|
|
store := &stubStore{
|
|
delivery: &original,
|
|
payload: &payload,
|
|
}
|
|
service := newTestService(t, Config{
|
|
Store: store,
|
|
DeliveryIDGenerator: &stubIDGenerator{ids: []common.DeliveryID{"clone-123"}},
|
|
Clock: stubClock{now: fixedNow()},
|
|
})
|
|
|
|
result, err := service.Execute(context.Background(), Input{DeliveryID: original.DeliveryID})
|
|
require.NoError(t, err)
|
|
require.Equal(t, Result{DeliveryID: common.DeliveryID("clone-123")}, result)
|
|
require.Len(t, store.createInputs, 1)
|
|
|
|
createInput := store.createInputs[0]
|
|
require.Equal(t, common.DeliveryID("clone-123"), createInput.Delivery.DeliveryID)
|
|
require.Equal(t, original.DeliveryID, createInput.Delivery.ResendParentDeliveryID)
|
|
require.Equal(t, deliverydomain.SourceOperatorResend, createInput.Delivery.Source)
|
|
require.Equal(t, common.IdempotencyKey("operator:resend:"+original.DeliveryID.String()), createInput.Delivery.IdempotencyKey)
|
|
require.Equal(t, deliverydomain.StatusQueued, createInput.Delivery.Status)
|
|
require.Equal(t, 1, createInput.Delivery.AttemptCount)
|
|
require.Empty(t, createInput.Delivery.LastAttemptStatus)
|
|
require.Nil(t, createInput.Delivery.SentAt)
|
|
require.Nil(t, createInput.Delivery.FailedAt)
|
|
require.Equal(t, attempt.StatusScheduled, createInput.FirstAttempt.Status)
|
|
require.Equal(t, 1, createInput.FirstAttempt.AttemptNo)
|
|
require.NotNil(t, createInput.DeliveryPayload)
|
|
require.Equal(t, common.DeliveryID("clone-123"), createInput.DeliveryPayload.DeliveryID)
|
|
require.Equal(t, payload.Attachments, createInput.DeliveryPayload.Attachments)
|
|
require.Equal(t, originalCopy, original)
|
|
}
|
|
|
|
func TestServiceExecuteLogsCloneCreationAndCreatesSpan(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
original := validOriginalDelivery()
|
|
payload := validPayload(original.DeliveryID)
|
|
loggerBuffer := &bytes.Buffer{}
|
|
recorder := tracetest.NewSpanRecorder()
|
|
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
|
telemetry := &stubTelemetry{}
|
|
|
|
store := &stubStore{
|
|
delivery: &original,
|
|
payload: &payload,
|
|
}
|
|
service := newTestService(t, Config{
|
|
Store: store,
|
|
DeliveryIDGenerator: &stubIDGenerator{ids: []common.DeliveryID{"clone-456"}},
|
|
Clock: stubClock{now: fixedNow()},
|
|
Telemetry: telemetry,
|
|
TracerProvider: tracerProvider,
|
|
Logger: slog.New(slog.NewJSONHandler(loggerBuffer, nil)),
|
|
})
|
|
|
|
_, err := service.Execute(context.Background(), Input{DeliveryID: original.DeliveryID})
|
|
require.NoError(t, err)
|
|
require.Equal(t, []string{"operator_resend:queued"}, telemetry.statuses)
|
|
require.Contains(t, loggerBuffer.String(), "\"delivery_id\":\"clone-456\"")
|
|
require.Contains(t, loggerBuffer.String(), "\"source\":\"operator_resend\"")
|
|
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn.ready\"")
|
|
require.Contains(t, loggerBuffer.String(), "\"otel_trace_id\":")
|
|
require.True(t, hasResendSpanNamed(recorder.Ended(), "mail.resend_delivery"))
|
|
}
|
|
|
|
type stubStore struct {
|
|
delivery *deliverydomain.Delivery
|
|
payload *acceptgenericdelivery.DeliveryPayload
|
|
createInputs []CreateResendInput
|
|
}
|
|
|
|
func (store *stubStore) GetDelivery(context.Context, common.DeliveryID) (deliverydomain.Delivery, bool, error) {
|
|
if store.delivery == nil {
|
|
return deliverydomain.Delivery{}, false, nil
|
|
}
|
|
|
|
return *store.delivery, true, nil
|
|
}
|
|
|
|
func (store *stubStore) GetDeliveryPayload(context.Context, common.DeliveryID) (acceptgenericdelivery.DeliveryPayload, bool, error) {
|
|
if store.payload == nil {
|
|
return acceptgenericdelivery.DeliveryPayload{}, false, nil
|
|
}
|
|
|
|
return *store.payload, true, nil
|
|
}
|
|
|
|
func (store *stubStore) CreateResend(_ context.Context, input CreateResendInput) error {
|
|
store.createInputs = append(store.createInputs, input)
|
|
return nil
|
|
}
|
|
|
|
type stubIDGenerator struct {
|
|
ids []common.DeliveryID
|
|
}
|
|
|
|
func (generator *stubIDGenerator) NewDeliveryID() (common.DeliveryID, error) {
|
|
if len(generator.ids) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
next := generator.ids[0]
|
|
generator.ids = generator.ids[1:]
|
|
return next, nil
|
|
}
|
|
|
|
type stubClock struct {
|
|
now time.Time
|
|
}
|
|
|
|
func (clock stubClock) Now() time.Time {
|
|
return clock.now
|
|
}
|
|
|
|
type stubTelemetry struct {
|
|
statuses []string
|
|
}
|
|
|
|
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 fixedNow() time.Time {
|
|
return time.Unix(1_775_122_100, 0).UTC()
|
|
}
|
|
|
|
func validOriginalDelivery() deliverydomain.Delivery {
|
|
createdAt := time.Unix(1_775_121_700, 0).UTC()
|
|
updatedAt := createdAt.Add(time.Minute)
|
|
sentAt := updatedAt
|
|
|
|
record := deliverydomain.Delivery{
|
|
DeliveryID: common.DeliveryID("delivery-original"),
|
|
Source: deliverydomain.SourceNotification,
|
|
PayloadMode: deliverydomain.PayloadModeTemplate,
|
|
TemplateID: common.TemplateID("game.turn.ready"),
|
|
Envelope: deliverydomain.Envelope{
|
|
To: []common.Email{common.Email("pilot@example.com")},
|
|
Cc: []common.Email{common.Email("copilot@example.com")},
|
|
Bcc: []common.Email{common.Email("ops@example.com")},
|
|
ReplyTo: []common.Email{common.Email("noreply@example.com")},
|
|
},
|
|
Content: deliverydomain.Content{
|
|
Subject: "Turn ready",
|
|
TextBody: "Your next turn is ready",
|
|
},
|
|
Attachments: []common.AttachmentMetadata{
|
|
{Filename: "instructions.txt", ContentType: "text/plain; charset=utf-8", SizeBytes: 7},
|
|
},
|
|
Locale: common.Locale("en"),
|
|
TemplateVariables: map[string]any{"turn": 7},
|
|
LocaleFallbackUsed: true,
|
|
IdempotencyKey: common.IdempotencyKey("notification:delivery-original"),
|
|
Status: deliverydomain.StatusSent,
|
|
AttemptCount: 2,
|
|
LastAttemptStatus: attempt.StatusProviderAccepted,
|
|
ProviderSummary: "provider=smtp result=accepted",
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
SentAt: &sentAt,
|
|
}
|
|
if err := record.Validate(); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return record
|
|
}
|
|
|
|
func validPayload(deliveryID common.DeliveryID) acceptgenericdelivery.DeliveryPayload {
|
|
payload := acceptgenericdelivery.DeliveryPayload{
|
|
DeliveryID: deliveryID,
|
|
Attachments: []acceptgenericdelivery.AttachmentPayload{
|
|
{
|
|
Filename: "instructions.txt",
|
|
ContentType: "text/plain; charset=utf-8",
|
|
ContentBase64: "cmVhZCBtZQ==",
|
|
SizeBytes: 7,
|
|
},
|
|
},
|
|
}
|
|
if err := payload.Validate(); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
var _ Store = (*stubStore)(nil)
|
|
var _ DeliveryIDGenerator = (*stubIDGenerator)(nil)
|
|
var _ Clock = stubClock{}
|
|
var _ Telemetry = (*stubTelemetry)(nil)
|
|
|
|
func hasResendSpanNamed(spans []sdktrace.ReadOnlySpan, name string) bool {
|
|
for _, span := range spans {
|
|
if span.Name() == name {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|