Files
galaxy-game/notification/internal/adapters/redisstate/route_state_store_test.go
T
2026-04-22 08:49:45 +02:00

466 lines
18 KiB
Go

package redisstate
import (
"context"
"testing"
"time"
"galaxy/notification/internal/api/intentstream"
"galaxy/notification/internal/service/acceptintent"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/require"
)
func TestAcceptanceStoreListDueRoutesLoadsScheduledMembers(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()
require.NoError(t, store.CreateAcceptance(context.Background(), validUserAcceptanceInput(now, 0)))
routes, err := store.ListDueRoutes(context.Background(), now, 10)
require.NoError(t, err)
require.Len(t, routes, 2)
require.ElementsMatch(t, []string{"push:user:user-1", "email:user:user-1"}, []string{routes[0].RouteID, routes[1].RouteID})
for _, route := range routes {
require.NoError(t, route.Validate())
}
}
func TestAcceptanceStoreReadRouteScheduleSnapshot(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()
require.NoError(t, store.CreateAcceptance(context.Background(), validUserAcceptanceInput(now, 0)))
snapshot, err := store.ReadRouteScheduleSnapshot(context.Background())
require.NoError(t, err)
require.Equal(t, int64(2), snapshot.Depth)
require.NotNil(t, snapshot.OldestScheduledFor)
require.Equal(t, now, *snapshot.OldestScheduledFor)
}
func TestAcceptanceStoreRouteLeaseAcquireReleaseAndExpire(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)
acquired, err := store.TryAcquireRouteLease(context.Background(), "1775121700000-0", "push:user:user-1", "token-1", 2*time.Second)
require.NoError(t, err)
require.True(t, acquired)
acquired, err = store.TryAcquireRouteLease(context.Background(), "1775121700000-0", "push:user:user-1", "token-2", 2*time.Second)
require.NoError(t, err)
require.False(t, acquired)
require.NoError(t, store.ReleaseRouteLease(context.Background(), "1775121700000-0", "push:user:user-1", "token-1"))
acquired, err = store.TryAcquireRouteLease(context.Background(), "1775121700000-0", "push:user:user-1", "token-3", 2*time.Second)
require.NoError(t, err)
require.True(t, acquired)
server.FastForward(3 * time.Second)
acquired, err = store.TryAcquireRouteLease(context.Background(), "1775121700000-0", "push:user:user-1", "token-4", 2*time.Second)
require.NoError(t, err)
require.True(t, acquired)
}
func TestAcceptanceStoreCompleteRoutePublishedAppendsTrimmedStreamEntryAndMarksRoutePublished(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 := validUserAcceptanceInput(now, 0)
require.NoError(t, store.CreateAcceptance(context.Background(), input))
acquired, err := store.TryAcquireRouteLease(context.Background(), input.Notification.NotificationID, "push:user:user-1", "token-1", 5*time.Second)
require.NoError(t, err)
require.True(t, acquired)
route, found, err := store.GetRoute(context.Background(), input.Notification.NotificationID, "push:user:user-1")
require.NoError(t, err)
require.True(t, found)
publishedAt := now.Add(time.Second).UTC().Truncate(time.Millisecond)
require.NoError(t, store.CompleteRoutePublished(context.Background(), CompleteRoutePublishedInput{
ExpectedRoute: route,
LeaseToken: "token-1",
PublishedAt: publishedAt,
Stream: "gateway:client-events",
StreamMaxLen: 1024,
StreamValues: map[string]any{
"user_id": "user-1",
"event_type": "game.turn.ready",
"event_id": input.Notification.NotificationID + "/push:user:user-1",
"payload_bytes": []byte("payload-1"),
"request_id": "request-1",
"trace_id": "trace-1",
},
}))
updatedRoute, found, err := store.GetRoute(context.Background(), input.Notification.NotificationID, "push:user:user-1")
require.NoError(t, err)
require.True(t, found)
require.Equal(t, acceptintent.RouteStatusPublished, updatedRoute.Status)
require.Equal(t, 1, updatedRoute.AttemptCount)
require.Equal(t, publishedAt, updatedRoute.PublishedAt)
scheduled, err := client.ZRange(context.Background(), Keyspace{}.RouteSchedule(), 0, -1).Result()
require.NoError(t, err)
require.Equal(t, []string{Keyspace{}.Route(input.Notification.NotificationID, "email:user:user-1")}, scheduled)
messages, err := client.XRange(context.Background(), "gateway:client-events", "-", "+").Result()
require.NoError(t, err)
require.Len(t, messages, 1)
require.Equal(t, "user-1", messages[0].Values["user_id"])
require.Equal(t, "game.turn.ready", messages[0].Values["event_type"])
leaseKey := Keyspace{}.RouteLease(input.Notification.NotificationID, "push:user:user-1")
_, err = client.Get(context.Background(), leaseKey).Result()
require.Error(t, err)
}
func TestAcceptanceStoreCompleteRoutePublishedAppendsUntrimmedMailCommand(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 := validUserAcceptanceInput(now, 0)
require.NoError(t, store.CreateAcceptance(context.Background(), input))
acquired, err := store.TryAcquireRouteLease(context.Background(), input.Notification.NotificationID, "email:user:user-1", "token-1", 5*time.Second)
require.NoError(t, err)
require.True(t, acquired)
route, found, err := store.GetRoute(context.Background(), input.Notification.NotificationID, "email:user:user-1")
require.NoError(t, err)
require.True(t, found)
publishedAt := now.Add(time.Second).UTC().Truncate(time.Millisecond)
require.NoError(t, store.CompleteRoutePublished(context.Background(), CompleteRoutePublishedInput{
ExpectedRoute: route,
LeaseToken: "token-1",
PublishedAt: publishedAt,
Stream: "mail:delivery_commands",
StreamMaxLen: 0,
StreamValues: map[string]any{
"delivery_id": input.Notification.NotificationID + "/email:user:user-1",
"source": "notification",
"payload_mode": "template",
"idempotency_key": "notification:" + input.Notification.NotificationID + "/email:user:user-1",
"requested_at_ms": "1775121700000",
"payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn.ready","locale":"en","variables":{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54},"attachments":[]}`,
},
}))
updatedRoute, found, err := store.GetRoute(context.Background(), input.Notification.NotificationID, "email:user:user-1")
require.NoError(t, err)
require.True(t, found)
require.Equal(t, acceptintent.RouteStatusPublished, updatedRoute.Status)
require.Equal(t, 1, updatedRoute.AttemptCount)
require.Equal(t, publishedAt, updatedRoute.PublishedAt)
messages, err := client.XRange(context.Background(), "mail:delivery_commands", "-", "+").Result()
require.NoError(t, err)
require.Len(t, messages, 1)
require.Equal(t, "notification", messages[0].Values["source"])
require.Equal(t, "template", messages[0].Values["payload_mode"])
require.Equal(t, "1775121700000-0/email:user:user-1", messages[0].Values["delivery_id"])
}
func TestAcceptanceStoreCompleteRouteFailedReschedulesRoute(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 := validUserAcceptanceInput(now, 0)
require.NoError(t, store.CreateAcceptance(context.Background(), input))
acquired, err := store.TryAcquireRouteLease(context.Background(), input.Notification.NotificationID, "push:user:user-1", "token-1", 5*time.Second)
require.NoError(t, err)
require.True(t, acquired)
route, found, err := store.GetRoute(context.Background(), input.Notification.NotificationID, "push:user:user-1")
require.NoError(t, err)
require.True(t, found)
failedAt := now.Add(time.Second).UTC().Truncate(time.Millisecond)
nextAttemptAt := failedAt.Add(2 * time.Second).UTC().Truncate(time.Millisecond)
require.NoError(t, store.CompleteRouteFailed(context.Background(), CompleteRouteFailedInput{
ExpectedRoute: route,
LeaseToken: "token-1",
FailedAt: failedAt,
NextAttemptAt: nextAttemptAt,
FailureClassification: "gateway_stream_publish_failed",
FailureMessage: "temporary outage",
}))
updatedRoute, found, err := store.GetRoute(context.Background(), input.Notification.NotificationID, "push:user:user-1")
require.NoError(t, err)
require.True(t, found)
require.Equal(t, acceptintent.RouteStatusFailed, updatedRoute.Status)
require.Equal(t, 1, updatedRoute.AttemptCount)
require.Equal(t, nextAttemptAt, updatedRoute.NextAttemptAt)
require.Equal(t, "gateway_stream_publish_failed", updatedRoute.LastErrorClassification)
scheduled, err := client.ZRangeWithScores(context.Background(), Keyspace{}.RouteSchedule(), 0, -1).Result()
require.NoError(t, err)
require.Len(t, scheduled, 2)
require.Contains(t, []string{
scheduled[0].Member.(string),
scheduled[1].Member.(string),
}, Keyspace{}.Route(input.Notification.NotificationID, "push:user:user-1"))
}
func TestAcceptanceStoreCompleteRouteDeadLetterStoresTerminalFailure(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 := validUserAcceptanceInput(now, 2)
require.NoError(t, store.CreateAcceptance(context.Background(), input))
acquired, err := store.TryAcquireRouteLease(context.Background(), input.Notification.NotificationID, "push:user:user-1", "token-1", 5*time.Second)
require.NoError(t, err)
require.True(t, acquired)
route, found, err := store.GetRoute(context.Background(), input.Notification.NotificationID, "push:user:user-1")
require.NoError(t, err)
require.True(t, found)
deadLetteredAt := now.Add(time.Second).UTC().Truncate(time.Millisecond)
require.NoError(t, store.CompleteRouteDeadLetter(context.Background(), CompleteRouteDeadLetterInput{
ExpectedRoute: route,
LeaseToken: "token-1",
DeadLetteredAt: deadLetteredAt,
FailureClassification: "payload_encoding_failed",
FailureMessage: "payload is invalid",
}))
updatedRoute, found, err := store.GetRoute(context.Background(), input.Notification.NotificationID, "push:user:user-1")
require.NoError(t, err)
require.True(t, found)
require.Equal(t, acceptintent.RouteStatusDeadLetter, updatedRoute.Status)
require.Equal(t, 3, updatedRoute.AttemptCount)
require.Equal(t, deadLetteredAt, updatedRoute.DeadLetteredAt)
payload, err := client.Get(context.Background(), Keyspace{}.DeadLetter(input.Notification.NotificationID, "push:user:user-1")).Bytes()
require.NoError(t, err)
entry, err := UnmarshalDeadLetter(payload)
require.NoError(t, err)
require.Equal(t, "payload_encoding_failed", entry.FailureClassification)
require.Equal(t, 3, entry.FinalAttemptCount)
scheduled, err := client.ZRange(context.Background(), Keyspace{}.RouteSchedule(), 0, -1).Result()
require.NoError(t, err)
require.Equal(t, []string{Keyspace{}.Route(input.Notification.NotificationID, "email:user:user-1")}, scheduled)
}
func TestAcceptanceStoreDeadLetterIsIsolatedByChannelAndRecipient(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 := validUserAcceptanceInput(now, 2)
input.Notification.RecipientUserIDs = []string{"user-1", "user-2"}
input.Routes = append(input.Routes,
acceptintent.NotificationRoute{
NotificationID: input.Notification.NotificationID,
RouteID: "push:user:user-2",
Channel: intentstream.ChannelPush,
RecipientRef: "user:user-2",
Status: acceptintent.RouteStatusPending,
AttemptCount: 0,
MaxAttempts: 3,
NextAttemptAt: now,
ResolvedEmail: "second@example.com",
ResolvedLocale: "en",
CreatedAt: now,
UpdatedAt: now,
},
acceptintent.NotificationRoute{
NotificationID: input.Notification.NotificationID,
RouteID: "email:user:user-2",
Channel: intentstream.ChannelEmail,
RecipientRef: "user:user-2",
Status: acceptintent.RouteStatusPending,
AttemptCount: 0,
MaxAttempts: 7,
NextAttemptAt: now,
ResolvedEmail: "second@example.com",
ResolvedLocale: "en",
CreatedAt: now,
UpdatedAt: now,
},
)
require.NoError(t, store.CreateAcceptance(context.Background(), input))
acquired, err := store.TryAcquireRouteLease(context.Background(), input.Notification.NotificationID, "push:user:user-1", "token-1", 5*time.Second)
require.NoError(t, err)
require.True(t, acquired)
route, found, err := store.GetRoute(context.Background(), input.Notification.NotificationID, "push:user:user-1")
require.NoError(t, err)
require.True(t, found)
deadLetteredAt := now.Add(time.Second).UTC().Truncate(time.Millisecond)
require.NoError(t, store.CompleteRouteDeadLetter(context.Background(), CompleteRouteDeadLetterInput{
ExpectedRoute: route,
LeaseToken: "token-1",
DeadLetteredAt: deadLetteredAt,
FailureClassification: "gateway_stream_publish_failed",
FailureMessage: "gateway unavailable",
}))
deadLetterRoute, found, err := store.GetRoute(context.Background(), input.Notification.NotificationID, "push:user:user-1")
require.NoError(t, err)
require.True(t, found)
require.Equal(t, acceptintent.RouteStatusDeadLetter, deadLetterRoute.Status)
for _, routeID := range []string{"email:user:user-1", "push:user:user-2", "email:user:user-2"} {
route, found, err := store.GetRoute(context.Background(), input.Notification.NotificationID, routeID)
require.NoError(t, err)
require.True(t, found, "route %s should remain stored", routeID)
require.Equal(t, acceptintent.RouteStatusPending, route.Status, "route %s should remain pending", routeID)
}
scheduled, err := client.ZRange(context.Background(), Keyspace{}.RouteSchedule(), 0, -1).Result()
require.NoError(t, err)
require.ElementsMatch(t, []string{
Keyspace{}.Route(input.Notification.NotificationID, "email:user:user-1"),
Keyspace{}.Route(input.Notification.NotificationID, "push:user:user-2"),
Keyspace{}.Route(input.Notification.NotificationID, "email:user:user-2"),
}, scheduled)
}
func validUserAcceptanceInput(now time.Time, pushAttemptCount int) acceptintent.CreateAcceptanceInput {
return acceptintent.CreateAcceptanceInput{
Notification: acceptintent.NotificationRecord{
NotificationID: "1775121700000-0",
NotificationType: intentstream.NotificationTypeGameTurnReady,
Producer: intentstream.ProducerGameMaster,
AudienceKind: intentstream.AudienceKindUser,
RecipientUserIDs: []string{"user-1"},
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
IdempotencyKey: "game-123:turn-54",
RequestFingerprint: "sha256:deadbeef",
RequestID: "request-1",
TraceID: "trace-1",
OccurredAt: now,
AcceptedAt: now,
UpdatedAt: now,
},
Routes: []acceptintent.NotificationRoute{
{
NotificationID: "1775121700000-0",
RouteID: "push:user:user-1",
Channel: intentstream.ChannelPush,
RecipientRef: "user:user-1",
Status: acceptintent.RouteStatusPending,
AttemptCount: pushAttemptCount,
MaxAttempts: 3,
NextAttemptAt: now,
ResolvedEmail: "pilot@example.com",
ResolvedLocale: "en",
CreatedAt: now,
UpdatedAt: now,
},
{
NotificationID: "1775121700000-0",
RouteID: "email:user:user-1",
Channel: intentstream.ChannelEmail,
RecipientRef: "user:user-1",
Status: acceptintent.RouteStatusPending,
AttemptCount: 0,
MaxAttempts: 7,
NextAttemptAt: now,
ResolvedEmail: "pilot@example.com",
ResolvedLocale: "en",
CreatedAt: now,
UpdatedAt: now,
},
},
Idempotency: acceptintent.IdempotencyRecord{
Producer: intentstream.ProducerGameMaster,
IdempotencyKey: "game-123:turn-54",
NotificationID: "1775121700000-0",
RequestFingerprint: "sha256:deadbeef",
CreatedAt: now,
ExpiresAt: now.Add(7 * 24 * time.Hour),
},
}
}