430 lines
15 KiB
Go
430 lines
15 KiB
Go
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{}
|