package redisstate_test import ( "context" "encoding/base64" "encoding/json" "errors" "testing" "time" "galaxy/lobby/internal/adapters/redisstate" "galaxy/lobby/internal/domain/racename" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/ports/racenamedirtest" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func newRaceNameDirectoryAdapter( t *testing.T, now func() time.Time, ) (*redisstate.RaceNameDirectory, *miniredis.Miniredis, *redis.Client) { t.Helper() server := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: server.Addr()}) t.Cleanup(func() { _ = client.Close() }) policy, err := racename.NewPolicy() require.NoError(t, err) var opts []redisstate.RaceNameDirectoryOption if now != nil { opts = append(opts, redisstate.WithRaceNameDirectoryClock(now)) } directory, err := redisstate.NewRaceNameDirectory(client, policy, opts...) require.NoError(t, err) return directory, server, client } func TestRaceNameDirectoryContract(t *testing.T) { racenamedirtest.Run(t, func(now func() time.Time) ports.RaceNameDirectory { directory, _, _ := newRaceNameDirectoryAdapter(t, now) return directory }) } func TestNewRaceNameDirectoryRejectsNilClient(t *testing.T) { policy, err := racename.NewPolicy() require.NoError(t, err) _, err = redisstate.NewRaceNameDirectory(nil, policy) require.Error(t, err) } func TestNewRaceNameDirectoryRejectsNilPolicy(t *testing.T) { server := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: server.Addr()}) t.Cleanup(func() { _ = client.Close() }) _, err := redisstate.NewRaceNameDirectory(client, nil) require.Error(t, err) } func TestRaceNameDirectoryPersistsExactKeyShapes(t *testing.T) { ctx := context.Background() directory, server, _ := newRaceNameDirectoryAdapter(t, nil) const ( gameID = "game-shape" userID = "user-shape" raceName = "PilotNova" ) require.NoError(t, directory.Reserve(ctx, gameID, userID, raceName)) canonical, err := directory.Canonicalize(raceName) require.NoError(t, err) encGame := base64URL(gameID) encUser := base64URL(userID) encCanonical := base64URL(canonical) require.True(t, server.Exists("lobby:race_names:reservations:"+encGame+":"+encCanonical)) require.True(t, server.Exists("lobby:race_names:canonical_lookup:"+encCanonical)) require.True(t, server.Exists("lobby:race_names:user_reservations:"+encUser)) members, err := server.SMembers("lobby:race_names:user_reservations:" + encUser) require.NoError(t, err) require.Contains(t, members, encGame+":"+encCanonical) lookupPayload, err := server.Get("lobby:race_names:canonical_lookup:" + encCanonical) require.NoError(t, err) var lookup map[string]any require.NoError(t, json.Unmarshal([]byte(lookupPayload), &lookup)) assert.Equal(t, ports.KindReservation, lookup["kind"]) assert.Equal(t, userID, lookup["holder_user_id"]) assert.Equal(t, gameID, lookup["game_id"]) } func TestRaceNameDirectoryCanonicalLookupUpgradesOnPendingAndRegistered(t *testing.T) { now, _ := fixedNow(t) directory, server, _ := newRaceNameDirectoryAdapter(t, now) ctx := context.Background() const ( gameID = "game-upgrade" userID = "user-upgrade" raceName = "UpgradePilot" ) require.NoError(t, directory.Reserve(ctx, gameID, userID, raceName)) canonical, err := directory.Canonicalize(raceName) require.NoError(t, err) lookupKey := "lobby:race_names:canonical_lookup:" + base64URL(canonical) lookupAfterReserve, err := server.Get(lookupKey) require.NoError(t, err) require.Contains(t, lookupAfterReserve, `"kind":"`+ports.KindReservation+`"`) eligibleUntil := now().Add(time.Hour) require.NoError(t, directory.MarkPendingRegistration(ctx, gameID, userID, raceName, eligibleUntil)) lookupAfterPending, err := server.Get(lookupKey) require.NoError(t, err) require.Contains(t, lookupAfterPending, `"kind":"`+ports.KindPendingRegistration+`"`) require.NoError(t, directory.Register(ctx, gameID, userID, raceName)) lookupAfterRegister, err := server.Get(lookupKey) require.NoError(t, err) require.Contains(t, lookupAfterRegister, `"kind":"`+ports.KindRegistered+`"`) require.NotContains(t, lookupAfterRegister, `"game_id"`, "registered lookup omits the game id") } func TestRaceNameDirectoryCanonicalLookupDowngradesOnReleaseCrossGame(t *testing.T) { directory, server, _ := newRaceNameDirectoryAdapter(t, nil) ctx := context.Background() const ( gameA = "game-keep-a" gameB = "game-keep-b" userID = "user-keep" raceNam = "KeepPilot" ) require.NoError(t, directory.Reserve(ctx, gameA, userID, raceNam)) require.NoError(t, directory.Reserve(ctx, gameB, userID, raceNam)) canonical, err := directory.Canonicalize(raceNam) require.NoError(t, err) lookupKey := "lobby:race_names:canonical_lookup:" + base64URL(canonical) require.NoError(t, directory.ReleaseReservation(ctx, gameA, userID, raceNam)) payload, err := server.Get(lookupKey) require.NoError(t, err) require.Contains(t, payload, `"kind":"`+ports.KindReservation+`"`) require.Contains(t, payload, `"game_id":"`+gameB+`"`) require.NoError(t, directory.ReleaseReservation(ctx, gameB, userID, raceNam)) require.False(t, server.Exists(lookupKey)) } func TestRaceNameDirectoryReleaseAllByUserLua(t *testing.T) { now, _ := fixedNow(t) directory, server, _ := newRaceNameDirectoryAdapter(t, now) ctx := context.Background() const ( userID = "user-lua" otherID = "user-lua-other" raceName = "LuaPilot" otherRN = "LuaVanguard" gameA = "game-lua-a" gameB = "game-lua-b" ) require.NoError(t, directory.Reserve(ctx, gameA, userID, raceName)) require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userID, raceName, now().Add(time.Hour))) require.NoError(t, directory.Register(ctx, gameA, userID, raceName)) require.NoError(t, directory.Reserve(ctx, gameB, userID, otherRN)) require.NoError(t, directory.MarkPendingRegistration(ctx, gameB, userID, otherRN, now().Add(2*time.Hour))) const isolatedRN = "LuaGoldenChain" require.NoError(t, directory.Reserve(ctx, gameA, otherID, isolatedRN)) require.NoError(t, directory.ReleaseAllByUser(ctx, userID)) require.False(t, server.Exists("lobby:race_names:user_registered:"+base64URL(userID))) require.False(t, server.Exists("lobby:race_names:user_reservations:"+base64URL(userID))) pendingMembers, err := server.ZMembers("lobby:race_names:pending_index") if err != nil { require.ErrorContains(t, err, "ERR no such key") } else { require.Empty(t, pendingMembers) } otherCanonical, err := directory.Canonicalize(isolatedRN) require.NoError(t, err) require.True(t, server.Exists("lobby:race_names:canonical_lookup:"+base64URL(otherCanonical))) reservations, err := directory.ListReservations(ctx, otherID) require.NoError(t, err) require.Len(t, reservations, 1) } func TestRaceNameDirectoryReleaseAllByUserIsSafeOnEmpty(t *testing.T) { directory, _, _ := newRaceNameDirectoryAdapter(t, nil) ctx := context.Background() require.NoError(t, directory.ReleaseAllByUser(ctx, "unknown-user")) } func TestRaceNameDirectoryCheckRejectsInvalidName(t *testing.T) { directory, _, _ := newRaceNameDirectoryAdapter(t, nil) _, err := directory.Check(context.Background(), "Pilot Nova", "user-x") require.Error(t, err) require.True(t, errors.Is(err, ports.ErrInvalidName)) } func fixedNow(t *testing.T) (func() time.Time, func(delta time.Duration)) { t.Helper() instant := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) var mu struct { value time.Time } mu.value = instant return func() time.Time { return mu.value }, func(delta time.Duration) { mu.value = mu.value.Add(delta) } } // base64URL is the package-level helper defined in gamestore_test.go; // race-name adapter tests reuse it via the same test package. var _ = base64.RawURLEncoding