245 lines
7.7 KiB
Go
245 lines
7.7 KiB
Go
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
|