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: "

Hello Pilot

", } 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{}