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)) }