feat: mail service
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/mail/internal/domain/attempt"
|
||||
"galaxy/mail/internal/domain/common"
|
||||
deliverydomain "galaxy/mail/internal/domain/delivery"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAtomicWriterCreateAcceptanceStoresStateWithoutIdempotencyRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.SentAt = nil
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
firstAttempt := validScheduledAttempt(t, record.DeliveryID)
|
||||
input := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(firstAttempt),
|
||||
DeliveryPayload: ptr(validDeliveryPayload(t, record.DeliveryID)),
|
||||
}
|
||||
|
||||
require.NoError(t, writer.CreateAcceptance(context.Background(), input))
|
||||
|
||||
storedDelivery, err := client.Get(context.Background(), Keyspace{}.Delivery(record.DeliveryID)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedDelivery, err := UnmarshalDelivery(storedDelivery)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, decodedDelivery)
|
||||
|
||||
storedAttempt, err := client.Get(context.Background(), Keyspace{}.Attempt(record.DeliveryID, firstAttempt.AttemptNo)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedAttempt, err := UnmarshalAttempt(storedAttempt)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, firstAttempt, decodedAttempt)
|
||||
|
||||
storedDeliveryPayload, err := client.Get(context.Background(), Keyspace{}.DeliveryPayload(record.DeliveryID)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedDeliveryPayload, err := UnmarshalDeliveryPayload(storedDeliveryPayload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, *input.DeliveryPayload, decodedDeliveryPayload)
|
||||
|
||||
scheduledDeliveries, err := client.ZRange(context.Background(), Keyspace{}.AttemptSchedule(), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{record.DeliveryID.String()}, scheduledDeliveries)
|
||||
|
||||
recipientMembers, err := client.ZRange(context.Background(), Keyspace{}.RecipientIndex(record.Envelope.To[0]), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{record.DeliveryID.String()}, recipientMembers)
|
||||
|
||||
idempotencyMembers, err := client.ZRange(context.Background(), Keyspace{}.IdempotencyIndex(record.Source, record.IdempotencyKey), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{record.DeliveryID.String()}, idempotencyMembers)
|
||||
}
|
||||
|
||||
func TestAtomicWriterCreateAcceptanceDetectsDuplicateIdempotencyRace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.SentAt = nil
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
input := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
|
||||
DeliveryPayload: ptr(validDeliveryPayload(t, record.DeliveryID)),
|
||||
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
|
||||
}
|
||||
|
||||
const contenders = 8
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
successes int
|
||||
conflicts int
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
for range contenders {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
err := writer.CreateAcceptance(context.Background(), input)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch {
|
||||
case err == nil:
|
||||
successes++
|
||||
case errors.Is(err, ErrConflict):
|
||||
conflicts++
|
||||
default:
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
require.Equal(t, 1, successes)
|
||||
require.Equal(t, contenders-1, conflicts)
|
||||
|
||||
require.True(t, server.Exists(Keyspace{}.Delivery(record.DeliveryID)))
|
||||
require.NotNil(t, input.FirstAttempt)
|
||||
require.True(t, server.Exists(Keyspace{}.Attempt(record.DeliveryID, input.FirstAttempt.AttemptNo)))
|
||||
require.True(t, server.Exists(Keyspace{}.DeliveryPayload(record.DeliveryID)))
|
||||
require.True(t, server.Exists(Keyspace{}.Idempotency(record.Source, record.IdempotencyKey)))
|
||||
|
||||
scheduleCard, err := client.ZCard(context.Background(), Keyspace{}.AttemptSchedule()).Result()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, scheduleCard)
|
||||
|
||||
createdAtCard, err := client.ZCard(context.Background(), Keyspace{}.CreatedAtIndex()).Result()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, createdAtCard)
|
||||
|
||||
idempotencyCard, err := client.ZCard(context.Background(), Keyspace{}.IdempotencyIndex(record.Source, record.IdempotencyKey)).Result()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, idempotencyCard)
|
||||
}
|
||||
|
||||
func TestCreateAcceptanceInputValidateRejectsMismatchedDeliveryPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.SentAt = nil
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
payload := validDeliveryPayload(t, common.DeliveryID("delivery-other"))
|
||||
input := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
|
||||
DeliveryPayload: &payload,
|
||||
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
|
||||
}
|
||||
|
||||
err := input.Validate()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "delivery payload delivery id must match delivery id")
|
||||
}
|
||||
|
||||
func TestCreateAcceptanceInputValidateRejectsMismatchedIdempotency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.SentAt = nil
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
input := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
|
||||
Idempotency: ptr(validIdempotencyRecord(t, deliverydomain.SourceAuthSession, record.DeliveryID, record.IdempotencyKey)),
|
||||
}
|
||||
|
||||
err := input.Validate()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "idempotency source must match delivery source")
|
||||
}
|
||||
|
||||
func TestCreateAcceptanceInputValidateRejectsUnexpectedIdempotencyRetention(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceNotification
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusQueued
|
||||
record.SentAt = nil
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
idempotencyRecord := validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)
|
||||
idempotencyRecord.ExpiresAt = idempotencyRecord.CreatedAt.Add(time.Hour)
|
||||
|
||||
input := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
|
||||
Idempotency: ptr(idempotencyRecord),
|
||||
}
|
||||
|
||||
err := input.Validate()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "idempotency retention must equal")
|
||||
}
|
||||
|
||||
func TestAtomicWriterCreateAcceptanceStoresSuppressedStateWithoutAttempt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validDelivery(t)
|
||||
record.Source = deliverydomain.SourceAuthSession
|
||||
record.ResendParentDeliveryID = ""
|
||||
record.Status = deliverydomain.StatusSuppressed
|
||||
record.AttemptCount = 0
|
||||
record.LastAttemptStatus = ""
|
||||
record.ProviderSummary = ""
|
||||
record.LocaleFallbackUsed = false
|
||||
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
|
||||
record.SentAt = nil
|
||||
record.SuppressedAt = ptr(record.UpdatedAt)
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
input := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
|
||||
}
|
||||
|
||||
require.NoError(t, writer.CreateAcceptance(context.Background(), input))
|
||||
|
||||
storedDelivery, err := client.Get(context.Background(), Keyspace{}.Delivery(record.DeliveryID)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedDelivery, err := UnmarshalDelivery(storedDelivery)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, decodedDelivery)
|
||||
|
||||
require.False(t, server.Exists(Keyspace{}.Attempt(record.DeliveryID, 1)))
|
||||
|
||||
scheduleCard, err := client.ZCard(context.Background(), Keyspace{}.AttemptSchedule()).Result()
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, scheduleCard)
|
||||
}
|
||||
|
||||
func TestAtomicWriterMarkRenderedUpdatesDeliveryAndStatusIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validQueuedTemplateDelivery(t)
|
||||
firstAttempt := validScheduledAttempt(t, record.DeliveryID)
|
||||
createInput := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(firstAttempt),
|
||||
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
|
||||
}
|
||||
require.NoError(t, writer.CreateAcceptance(context.Background(), createInput))
|
||||
|
||||
rendered := record
|
||||
rendered.Status = deliverydomain.StatusRendered
|
||||
rendered.Content = deliverydomain.Content{
|
||||
Subject: "Turn 54",
|
||||
TextBody: "Hello Pilot",
|
||||
HTMLBody: "<p>Hello Pilot</p>",
|
||||
}
|
||||
rendered.LocaleFallbackUsed = true
|
||||
rendered.UpdatedAt = rendered.CreatedAt.Add(time.Minute)
|
||||
require.NoError(t, rendered.Validate())
|
||||
|
||||
require.NoError(t, writer.MarkRendered(context.Background(), MarkRenderedInput{
|
||||
Delivery: rendered,
|
||||
}))
|
||||
|
||||
storedDelivery, err := client.Get(context.Background(), Keyspace{}.Delivery(record.DeliveryID)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedDelivery, err := UnmarshalDelivery(storedDelivery)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rendered, decodedDelivery)
|
||||
|
||||
queuedMembers, err := client.ZRange(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusQueued), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, queuedMembers)
|
||||
|
||||
renderedMembers, err := client.ZRange(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusRendered), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{record.DeliveryID.String()}, renderedMembers)
|
||||
}
|
||||
|
||||
func TestAtomicWriterMarkRenderFailedUpdatesDeliveryAttemptAndStatusIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validQueuedTemplateDelivery(t)
|
||||
firstAttempt := validScheduledAttempt(t, record.DeliveryID)
|
||||
createInput := CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(firstAttempt),
|
||||
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
|
||||
}
|
||||
require.NoError(t, writer.CreateAcceptance(context.Background(), createInput))
|
||||
|
||||
failed := record
|
||||
failed.Status = deliverydomain.StatusFailed
|
||||
failed.LastAttemptStatus = attempt.StatusRenderFailed
|
||||
failed.ProviderSummary = "missing required variables: player.name"
|
||||
failed.UpdatedAt = failed.CreatedAt.Add(time.Minute)
|
||||
failed.FailedAt = ptr(failed.UpdatedAt)
|
||||
require.NoError(t, failed.Validate())
|
||||
|
||||
renderFailedAttempt := validRenderFailedAttempt(t, record.DeliveryID)
|
||||
|
||||
require.NoError(t, writer.MarkRenderFailed(context.Background(), MarkRenderFailedInput{
|
||||
Delivery: failed,
|
||||
Attempt: renderFailedAttempt,
|
||||
}))
|
||||
|
||||
storedDelivery, err := client.Get(context.Background(), Keyspace{}.Delivery(record.DeliveryID)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedDelivery, err := UnmarshalDelivery(storedDelivery)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, failed, decodedDelivery)
|
||||
|
||||
storedAttempt, err := client.Get(context.Background(), Keyspace{}.Attempt(record.DeliveryID, 1)).Bytes()
|
||||
require.NoError(t, err)
|
||||
decodedAttempt, err := UnmarshalAttempt(storedAttempt)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, renderFailedAttempt, decodedAttempt)
|
||||
|
||||
queuedMembers, err := client.ZRange(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusQueued), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, queuedMembers)
|
||||
|
||||
failedMembers, err := client.ZRange(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusFailed), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{record.DeliveryID.String()}, failedMembers)
|
||||
|
||||
scheduledMembers, err := client.ZRange(context.Background(), Keyspace{}.AttemptSchedule(), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, scheduledMembers)
|
||||
}
|
||||
|
||||
func TestAtomicWriterMarkRenderedRejectsUnexpectedCurrentState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
|
||||
writer, err := NewAtomicWriter(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := validQueuedTemplateDelivery(t)
|
||||
firstAttempt := validScheduledAttempt(t, record.DeliveryID)
|
||||
require.NoError(t, writer.CreateAcceptance(context.Background(), CreateAcceptanceInput{
|
||||
Delivery: record,
|
||||
FirstAttempt: ptr(firstAttempt),
|
||||
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
|
||||
}))
|
||||
|
||||
failed := record
|
||||
failed.Status = deliverydomain.StatusFailed
|
||||
failed.LastAttemptStatus = attempt.StatusRenderFailed
|
||||
failed.ProviderSummary = "missing required variables: player.name"
|
||||
failed.UpdatedAt = failed.CreatedAt.Add(time.Minute)
|
||||
failed.FailedAt = ptr(failed.UpdatedAt)
|
||||
require.NoError(t, failed.Validate())
|
||||
require.NoError(t, writer.MarkRenderFailed(context.Background(), MarkRenderFailedInput{
|
||||
Delivery: failed,
|
||||
Attempt: validRenderFailedAttempt(t, record.DeliveryID),
|
||||
}))
|
||||
|
||||
rendered := record
|
||||
rendered.Status = deliverydomain.StatusRendered
|
||||
rendered.Content = deliverydomain.Content{
|
||||
Subject: "Turn 54",
|
||||
TextBody: "Hello Pilot",
|
||||
}
|
||||
rendered.UpdatedAt = rendered.CreatedAt.Add(2 * time.Minute)
|
||||
require.NoError(t, rendered.Validate())
|
||||
|
||||
err = writer.MarkRendered(context.Background(), MarkRenderedInput{Delivery: rendered})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrConflict)
|
||||
}
|
||||
|
||||
func ptr[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
|
||||
var _ = attempt.Attempt{}
|
||||
Reference in New Issue
Block a user