feat: game lobby service
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
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
|
||||
Reference in New Issue
Block a user