package worker import ( "context" "errors" "io" "log/slog" "testing" "time" "galaxy/mail/internal/adapters/redisstate" "galaxy/mail/internal/service/acceptgenericdelivery" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" ) func TestCommandConsumerAcceptsRenderedCommand(t *testing.T) { t.Parallel() fixture := newCommandConsumerFixture(t) messageID := addRenderedCommand(t, fixture.client, "mail-123", "notification:mail-123") ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- fixture.consumer.Run(ctx) }() require.Eventually(t, func() bool { delivery, found, err := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-123") if err != nil || !found { return false } entryID, found, err := fixture.offsetStore.Load(context.Background(), fixture.stream) return err == nil && found && entryID == messageID && delivery.DeliveryID == "mail-123" }, 5*time.Second, 20*time.Millisecond) cancel() require.ErrorIs(t, <-done, context.Canceled) } func TestCommandConsumerAcceptsTemplateCommand(t *testing.T) { t.Parallel() fixture := newCommandConsumerFixture(t) messageID := addTemplateCommand(t, fixture.client, "mail-124", "notification:mail-124") ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- fixture.consumer.Run(ctx) }() require.Eventually(t, func() bool { delivery, found, err := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-124") if err != nil || !found { return false } entryID, found, err := fixture.offsetStore.Load(context.Background(), fixture.stream) return err == nil && found && entryID == messageID && delivery.TemplateID == "game.turn.ready" }, 5*time.Second, 20*time.Millisecond) cancel() require.ErrorIs(t, <-done, context.Canceled) } func TestCommandConsumerRecordsMalformedCommandAndContinues(t *testing.T) { t.Parallel() fixture := newCommandConsumerFixture(t) malformedID := addMalformedRenderedCommand(t, fixture.client, "mail-bad", "notification:mail-bad") validID := addRenderedCommand(t, fixture.client, "mail-125", "notification:mail-125") ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- fixture.consumer.Run(ctx) }() require.Eventually(t, func() bool { _, deliveryFound, deliveryErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-125") entry, malformedFound, malformedErr := fixture.malformedStore.Get(context.Background(), malformedID) entryID, offsetFound, offsetErr := fixture.offsetStore.Load(context.Background(), fixture.stream) return deliveryErr == nil && malformedErr == nil && offsetErr == nil && deliveryFound && malformedFound && entry.FailureCode == "invalid_payload" && offsetFound && entryID == validID }, 5*time.Second, 20*time.Millisecond) cancel() require.ErrorIs(t, <-done, context.Canceled) } func TestCommandConsumerRestartsFromSavedOffset(t *testing.T) { t.Parallel() fixture := newCommandConsumerFixture(t) firstID := addRenderedCommand(t, fixture.client, "mail-126", "notification:mail-126") firstCtx, firstCancel := context.WithCancel(context.Background()) firstDone := make(chan error, 1) go func() { firstDone <- fixture.consumer.Run(firstCtx) }() require.Eventually(t, func() bool { entryID, found, err := fixture.offsetStore.Load(context.Background(), fixture.stream) return err == nil && found && entryID == firstID }, 5*time.Second, 20*time.Millisecond) firstCancel() require.ErrorIs(t, <-firstDone, context.Canceled) secondID := addRenderedCommand(t, fixture.client, "mail-127", "notification:mail-127") secondCtx, secondCancel := context.WithCancel(context.Background()) secondDone := make(chan error, 1) go func() { secondDone <- fixture.consumer.Run(secondCtx) }() require.Eventually(t, func() bool { _, firstFound, firstErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-126") _, secondFound, secondErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-127") entryID, offsetFound, offsetErr := fixture.offsetStore.Load(context.Background(), fixture.stream) return firstErr == nil && secondErr == nil && offsetErr == nil && firstFound && secondFound && offsetFound && entryID == secondID }, 5*time.Second, 20*time.Millisecond) secondCancel() require.ErrorIs(t, <-secondDone, context.Canceled) } func TestCommandConsumerDoesNotDuplicateAcceptanceAfterOffsetSaveFailure(t *testing.T) { t.Parallel() fixture := newCommandConsumerFixture(t) messageID := addRenderedCommand(t, fixture.client, "mail-128", "notification:mail-128") failingOffsetStore := &scriptedOffsetStore{ saveErrs: []error{errors.New("offset unavailable")}, } consumer := newCommandConsumerForTest(t, fixture.client, fixture.stream, fixture.acceptor, fixture.malformedStore, failingOffsetStore) err := consumer.Run(context.Background()) require.Error(t, err) require.ErrorContains(t, err, "save stream offset") delivery, found, err := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-128") require.NoError(t, err) require.True(t, found) require.Equal(t, "mail-128", delivery.DeliveryID.String()) indexCard, err := fixture.client.ZCard(context.Background(), redisstate.Keyspace{}.CreatedAtIndex()).Result() require.NoError(t, err) require.EqualValues(t, 1, indexCard) replayConsumer := newCommandConsumerForTest(t, fixture.client, fixture.stream, fixture.acceptor, fixture.malformedStore, failingOffsetStore) replayCtx, replayCancel := context.WithCancel(context.Background()) replayDone := make(chan error, 1) go func() { replayDone <- replayConsumer.Run(replayCtx) }() require.Eventually(t, func() bool { return failingOffsetStore.lastEntryID == messageID }, 5*time.Second, 20*time.Millisecond) replayCancel() require.ErrorIs(t, <-replayDone, context.Canceled) indexCard, err = fixture.client.ZCard(context.Background(), redisstate.Keyspace{}.CreatedAtIndex()).Result() require.NoError(t, err) require.EqualValues(t, 1, indexCard) scheduleCard, err := fixture.client.ZCard(context.Background(), redisstate.Keyspace{}.AttemptSchedule()).Result() require.NoError(t, err) require.EqualValues(t, 1, scheduleCard) } func TestCommandConsumerRecordsIdempotencyConflictAsMalformed(t *testing.T) { t.Parallel() fixture := newCommandConsumerFixture(t) addRenderedCommand(t, fixture.client, "mail-129", "notification:shared") conflictID := addRenderedCommandWithSubject(t, fixture.client, "mail-130", "notification:shared", "Different subject") ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- fixture.consumer.Run(ctx) }() require.Eventually(t, func() bool { _, firstFound, firstErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-129") _, secondFound, secondErr := fixture.acceptanceStore.GetDelivery(context.Background(), "mail-130") entry, malformedFound, malformedErr := fixture.malformedStore.Get(context.Background(), conflictID) return firstErr == nil && secondErr == nil && malformedErr == nil && firstFound && !secondFound && malformedFound && entry.FailureCode == "idempotency_conflict" }, 5*time.Second, 20*time.Millisecond) cancel() require.ErrorIs(t, <-done, context.Canceled) } type commandConsumerFixture struct { client *redis.Client stream string consumer *CommandConsumer acceptor *acceptgenericdelivery.Service acceptanceStore *redisstate.GenericAcceptanceStore malformedStore *redisstate.MalformedCommandStore offsetStore *redisstate.StreamOffsetStore } func newCommandConsumerFixture(t *testing.T) commandConsumerFixture { t.Helper() server := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: server.Addr()}) t.Cleanup(func() { require.NoError(t, client.Close()) }) acceptanceStore, err := redisstate.NewGenericAcceptanceStore(client) require.NoError(t, err) now := time.Now().UTC().Truncate(time.Millisecond) acceptor, err := acceptgenericdelivery.New(acceptgenericdelivery.Config{ Store: acceptanceStore, Clock: testClock{now: now}, IdempotencyTTL: redisstate.IdempotencyTTL, }) require.NoError(t, err) malformedStore, err := redisstate.NewMalformedCommandStore(client) require.NoError(t, err) offsetStore, err := redisstate.NewStreamOffsetStore(client) require.NoError(t, err) stream := redisstate.Keyspace{}.DeliveryCommands() consumer := newCommandConsumerForTest(t, client, stream, acceptor, malformedStore, offsetStore) return commandConsumerFixture{ client: client, stream: stream, consumer: consumer, acceptor: acceptor, acceptanceStore: acceptanceStore, malformedStore: malformedStore, offsetStore: offsetStore, } } func newCommandConsumerForTest( t *testing.T, client *redis.Client, stream string, acceptor AcceptGenericDeliveryUseCase, malformedRecorder MalformedCommandRecorder, offsetStore StreamOffsetStore, ) *CommandConsumer { t.Helper() consumer, err := NewCommandConsumer(CommandConsumerConfig{ Client: client, Stream: stream, BlockTimeout: 20 * time.Millisecond, Acceptor: acceptor, MalformedRecorder: malformedRecorder, OffsetStore: offsetStore, Clock: testClock{now: time.Now().UTC().Truncate(time.Millisecond)}, }, testLogger()) require.NoError(t, err) return consumer } func addRenderedCommand(t *testing.T, client *redis.Client, deliveryID string, idempotencyKey string) string { t.Helper() return addRenderedCommandWithSubject(t, client, deliveryID, idempotencyKey, "Turn ready") } func addRenderedCommandWithSubject(t *testing.T, client *redis.Client, deliveryID string, idempotencyKey string, subject string) string { t.Helper() messageID, err := client.XAdd(context.Background(), &redis.XAddArgs{ Stream: redisstate.Keyspace{}.DeliveryCommands(), Values: map[string]any{ "delivery_id": deliveryID, "source": "notification", "payload_mode": "rendered", "idempotency_key": idempotencyKey, "requested_at_ms": "1775121700000", "payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":["noreply@example.com"],"subject":"` + subject + `","text_body":"Turn 54 is ready.","html_body":"
Turn 54 is ready.
","attachments":[]}`, }, }).Result() require.NoError(t, err) return messageID } func addTemplateCommand(t *testing.T, client *redis.Client, deliveryID string, idempotencyKey string) string { t.Helper() messageID, err := client.XAdd(context.Background(), &redis.XAddArgs{ Stream: redisstate.Keyspace{}.DeliveryCommands(), Values: map[string]any{ "delivery_id": deliveryID, "source": "notification", "payload_mode": "template", "idempotency_key": idempotencyKey, "requested_at_ms": "1775121700001", "payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn.ready","locale":"fr-FR","variables":{"turn_number":54},"attachments":[]}`, }, }).Result() require.NoError(t, err) return messageID } func addMalformedRenderedCommand(t *testing.T, client *redis.Client, deliveryID string, idempotencyKey string) string { t.Helper() messageID, err := client.XAdd(context.Background(), &redis.XAddArgs{ Stream: redisstate.Keyspace{}.DeliveryCommands(), Values: map[string]any{ "delivery_id": deliveryID, "source": "notification", "payload_mode": "rendered", "idempotency_key": idempotencyKey, "requested_at_ms": "1775121700000", "payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"text_body":"Turn 54 is ready.","attachments":[]}`, }, }).Result() require.NoError(t, err) return messageID } type testClock struct { now time.Time } func (clock testClock) Now() time.Time { return clock.now } type scriptedOffsetStore struct { lastEntryID string found bool saveErrs []error saveCalls int } func (store *scriptedOffsetStore) Load(context.Context, string) (string, bool, error) { if !store.found { return "", false, nil } return store.lastEntryID, true, nil } func (store *scriptedOffsetStore) Save(_ context.Context, _ string, entryID string) error { if store.saveCalls < len(store.saveErrs) && store.saveErrs[store.saveCalls] != nil { store.saveCalls++ return store.saveErrs[store.saveCalls-1] } store.saveCalls++ store.lastEntryID = entryID store.found = true return nil } func testLogger() *slog.Logger { return slog.New(slog.NewJSONHandler(io.Discard, nil)) }