package redisstate import ( "context" "io" "log/slog" "testing" "time" "galaxy/notification/internal/api/intentstream" "galaxy/notification/internal/config" "galaxy/notification/internal/service/acceptintent" "galaxy/notification/internal/service/malformedintent" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" ) func TestAcceptanceStoreCreateAcceptancePersistsNotificationRoutesAndSchedule(t *testing.T) { t.Parallel() server := miniredis.RunT(t) client := newTestRedisClient(t, server) store, err := NewAcceptanceStore(client, AcceptanceConfig{ RecordTTL: 24 * time.Hour, DeadLetterTTL: 72 * time.Hour, IdempotencyTTL: 7 * 24 * time.Hour, }) require.NoError(t, err) now := time.UnixMilli(1775121700000).UTC() input := validAdminAcceptanceInput(now) require.NoError(t, store.CreateAcceptance(context.Background(), input)) notificationRecord, found, err := store.GetNotification(context.Background(), input.Notification.NotificationID) require.NoError(t, err) require.True(t, found) require.Equal(t, input.Notification.NotificationID, notificationRecord.NotificationID) idempotencyRecord, found, err := store.GetIdempotency(context.Background(), input.Idempotency.Producer, input.Idempotency.IdempotencyKey) require.NoError(t, err) require.True(t, found) require.Equal(t, input.Idempotency.RequestFingerprint, idempotencyRecord.RequestFingerprint) pushRoutePayload, err := client.Get(context.Background(), Keyspace{}.Route(input.Notification.NotificationID, "push:email:owner@example.com")).Bytes() require.NoError(t, err) pushRoute, err := UnmarshalRoute(pushRoutePayload) require.NoError(t, err) require.Equal(t, acceptintent.RouteStatusSkipped, pushRoute.Status) emailRouteKey := Keyspace{}.Route(input.Notification.NotificationID, "email:email:owner@example.com") emailRoutePayload, err := client.Get(context.Background(), emailRouteKey).Bytes() require.NoError(t, err) emailRoute, err := UnmarshalRoute(emailRoutePayload) require.NoError(t, err) require.Equal(t, acceptintent.RouteStatusPending, emailRoute.Status) scheduled, err := client.ZRangeWithScores(context.Background(), Keyspace{}.RouteSchedule(), 0, -1).Result() require.NoError(t, err) require.Len(t, scheduled, 1) require.Equal(t, emailRouteKey, scheduled[0].Member) require.Equal(t, float64(now.UnixMilli()), scheduled[0].Score) notificationTTL, err := client.PTTL(context.Background(), Keyspace{}.Notification(input.Notification.NotificationID)).Result() require.NoError(t, err) require.Greater(t, notificationTTL, 23*time.Hour) require.LessOrEqual(t, notificationTTL, 24*time.Hour) routeTTL, err := client.PTTL(context.Background(), emailRouteKey).Result() require.NoError(t, err) require.Greater(t, routeTTL, 23*time.Hour) require.LessOrEqual(t, routeTTL, 24*time.Hour) idempotencyTTL, err := client.PTTL(context.Background(), Keyspace{}.Idempotency(input.Idempotency.Producer, input.Idempotency.IdempotencyKey)).Result() require.NoError(t, err) require.Greater(t, idempotencyTTL, 6*24*time.Hour) require.LessOrEqual(t, idempotencyTTL, 7*24*time.Hour) } func TestMalformedIntentStoreRecordPersistsEntry(t *testing.T) { t.Parallel() server := miniredis.RunT(t) client := newTestRedisClient(t, server) store, err := NewMalformedIntentStore(client, 72*time.Hour) require.NoError(t, err) entry := malformedintent.Entry{ StreamEntryID: "1775121700000-0", NotificationType: "game.turn.ready", Producer: "game_master", IdempotencyKey: "game-123:turn-54", FailureCode: malformedintent.FailureCodeInvalidPayload, FailureMessage: "payload_json.turn_number is required", RawFields: map[string]any{ "notification_type": "game.turn.ready", }, RecordedAt: time.UnixMilli(1775121700000).UTC(), } require.NoError(t, store.Record(context.Background(), entry)) payload, err := client.Get(context.Background(), Keyspace{}.MalformedIntent(entry.StreamEntryID)).Bytes() require.NoError(t, err) recordedEntry, err := UnmarshalMalformedIntent(payload) require.NoError(t, err) require.Equal(t, entry.StreamEntryID, recordedEntry.StreamEntryID) require.Equal(t, entry.FailureCode, recordedEntry.FailureCode) ttl, err := client.PTTL(context.Background(), Keyspace{}.MalformedIntent(entry.StreamEntryID)).Result() require.NoError(t, err) require.Greater(t, ttl, 71*time.Hour) require.LessOrEqual(t, ttl, 72*time.Hour) } func TestStreamOffsetStoreLoadAndSave(t *testing.T) { t.Parallel() server := miniredis.RunT(t) client := newTestRedisClient(t, server) store, err := NewStreamOffsetStore(client) require.NoError(t, err) _, found, err := store.Load(context.Background(), "notification:intents") require.NoError(t, err) require.False(t, found) require.NoError(t, store.Save(context.Background(), "notification:intents", "1775121700000-0")) entryID, found, err := store.Load(context.Background(), "notification:intents") require.NoError(t, err) require.True(t, found) require.Equal(t, "1775121700000-0", entryID) } func TestIntentStreamLagReaderReadsOldestUnprocessedEntry(t *testing.T) { t.Parallel() server := miniredis.RunT(t) client := newTestRedisClient(t, server) store, err := NewStreamOffsetStore(client) require.NoError(t, err) reader, err := NewIntentStreamLagReader(store, "notification:intents") require.NoError(t, err) firstID, err := client.XAdd(context.Background(), &redis.XAddArgs{ Stream: "notification:intents", ID: "1775121700000-0", Values: map[string]any{"payload": "first"}, }).Result() require.NoError(t, err) secondID, err := client.XAdd(context.Background(), &redis.XAddArgs{ Stream: "notification:intents", ID: "1775121701000-0", Values: map[string]any{"payload": "second"}, }).Result() require.NoError(t, err) snapshot, err := reader.ReadIntentStreamLagSnapshot(context.Background()) require.NoError(t, err) require.NotNil(t, snapshot.OldestUnprocessedAt) require.Equal(t, time.UnixMilli(1775121700000).UTC(), *snapshot.OldestUnprocessedAt) require.NoError(t, store.Save(context.Background(), "notification:intents", firstID)) snapshot, err = reader.ReadIntentStreamLagSnapshot(context.Background()) require.NoError(t, err) require.NotNil(t, snapshot.OldestUnprocessedAt) require.Equal(t, time.UnixMilli(1775121701000).UTC(), *snapshot.OldestUnprocessedAt) require.NoError(t, store.Save(context.Background(), "notification:intents", secondID)) snapshot, err = reader.ReadIntentStreamLagSnapshot(context.Background()) require.NoError(t, err) require.Nil(t, snapshot.OldestUnprocessedAt) } func TestAcceptanceStoreWorksWithAcceptIntentService(t *testing.T) { t.Parallel() server := miniredis.RunT(t) client := newTestRedisClient(t, server) store, err := NewAcceptanceStore(client, AcceptanceConfig{ RecordTTL: 24 * time.Hour, DeadLetterTTL: 72 * time.Hour, IdempotencyTTL: 7 * 24 * time.Hour, }) require.NoError(t, err) service, err := acceptintent.New(acceptintent.Config{ Store: store, UserDirectory: staticUserDirectory{}, Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()}, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), PushMaxAttempts: 3, EmailMaxAttempts: 7, IdempotencyTTL: 7 * 24 * time.Hour, AdminRouting: config.AdminRoutingConfig{ LobbyApplicationSubmitted: []string{"owner@example.com"}, }, }) require.NoError(t, err) result, err := service.Execute(context.Background(), acceptintent.AcceptInput{ NotificationID: "1775121700000-0", Intent: intentstream.Intent{ NotificationType: intentstream.NotificationTypeLobbyApplicationSubmitted, Producer: intentstream.ProducerGameLobby, AudienceKind: intentstream.AudienceKindAdminEmail, IdempotencyKey: "game-456:application-submitted:user-42", OccurredAt: time.UnixMilli(1775121700002).UTC(), PayloadJSON: `{"applicant_name":"Nova Pilot","applicant_user_id":"user-42","game_id":"game-456","game_name":"Orion Front"}`, }, }) require.NoError(t, err) require.Equal(t, acceptintent.OutcomeAccepted, result.Outcome) record, found, err := store.GetNotification(context.Background(), "1775121700000-0") require.NoError(t, err) require.True(t, found) require.Equal(t, "1775121700000-0", record.NotificationID) } type fixedClock struct { now time.Time } func (clock fixedClock) Now() time.Time { return clock.now } func validAdminAcceptanceInput(now time.Time) acceptintent.CreateAcceptanceInput { return acceptintent.CreateAcceptanceInput{ Notification: acceptintent.NotificationRecord{ NotificationID: "1775121700000-0", NotificationType: intentstream.NotificationTypeLobbyApplicationSubmitted, Producer: intentstream.ProducerGameLobby, AudienceKind: intentstream.AudienceKindAdminEmail, PayloadJSON: `{"applicant_name":"Nova Pilot","applicant_user_id":"user-42","game_id":"game-456","game_name":"Orion Front"}`, IdempotencyKey: "game-456:application-submitted:user-42", RequestFingerprint: "sha256:deadbeef", OccurredAt: now, AcceptedAt: now, UpdatedAt: now, }, Routes: []acceptintent.NotificationRoute{ { NotificationID: "1775121700000-0", RouteID: "push:email:owner@example.com", Channel: intentstream.ChannelPush, RecipientRef: "email:owner@example.com", Status: acceptintent.RouteStatusSkipped, AttemptCount: 0, MaxAttempts: 3, ResolvedEmail: "owner@example.com", ResolvedLocale: "en", CreatedAt: now, UpdatedAt: now, SkippedAt: now, }, { NotificationID: "1775121700000-0", RouteID: "email:email:owner@example.com", Channel: intentstream.ChannelEmail, RecipientRef: "email:owner@example.com", Status: acceptintent.RouteStatusPending, AttemptCount: 0, MaxAttempts: 7, NextAttemptAt: now, ResolvedEmail: "owner@example.com", ResolvedLocale: "en", CreatedAt: now, UpdatedAt: now, }, }, Idempotency: acceptintent.IdempotencyRecord{ Producer: intentstream.ProducerGameLobby, IdempotencyKey: "game-456:application-submitted:user-42", NotificationID: "1775121700000-0", RequestFingerprint: "sha256:deadbeef", CreatedAt: now, ExpiresAt: now.Add(7 * 24 * time.Hour), }, } } func newTestRedisClient(t *testing.T, server *miniredis.Miniredis) *redis.Client { t.Helper() client := redis.NewClient(&redis.Options{ Addr: server.Addr(), Protocol: 2, DisableIdentity: true, }) t.Cleanup(func() { require.NoError(t, client.Close()) }) return client } type staticUserDirectory struct{} func (staticUserDirectory) GetUserByID(context.Context, string) (acceptintent.UserRecord, error) { return acceptintent.UserRecord{}, acceptintent.ErrRecipientNotFound }