558 lines
17 KiB
Go
558 lines
17 KiB
Go
package redisstate_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/lobby/internal/adapters/redisstate"
|
|
"galaxy/lobby/internal/domain/common"
|
|
"galaxy/lobby/internal/domain/game"
|
|
"galaxy/lobby/internal/ports"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/redis/go-redis/v9"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func newTestStore(t *testing.T) (*redisstate.GameStore, *miniredis.Miniredis, *redis.Client) {
|
|
t.Helper()
|
|
|
|
server := miniredis.RunT(t)
|
|
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
|
t.Cleanup(func() {
|
|
_ = client.Close()
|
|
})
|
|
|
|
store, err := redisstate.NewGameStore(client)
|
|
require.NoError(t, err)
|
|
|
|
return store, server, client
|
|
}
|
|
|
|
func fixtureGame(t *testing.T) game.Game {
|
|
t.Helper()
|
|
|
|
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
|
record, err := game.New(game.NewGameInput{
|
|
GameID: common.GameID("game-1"),
|
|
GameName: "Spring Classic",
|
|
Description: "first public game",
|
|
GameType: game.GameTypePublic,
|
|
MinPlayers: 4,
|
|
MaxPlayers: 8,
|
|
StartGapHours: 24,
|
|
StartGapPlayers: 2,
|
|
EnrollmentEndsAt: now.Add(7 * 24 * time.Hour),
|
|
TurnSchedule: "0 18 * * *",
|
|
TargetEngineVersion: "v1.2.3",
|
|
Now: now,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return record
|
|
}
|
|
|
|
func statusIndexMembers(t *testing.T, client *redis.Client, status game.Status) []string {
|
|
t.Helper()
|
|
|
|
members, err := client.ZRange(context.Background(), "lobby:games_by_status:"+base64URL(string(status)), 0, -1).Result()
|
|
require.NoError(t, err)
|
|
return members
|
|
}
|
|
|
|
func TestNewGameStoreRejectsNilClient(t *testing.T) {
|
|
_, err := redisstate.NewGameStore(nil)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestGameStoreSaveAndGet(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, client := newTestStore(t)
|
|
|
|
record := fixtureGame(t)
|
|
require.NoError(t, store.Save(ctx, record))
|
|
|
|
got, err := store.Get(ctx, record.GameID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, record.GameID, got.GameID)
|
|
assert.Equal(t, record.Status, got.Status)
|
|
assert.Equal(t, record.GameName, got.GameName)
|
|
assert.Equal(t, record.MinPlayers, got.MinPlayers)
|
|
assert.Equal(t, record.MaxPlayers, got.MaxPlayers)
|
|
assert.Equal(t, record.EnrollmentEndsAt.Unix(), got.EnrollmentEndsAt.Unix())
|
|
|
|
members := statusIndexMembers(t, client, game.StatusDraft)
|
|
assert.Contains(t, members, record.GameID.String())
|
|
}
|
|
|
|
func TestGameStoreGetReturnsNotFound(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, _ := newTestStore(t)
|
|
|
|
_, err := store.Get(ctx, common.GameID("game-missing"))
|
|
require.ErrorIs(t, err, game.ErrNotFound)
|
|
}
|
|
|
|
func TestGameStoreSaveRewritesStatusIndexOnStatusChange(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, client := newTestStore(t)
|
|
|
|
record := fixtureGame(t)
|
|
require.NoError(t, store.Save(ctx, record))
|
|
|
|
record.Status = game.StatusEnrollmentOpen
|
|
record.UpdatedAt = record.UpdatedAt.Add(time.Minute)
|
|
require.NoError(t, store.Save(ctx, record))
|
|
|
|
assert.Empty(t, statusIndexMembers(t, client, game.StatusDraft))
|
|
assert.Contains(t, statusIndexMembers(t, client, game.StatusEnrollmentOpen), record.GameID.String())
|
|
}
|
|
|
|
func TestGameStoreCountByStatusReturnsAllBuckets(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, _ := newTestStore(t)
|
|
|
|
record1 := fixtureGame(t)
|
|
record1.GameID = common.GameID("game-count-a")
|
|
|
|
record2 := fixtureGame(t)
|
|
record2.GameID = common.GameID("game-count-b")
|
|
record2.CreatedAt = record2.CreatedAt.Add(time.Second)
|
|
record2.UpdatedAt = record2.CreatedAt
|
|
|
|
record3 := fixtureGame(t)
|
|
record3.GameID = common.GameID("game-count-c")
|
|
record3.Status = game.StatusEnrollmentOpen
|
|
|
|
for _, record := range []game.Game{record1, record2, record3} {
|
|
require.NoError(t, store.Save(ctx, record))
|
|
}
|
|
|
|
counts, err := store.CountByStatus(ctx)
|
|
require.NoError(t, err)
|
|
|
|
for _, status := range game.AllStatuses() {
|
|
_, present := counts[status]
|
|
require.True(t, present, "expected %s bucket", status)
|
|
}
|
|
require.Equal(t, 2, counts[game.StatusDraft])
|
|
require.Equal(t, 1, counts[game.StatusEnrollmentOpen])
|
|
require.Equal(t, 0, counts[game.StatusRunning])
|
|
}
|
|
|
|
func TestGameStoreGetByStatusReturnsMatchingRecords(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, _ := newTestStore(t)
|
|
|
|
record1 := fixtureGame(t)
|
|
record1.GameID = common.GameID("game-a")
|
|
|
|
record2 := fixtureGame(t)
|
|
record2.GameID = common.GameID("game-b")
|
|
record2.CreatedAt = record2.CreatedAt.Add(time.Second)
|
|
record2.UpdatedAt = record2.CreatedAt
|
|
|
|
record3 := fixtureGame(t)
|
|
record3.GameID = common.GameID("game-c")
|
|
record3.Status = game.StatusEnrollmentOpen
|
|
|
|
for _, record := range []game.Game{record1, record2, record3} {
|
|
require.NoError(t, store.Save(ctx, record))
|
|
}
|
|
|
|
drafts, err := store.GetByStatus(ctx, game.StatusDraft)
|
|
require.NoError(t, err)
|
|
require.Len(t, drafts, 2)
|
|
gotIDs := []string{drafts[0].GameID.String(), drafts[1].GameID.String()}
|
|
assert.Contains(t, gotIDs, record1.GameID.String())
|
|
assert.Contains(t, gotIDs, record2.GameID.String())
|
|
|
|
enrollment, err := store.GetByStatus(ctx, game.StatusEnrollmentOpen)
|
|
require.NoError(t, err)
|
|
require.Len(t, enrollment, 1)
|
|
assert.Equal(t, record3.GameID, enrollment[0].GameID)
|
|
|
|
running, err := store.GetByStatus(ctx, game.StatusRunning)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, running)
|
|
}
|
|
|
|
func TestGameStoreGetByOwnerReturnsOwnedGames(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, _ := newTestStore(t)
|
|
|
|
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
|
record1, err := game.New(game.NewGameInput{
|
|
GameID: common.GameID("game-priv-a"),
|
|
GameName: "Owner A first",
|
|
GameType: game.GameTypePrivate,
|
|
OwnerUserID: "user-owner-a",
|
|
MinPlayers: 2,
|
|
MaxPlayers: 4,
|
|
StartGapHours: 1,
|
|
StartGapPlayers: 1,
|
|
EnrollmentEndsAt: now.Add(48 * time.Hour),
|
|
TurnSchedule: "0 18 * * *",
|
|
TargetEngineVersion: "v1.0.0",
|
|
Now: now,
|
|
})
|
|
require.NoError(t, err)
|
|
record2, err := game.New(game.NewGameInput{
|
|
GameID: common.GameID("game-priv-b"),
|
|
GameName: "Owner A second",
|
|
GameType: game.GameTypePrivate,
|
|
OwnerUserID: "user-owner-a",
|
|
MinPlayers: 2,
|
|
MaxPlayers: 4,
|
|
StartGapHours: 1,
|
|
StartGapPlayers: 1,
|
|
EnrollmentEndsAt: now.Add(48 * time.Hour),
|
|
TurnSchedule: "0 18 * * *",
|
|
TargetEngineVersion: "v1.0.0",
|
|
Now: now.Add(time.Second),
|
|
})
|
|
require.NoError(t, err)
|
|
record3, err := game.New(game.NewGameInput{
|
|
GameID: common.GameID("game-priv-c"),
|
|
GameName: "Owner B",
|
|
GameType: game.GameTypePrivate,
|
|
OwnerUserID: "user-owner-b",
|
|
MinPlayers: 2,
|
|
MaxPlayers: 4,
|
|
StartGapHours: 1,
|
|
StartGapPlayers: 1,
|
|
EnrollmentEndsAt: now.Add(48 * time.Hour),
|
|
TurnSchedule: "0 18 * * *",
|
|
TargetEngineVersion: "v1.0.0",
|
|
Now: now,
|
|
})
|
|
require.NoError(t, err)
|
|
publicRecord := fixtureGame(t)
|
|
|
|
for _, record := range []game.Game{record1, record2, record3, publicRecord} {
|
|
require.NoError(t, store.Save(ctx, record))
|
|
}
|
|
|
|
ownerA, err := store.GetByOwner(ctx, "user-owner-a")
|
|
require.NoError(t, err)
|
|
require.Len(t, ownerA, 2)
|
|
|
|
ownerB, err := store.GetByOwner(ctx, "user-owner-b")
|
|
require.NoError(t, err)
|
|
require.Len(t, ownerB, 1)
|
|
assert.Equal(t, record3.GameID, ownerB[0].GameID)
|
|
|
|
ownerNone, err := store.GetByOwner(ctx, "user-owner-none")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, ownerNone)
|
|
}
|
|
|
|
func TestGameStoreGetByStatusDropsStaleIndexEntries(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, server, _ := newTestStore(t)
|
|
|
|
record := fixtureGame(t)
|
|
require.NoError(t, store.Save(ctx, record))
|
|
|
|
// Delete the primary key out-of-band, leaving the index entry stale.
|
|
server.Del("lobby:games:" + base64URL(record.GameID.String()))
|
|
|
|
records, err := store.GetByStatus(ctx, game.StatusDraft)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, records)
|
|
}
|
|
|
|
func TestGameStoreUpdateStatusValidTransition(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, client := newTestStore(t)
|
|
|
|
record := fixtureGame(t)
|
|
require.NoError(t, store.Save(ctx, record))
|
|
|
|
at := record.CreatedAt.Add(time.Hour)
|
|
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
|
GameID: record.GameID,
|
|
ExpectedFrom: game.StatusDraft,
|
|
To: game.StatusEnrollmentOpen,
|
|
Trigger: game.TriggerCommand,
|
|
At: at,
|
|
}))
|
|
|
|
got, err := store.Get(ctx, record.GameID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, game.StatusEnrollmentOpen, got.Status)
|
|
assert.True(t, got.UpdatedAt.Equal(at.UTC()))
|
|
assert.Nil(t, got.StartedAt)
|
|
assert.Nil(t, got.FinishedAt)
|
|
|
|
assert.Empty(t, statusIndexMembers(t, client, game.StatusDraft))
|
|
assert.Contains(t, statusIndexMembers(t, client, game.StatusEnrollmentOpen), record.GameID.String())
|
|
}
|
|
|
|
func TestGameStoreUpdateStatusSetsStartedAtAndFinishedAt(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, _ := newTestStore(t)
|
|
|
|
record := fixtureGame(t)
|
|
record.Status = game.StatusStarting
|
|
require.NoError(t, store.Save(ctx, record))
|
|
|
|
startedAt := record.CreatedAt.Add(time.Hour)
|
|
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
|
GameID: record.GameID,
|
|
ExpectedFrom: game.StatusStarting,
|
|
To: game.StatusRunning,
|
|
Trigger: game.TriggerRuntimeEvent,
|
|
At: startedAt,
|
|
}))
|
|
|
|
got, err := store.Get(ctx, record.GameID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, game.StatusRunning, got.Status)
|
|
require.NotNil(t, got.StartedAt)
|
|
assert.True(t, got.StartedAt.Equal(startedAt.UTC()))
|
|
assert.Nil(t, got.FinishedAt)
|
|
|
|
finishedAt := startedAt.Add(2 * time.Hour)
|
|
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
|
GameID: record.GameID,
|
|
ExpectedFrom: game.StatusRunning,
|
|
To: game.StatusFinished,
|
|
Trigger: game.TriggerRuntimeEvent,
|
|
At: finishedAt,
|
|
}))
|
|
|
|
got, err = store.Get(ctx, record.GameID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, game.StatusFinished, got.Status)
|
|
require.NotNil(t, got.StartedAt)
|
|
assert.True(t, got.StartedAt.Equal(startedAt.UTC()))
|
|
require.NotNil(t, got.FinishedAt)
|
|
assert.True(t, got.FinishedAt.Equal(finishedAt.UTC()))
|
|
}
|
|
|
|
func TestGameStoreUpdateStatusRejectsInvalidTransitionWithoutMutation(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, _ := newTestStore(t)
|
|
|
|
record := fixtureGame(t)
|
|
require.NoError(t, store.Save(ctx, record))
|
|
|
|
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
|
GameID: record.GameID,
|
|
ExpectedFrom: game.StatusDraft,
|
|
To: game.StatusRunning,
|
|
Trigger: game.TriggerCommand,
|
|
At: record.CreatedAt.Add(time.Minute),
|
|
})
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, game.ErrInvalidTransition))
|
|
|
|
got, err := store.Get(ctx, record.GameID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, game.StatusDraft, got.Status)
|
|
assert.True(t, got.UpdatedAt.Equal(record.UpdatedAt))
|
|
}
|
|
|
|
func TestGameStoreUpdateStatusRejectsWrongTrigger(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, _ := newTestStore(t)
|
|
|
|
record := fixtureGame(t)
|
|
require.NoError(t, store.Save(ctx, record))
|
|
|
|
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
|
GameID: record.GameID,
|
|
ExpectedFrom: game.StatusDraft,
|
|
To: game.StatusEnrollmentOpen,
|
|
Trigger: game.TriggerDeadline,
|
|
At: record.CreatedAt.Add(time.Minute),
|
|
})
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, game.ErrInvalidTransition))
|
|
}
|
|
|
|
func TestGameStoreUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, _ := newTestStore(t)
|
|
|
|
record := fixtureGame(t)
|
|
require.NoError(t, store.Save(ctx, record))
|
|
|
|
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
|
GameID: record.GameID,
|
|
ExpectedFrom: game.StatusEnrollmentOpen,
|
|
To: game.StatusReadyToStart,
|
|
Trigger: game.TriggerManual,
|
|
At: record.CreatedAt.Add(time.Minute),
|
|
})
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, game.ErrConflict))
|
|
}
|
|
|
|
func TestGameStoreUpdateStatusReturnsNotFoundForMissingRecord(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, _ := newTestStore(t)
|
|
|
|
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
|
GameID: common.GameID("game-missing"),
|
|
ExpectedFrom: game.StatusDraft,
|
|
To: game.StatusEnrollmentOpen,
|
|
Trigger: game.TriggerCommand,
|
|
At: time.Now().UTC(),
|
|
})
|
|
require.ErrorIs(t, err, game.ErrNotFound)
|
|
}
|
|
|
|
func TestGameStoreUpdateRuntimeSnapshot(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, client := newTestStore(t)
|
|
|
|
record := fixtureGame(t)
|
|
record.Status = game.StatusRunning
|
|
startedAt := record.CreatedAt.Add(time.Hour)
|
|
record.StartedAt = &startedAt
|
|
require.NoError(t, store.Save(ctx, record))
|
|
|
|
at := startedAt.Add(10 * time.Minute)
|
|
require.NoError(t, store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{
|
|
GameID: record.GameID,
|
|
Snapshot: game.RuntimeSnapshot{
|
|
CurrentTurn: 5,
|
|
RuntimeStatus: "running_accepting_commands",
|
|
EngineHealthSummary: "ok",
|
|
},
|
|
At: at,
|
|
}))
|
|
|
|
got, err := store.Get(ctx, record.GameID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 5, got.RuntimeSnapshot.CurrentTurn)
|
|
assert.Equal(t, "running_accepting_commands", got.RuntimeSnapshot.RuntimeStatus)
|
|
assert.Equal(t, "ok", got.RuntimeSnapshot.EngineHealthSummary)
|
|
assert.True(t, got.UpdatedAt.Equal(at.UTC()))
|
|
assert.Equal(t, game.StatusRunning, got.Status)
|
|
|
|
assert.Contains(t, statusIndexMembers(t, client, game.StatusRunning), record.GameID.String())
|
|
}
|
|
|
|
func TestGameStoreUpdateRuntimeSnapshotReturnsNotFound(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, _ := newTestStore(t)
|
|
|
|
err := store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{
|
|
GameID: common.GameID("game-missing"),
|
|
Snapshot: game.RuntimeSnapshot{},
|
|
At: time.Now().UTC(),
|
|
})
|
|
require.ErrorIs(t, err, game.ErrNotFound)
|
|
}
|
|
|
|
func TestGameStoreUpdateRuntimeBinding(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, _ := newTestStore(t)
|
|
|
|
record := fixtureGame(t)
|
|
record.Status = game.StatusStarting
|
|
require.NoError(t, store.Save(ctx, record))
|
|
|
|
bound := record.CreatedAt.Add(time.Hour)
|
|
require.NoError(t, store.UpdateRuntimeBinding(ctx, ports.UpdateRuntimeBindingInput{
|
|
GameID: record.GameID,
|
|
Binding: game.RuntimeBinding{
|
|
ContainerID: "container-1",
|
|
EngineEndpoint: "engine.local:9000",
|
|
RuntimeJobID: "1700000000000-0",
|
|
BoundAt: bound,
|
|
},
|
|
At: bound,
|
|
}))
|
|
|
|
got, err := store.Get(ctx, record.GameID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got.RuntimeBinding)
|
|
assert.Equal(t, "container-1", got.RuntimeBinding.ContainerID)
|
|
assert.Equal(t, "engine.local:9000", got.RuntimeBinding.EngineEndpoint)
|
|
assert.Equal(t, "1700000000000-0", got.RuntimeBinding.RuntimeJobID)
|
|
assert.True(t, got.RuntimeBinding.BoundAt.Equal(bound.UTC()))
|
|
assert.Equal(t, game.StatusStarting, got.Status, "binding update must not change status")
|
|
assert.True(t, got.UpdatedAt.Equal(bound.UTC()))
|
|
}
|
|
|
|
func TestGameStoreUpdateRuntimeBindingReturnsNotFound(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, _ := newTestStore(t)
|
|
|
|
err := store.UpdateRuntimeBinding(ctx, ports.UpdateRuntimeBindingInput{
|
|
GameID: common.GameID("game-missing"),
|
|
Binding: game.RuntimeBinding{
|
|
ContainerID: "container-1",
|
|
EngineEndpoint: "engine.local:9000",
|
|
RuntimeJobID: "1700000000000-0",
|
|
BoundAt: time.Now().UTC(),
|
|
},
|
|
At: time.Now().UTC(),
|
|
})
|
|
require.ErrorIs(t, err, game.ErrNotFound)
|
|
}
|
|
|
|
func TestGameStoreConcurrentUpdateStatusHasExactlyOneWinner(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _, client := newTestStore(t)
|
|
|
|
record := fixtureGame(t)
|
|
require.NoError(t, store.Save(ctx, record))
|
|
|
|
storeA, err := redisstate.NewGameStore(client)
|
|
require.NoError(t, err)
|
|
storeB, err := redisstate.NewGameStore(client)
|
|
require.NoError(t, err)
|
|
|
|
var (
|
|
wg sync.WaitGroup
|
|
successes atomic.Int32
|
|
conflicts atomic.Int32
|
|
others atomic.Int32
|
|
)
|
|
|
|
apply := func(target *redisstate.GameStore) {
|
|
defer wg.Done()
|
|
err := target.UpdateStatus(ctx, ports.UpdateStatusInput{
|
|
GameID: record.GameID,
|
|
ExpectedFrom: game.StatusDraft,
|
|
To: game.StatusEnrollmentOpen,
|
|
Trigger: game.TriggerCommand,
|
|
At: record.CreatedAt.Add(time.Minute),
|
|
})
|
|
switch {
|
|
case err == nil:
|
|
successes.Add(1)
|
|
case errors.Is(err, game.ErrConflict):
|
|
conflicts.Add(1)
|
|
default:
|
|
others.Add(1)
|
|
}
|
|
}
|
|
|
|
wg.Add(2)
|
|
go apply(storeA)
|
|
go apply(storeB)
|
|
wg.Wait()
|
|
|
|
assert.Equal(t, int32(0), others.Load(), "unexpected non-conflict error")
|
|
assert.Equal(t, int32(1), successes.Load(), "expected exactly one success")
|
|
assert.Equal(t, int32(1), conflicts.Load(), "expected exactly one conflict")
|
|
}
|
|
|
|
// base64URL mirrors the private key-segment encoding used by Keyspace.
|
|
// The tests use it to assert on exact Redis key shapes.
|
|
func base64URL(value string) string {
|
|
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
|
}
|