package gamestore_test import ( "context" "testing" "time" "galaxy/lobby/internal/adapters/postgres/gamestore" "galaxy/lobby/internal/adapters/postgres/internal/pgtest" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestMain(m *testing.M) { pgtest.RunMain(m) } func newStore(t *testing.T) *gamestore.Store { t.Helper() pgtest.TruncateAll(t) store, err := gamestore.New(gamestore.Config{ DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout, }) require.NoError(t, err) return store } func fixturePublicGame(t *testing.T, id string) 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(id), GameName: "Spring Classic " + id, 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 fixturePrivateGame(t *testing.T, id, ownerID string) 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(id), GameName: "Private " + id, GameType: game.GameTypePrivate, OwnerUserID: ownerID, MinPlayers: 2, MaxPlayers: 6, StartGapHours: 12, StartGapPlayers: 2, EnrollmentEndsAt: now.Add(7 * 24 * time.Hour), TurnSchedule: "0 18 * * *", TargetEngineVersion: "v1.0.0", Now: now, }) require.NoError(t, err) return record } func TestSaveAndGet(t *testing.T) { ctx := context.Background() store := newStore(t) record := fixturePublicGame(t, "game-001") 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.GameName, got.GameName) assert.Equal(t, record.Status, got.Status) assert.Equal(t, record.MinPlayers, got.MinPlayers) assert.Equal(t, record.MaxPlayers, got.MaxPlayers) assert.True(t, record.EnrollmentEndsAt.Equal(got.EnrollmentEndsAt)) assert.Equal(t, time.UTC, got.CreatedAt.Location()) assert.Equal(t, time.UTC, got.UpdatedAt.Location()) } func TestGetReturnsNotFound(t *testing.T) { ctx := context.Background() store := newStore(t) _, err := store.Get(ctx, common.GameID("game-missing-x")) require.ErrorIs(t, err, game.ErrNotFound) } func TestSaveIsUpsert(t *testing.T) { ctx := context.Background() store := newStore(t) record := fixturePublicGame(t, "game-001") require.NoError(t, store.Save(ctx, record)) // edit a few fields, save again record.GameName = "Renamed" record.UpdatedAt = record.UpdatedAt.Add(time.Minute) require.NoError(t, store.Save(ctx, record)) got, err := store.Get(ctx, record.GameID) require.NoError(t, err) assert.Equal(t, "Renamed", got.GameName) } func TestUpdateStatusHappyPath(t *testing.T) { ctx := context.Background() store := newStore(t) record := fixturePublicGame(t, "game-001") require.NoError(t, store.Save(ctx, record)) require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{ GameID: record.GameID, ExpectedFrom: game.StatusDraft, To: game.StatusEnrollmentOpen, Trigger: game.TriggerCommand, At: record.UpdatedAt.Add(time.Minute), })) got, err := store.Get(ctx, record.GameID) require.NoError(t, err) assert.Equal(t, game.StatusEnrollmentOpen, got.Status) } func TestUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) { ctx := context.Background() store := newStore(t) record := fixturePublicGame(t, "game-001") require.NoError(t, store.Save(ctx, record)) err := store.UpdateStatus(ctx, ports.UpdateStatusInput{ GameID: record.GameID, ExpectedFrom: game.StatusEnrollmentOpen, // wrong To: game.StatusReadyToStart, Trigger: game.TriggerManual, At: record.UpdatedAt.Add(time.Minute), }) require.ErrorIs(t, err, game.ErrConflict) } func TestUpdateStatusReturnsNotFoundForMissing(t *testing.T) { ctx := context.Background() store := newStore(t) err := store.UpdateStatus(ctx, ports.UpdateStatusInput{ GameID: common.GameID("game-missing-x"), ExpectedFrom: game.StatusDraft, To: game.StatusEnrollmentOpen, Trigger: game.TriggerCommand, At: time.Now().UTC(), }) require.ErrorIs(t, err, game.ErrNotFound) } func TestUpdateStatusSetsStartedAtOnRunning(t *testing.T) { ctx := context.Background() store := newStore(t) record := fixturePublicGame(t, "game-001") require.NoError(t, store.Save(ctx, record)) advance := func(from, to game.Status, trigger game.Trigger, at time.Time) { require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{ GameID: record.GameID, ExpectedFrom: from, To: to, Trigger: trigger, At: at, })) } now := record.UpdatedAt.Add(time.Minute) advance(game.StatusDraft, game.StatusEnrollmentOpen, game.TriggerCommand, now) advance(game.StatusEnrollmentOpen, game.StatusReadyToStart, game.TriggerManual, now.Add(time.Minute)) advance(game.StatusReadyToStart, game.StatusStarting, game.TriggerCommand, now.Add(2*time.Minute)) startedAt := now.Add(3 * time.Minute) advance(game.StatusStarting, game.StatusRunning, game.TriggerRuntimeEvent, 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)) } func TestGetByStatusReturnsExpectedRecords(t *testing.T) { ctx := context.Background() store := newStore(t) a := fixturePublicGame(t, "game-aaa") b := fixturePublicGame(t, "game-bbb") c := fixturePublicGame(t, "game-ccc") for _, r := range []game.Game{a, b, c} { require.NoError(t, store.Save(ctx, r)) } require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{ GameID: b.GameID, ExpectedFrom: game.StatusDraft, To: game.StatusEnrollmentOpen, Trigger: game.TriggerCommand, At: b.UpdatedAt.Add(time.Minute), })) drafts, err := store.GetByStatus(ctx, game.StatusDraft) require.NoError(t, err) gotIDs := map[common.GameID]struct{}{} for _, r := range drafts { gotIDs[r.GameID] = struct{}{} } assert.Contains(t, gotIDs, a.GameID) assert.Contains(t, gotIDs, c.GameID) assert.NotContains(t, gotIDs, b.GameID) open, err := store.GetByStatus(ctx, game.StatusEnrollmentOpen) require.NoError(t, err) require.Len(t, open, 1) assert.Equal(t, b.GameID, open[0].GameID) } func TestGetByOwnerOnlyReturnsPrivateGames(t *testing.T) { ctx := context.Background() store := newStore(t) owner := "user-123" pub := fixturePublicGame(t, "game-pub-001") priv1 := fixturePrivateGame(t, "game-priv-001", owner) priv2 := fixturePrivateGame(t, "game-priv-002", owner) priv3 := fixturePrivateGame(t, "game-priv-003", "user-other") for _, r := range []game.Game{pub, priv1, priv2, priv3} { require.NoError(t, store.Save(ctx, r)) } got, err := store.GetByOwner(ctx, owner) require.NoError(t, err) ids := map[common.GameID]struct{}{} for _, r := range got { ids[r.GameID] = struct{}{} } assert.Contains(t, ids, priv1.GameID) assert.Contains(t, ids, priv2.GameID) assert.NotContains(t, ids, priv3.GameID) assert.NotContains(t, ids, pub.GameID) } func TestCountByStatusIncludesAllBuckets(t *testing.T) { ctx := context.Background() store := newStore(t) require.NoError(t, store.Save(ctx, fixturePublicGame(t, "game-aaa"))) require.NoError(t, store.Save(ctx, fixturePublicGame(t, "game-bbb"))) counts, err := store.CountByStatus(ctx) require.NoError(t, err) for _, status := range game.AllStatuses() { _, ok := counts[status] assert.Truef(t, ok, "missing bucket for %q", status) } assert.Equal(t, 2, counts[game.StatusDraft]) } func TestUpdateRuntimeSnapshotRoundTripsValues(t *testing.T) { ctx := context.Background() store := newStore(t) record := fixturePublicGame(t, "game-001") require.NoError(t, store.Save(ctx, record)) snapshot := game.RuntimeSnapshot{ CurrentTurn: 42, RuntimeStatus: "running_accepting_commands", EngineHealthSummary: "ok", } require.NoError(t, store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{ GameID: record.GameID, Snapshot: snapshot, At: record.UpdatedAt.Add(time.Minute), })) got, err := store.Get(ctx, record.GameID) require.NoError(t, err) assert.Equal(t, snapshot, got.RuntimeSnapshot) } func TestUpdateRuntimeBindingRoundTripsValues(t *testing.T) { ctx := context.Background() store := newStore(t) record := fixturePublicGame(t, "game-001") require.NoError(t, store.Save(ctx, record)) at := record.UpdatedAt.Add(time.Minute) require.NoError(t, store.UpdateRuntimeBinding(ctx, ports.UpdateRuntimeBindingInput{ GameID: record.GameID, Binding: game.RuntimeBinding{ ContainerID: "container-7", EngineEndpoint: "10.0.0.5:9000", RuntimeJobID: "1700000000-0", BoundAt: at, }, At: at, })) got, err := store.Get(ctx, record.GameID) require.NoError(t, err) require.NotNil(t, got.RuntimeBinding) assert.Equal(t, "container-7", got.RuntimeBinding.ContainerID) assert.Equal(t, "10.0.0.5:9000", got.RuntimeBinding.EngineEndpoint) assert.Equal(t, "1700000000-0", got.RuntimeBinding.RuntimeJobID) assert.True(t, got.RuntimeBinding.BoundAt.Equal(at)) assert.Equal(t, time.UTC, got.RuntimeBinding.BoundAt.Location()) } func TestUpdateRuntimeSnapshotReturnsNotFoundForMissing(t *testing.T) { ctx := context.Background() store := newStore(t) err := store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{ GameID: common.GameID("game-missing-x"), Snapshot: game.RuntimeSnapshot{CurrentTurn: 1}, At: time.Now().UTC(), }) require.ErrorIs(t, err, game.ErrNotFound) } func TestNewRejectsNilDB(t *testing.T) { _, err := gamestore.New(gamestore.Config{OperationTimeout: time.Second}) require.Error(t, err) } func TestNewRejectsNonPositiveTimeout(t *testing.T) { _, err := gamestore.New(gamestore.Config{DB: pgtest.Ensure(t).Pool()}) require.Error(t, err) }