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

312 lines
11 KiB
Go

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
}