package worker import ( "context" "testing" "time" redisstate "galaxy/notification/internal/adapters/redisstate" "galaxy/notification/internal/service/acceptintent" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" ) func TestEmailPublisherPublishesDueEmailRouteAndLeavesPushRoutePending(t *testing.T) { t.Parallel() fixture := newEmailPublisherFixture(t) require.NoError(t, fixture.store.CreateAcceptance(context.Background(), validEmailAcceptanceInput(fixture.now, 0))) running := runEmailPublisher(t, fixture.publisher) defer running.stop(t) require.Eventually(t, func() bool { route, found, err := fixture.store.GetRoute(context.Background(), "1775121700000-0", "email:user:user-1") return err == nil && found && route.Status == acceptintent.RouteStatusPublished }, time.Second, 10*time.Millisecond) pushRoute, found, err := fixture.store.GetRoute(context.Background(), "1775121700000-0", "push:user:user-1") require.NoError(t, err) require.True(t, found) require.Equal(t, acceptintent.RouteStatusPending, pushRoute.Status) messages, err := fixture.client.XRange(context.Background(), fixture.mailStream, "-", "+").Result() require.NoError(t, err) require.Len(t, messages, 1) require.Equal(t, "1775121700000-0/email:user:user-1", messages[0].Values["delivery_id"]) require.Equal(t, "notification", messages[0].Values["source"]) require.Equal(t, "template", messages[0].Values["payload_mode"]) require.True(t, fixture.telemetry.hasRoutePublishAttempt("email", "published", "")) } func TestEmailPublisherRetriesMailStreamPublicationFailures(t *testing.T) { t.Parallel() fixture := newEmailPublisherFixture(t) require.NoError(t, fixture.store.CreateAcceptance(context.Background(), validEmailAcceptanceInput(fixture.now, 0))) require.NoError(t, fixture.client.Set(context.Background(), fixture.mailStream, "wrong-type", 0).Err()) running := runEmailPublisher(t, fixture.publisher) defer running.stop(t) require.Eventually(t, func() bool { route, found, err := fixture.store.GetRoute(context.Background(), "1775121700000-0", "email:user:user-1") return err == nil && found && route.Status == acceptintent.RouteStatusFailed && route.AttemptCount == 1 }, time.Second, 10*time.Millisecond) require.True(t, fixture.telemetry.hasRoutePublishAttempt("email", "retry", emailFailureClassificationMailStreamWrite)) require.True(t, fixture.telemetry.hasRouteRetry("email")) require.NoError(t, fixture.client.Del(context.Background(), fixture.mailStream).Err()) require.Eventually(t, func() bool { route, found, err := fixture.store.GetRoute(context.Background(), "1775121700000-0", "email:user:user-1") return err == nil && found && route.Status == acceptintent.RouteStatusPublished && route.AttemptCount == 2 }, 2*time.Second, 10*time.Millisecond) messages, err := fixture.client.XRange(context.Background(), fixture.mailStream, "-", "+").Result() require.NoError(t, err) require.Len(t, messages, 1) require.True(t, fixture.telemetry.hasRoutePublishAttempt("email", "published", "")) } func TestEmailPublisherLeasePreventsDuplicatePublicationAcrossReplicas(t *testing.T) { t.Parallel() fixture := newEmailPublisherFixture(t) require.NoError(t, fixture.store.CreateAcceptance(context.Background(), validEmailAcceptanceInput(fixture.now, 0))) otherPublisher, err := NewEmailPublisher(EmailPublisherConfig{ Store: fixture.store, MailDeliveryCommandsStream: fixture.mailStream, RouteLeaseTTL: 200 * time.Millisecond, RouteBackoffMin: 20 * time.Millisecond, RouteBackoffMax: 20 * time.Millisecond, PollInterval: 10 * time.Millisecond, BatchSize: 16, Clock: newSteppingClock(fixture.now, time.Millisecond), }, testWorkerLogger()) require.NoError(t, err) first := runEmailPublisher(t, fixture.publisher) defer first.stop(t) second := runEmailPublisher(t, otherPublisher) defer second.stop(t) require.Eventually(t, func() bool { route, found, err := fixture.store.GetRoute(context.Background(), "1775121700000-0", "email:user:user-1") return err == nil && found && route.Status == acceptintent.RouteStatusPublished }, time.Second, 10*time.Millisecond) messages, err := fixture.client.XRange(context.Background(), fixture.mailStream, "-", "+").Result() require.NoError(t, err) require.Len(t, messages, 1) } func TestEmailPublisherDeadLettersExhaustedRoute(t *testing.T) { t.Parallel() fixture := newEmailPublisherFixture(t) require.NoError(t, fixture.store.CreateAcceptance(context.Background(), validEmailAcceptanceInput(fixture.now, 6))) require.NoError(t, fixture.client.Set(context.Background(), fixture.mailStream, "wrong-type", 0).Err()) running := runEmailPublisher(t, fixture.publisher) defer running.stop(t) require.Eventually(t, func() bool { route, found, err := fixture.store.GetRoute(context.Background(), "1775121700000-0", "email:user:user-1") return err == nil && found && route.Status == acceptintent.RouteStatusDeadLetter && route.AttemptCount == 7 }, time.Second, 10*time.Millisecond) deadLetterPayload, err := fixture.client.Get(context.Background(), redisstate.Keyspace{}.DeadLetter("1775121700000-0", "email:user:user-1")).Bytes() require.NoError(t, err) deadLetter, err := redisstate.UnmarshalDeadLetter(deadLetterPayload) require.NoError(t, err) require.Equal(t, emailFailureClassificationMailStreamWrite, deadLetter.FailureClassification) require.True(t, fixture.telemetry.hasRoutePublishAttempt("email", "dead_letter", emailFailureClassificationMailStreamWrite)) require.True(t, fixture.telemetry.hasRouteDeadLetter("email", emailFailureClassificationMailStreamWrite)) } type emailPublisherFixture struct { client *redis.Client store *redisstate.AcceptanceStore publisher *EmailPublisher mailStream string now time.Time clock *steppingClock telemetry *recordingWorkerTelemetry } func newEmailPublisherFixture(t *testing.T) emailPublisherFixture { t.Helper() server := miniredis.RunT(t) client := redis.NewClient(&redis.Options{ Addr: server.Addr(), Protocol: 2, DisableIdentity: true, }) t.Cleanup(func() { require.NoError(t, client.Close()) }) store, err := redisstate.NewAcceptanceStore(client, redisstate.AcceptanceConfig{ RecordTTL: 24 * time.Hour, DeadLetterTTL: 72 * time.Hour, IdempotencyTTL: 7 * 24 * time.Hour, }) require.NoError(t, err) now := time.UnixMilli(1775121700000).UTC() clock := newSteppingClock(now, time.Millisecond) telemetry := &recordingWorkerTelemetry{} publisher, err := NewEmailPublisher(EmailPublisherConfig{ Store: store, MailDeliveryCommandsStream: "mail:delivery_commands", RouteLeaseTTL: 200 * time.Millisecond, RouteBackoffMin: 20 * time.Millisecond, RouteBackoffMax: 20 * time.Millisecond, PollInterval: 10 * time.Millisecond, BatchSize: 16, Telemetry: telemetry, Clock: clock, }, testWorkerLogger()) require.NoError(t, err) return emailPublisherFixture{ client: client, store: store, publisher: publisher, mailStream: "mail:delivery_commands", now: now, clock: clock, telemetry: telemetry, } } func validEmailAcceptanceInput(now time.Time, emailAttemptCount int) acceptintent.CreateAcceptanceInput { input := validPushAcceptanceInput(now) for index := range input.Routes { if input.Routes[index].RouteID != "email:user:user-1" { continue } input.Routes[index].AttemptCount = emailAttemptCount input.Routes[index].MaxAttempts = 7 } return input } type runningEmailPublisher struct { cancel context.CancelFunc resultCh chan error } func runEmailPublisher(t *testing.T, publisher *EmailPublisher) runningEmailPublisher { t.Helper() ctx, cancel := context.WithCancel(context.Background()) resultCh := make(chan error, 1) go func() { resultCh <- publisher.Run(ctx) }() return runningEmailPublisher{ cancel: cancel, resultCh: resultCh, } } func (r runningEmailPublisher) stop(t *testing.T) { t.Helper() r.cancel() select { case err := <-r.resultCh: require.ErrorIs(t, err, context.Canceled) case <-time.After(time.Second): require.FailNow(t, "email publisher did not stop") } }