package gameinmem import ( "context" "errors" "testing" "time" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "github.com/stretchr/testify/require" ) func newDraftRecord(t *testing.T, id common.GameID, createdAt time.Time) game.Game { t.Helper() record, err := game.New(game.NewGameInput{ GameID: id, GameName: "Test Game", GameType: game.GameTypePublic, OwnerUserID: "", MinPlayers: 2, MaxPlayers: 4, StartGapHours: 4, StartGapPlayers: 1, EnrollmentEndsAt: createdAt.Add(24 * time.Hour), TurnSchedule: "0 */6 * * *", TargetEngineVersion: "1.0.0", Now: createdAt, }) require.NoError(t, err) return record } func TestStoreSaveGetRoundtrip(t *testing.T) { t.Parallel() store := NewStore() ctx := context.Background() record := newDraftRecord(t, "game-alpha", time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)) require.NoError(t, store.Save(ctx, record)) loaded, err := store.Get(ctx, "game-alpha") require.NoError(t, err) require.Equal(t, record.GameID, loaded.GameID) require.Equal(t, record.Status, loaded.Status) require.Equal(t, record.UpdatedAt.UTC(), loaded.UpdatedAt) } func TestStoreGetMissing(t *testing.T) { t.Parallel() store := NewStore() _, err := store.Get(context.Background(), "game-missing") require.ErrorIs(t, err, game.ErrNotFound) } func TestStoreGetByStatusOrderedByCreatedAt(t *testing.T) { t.Parallel() store := NewStore() ctx := context.Background() earlier := time.Date(2026, 4, 24, 9, 0, 0, 0, time.UTC) later := earlier.Add(30 * time.Minute) a := newDraftRecord(t, "game-a", earlier) b := newDraftRecord(t, "game-b", later) require.NoError(t, store.Save(ctx, b)) require.NoError(t, store.Save(ctx, a)) records, err := store.GetByStatus(ctx, game.StatusDraft) require.NoError(t, err) require.Len(t, records, 2) require.Equal(t, common.GameID("game-a"), records[0].GameID) require.Equal(t, common.GameID("game-b"), records[1].GameID) } func TestStoreCountByStatusReturnsAllStatusBuckets(t *testing.T) { t.Parallel() store := NewStore() ctx := context.Background() createdAt := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC) require.NoError(t, store.Save(ctx, newDraftRecord(t, "game-a", createdAt))) require.NoError(t, store.Save(ctx, newDraftRecord(t, "game-b", createdAt))) 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, 0, counts[game.StatusRunning]) } func TestStoreUpdateStatusHappyPath(t *testing.T) { t.Parallel() store := NewStore() ctx := context.Background() created := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) record := newDraftRecord(t, "game-open", created) require.NoError(t, store.Save(ctx, record)) at := created.Add(time.Hour) err := store.UpdateStatus(ctx, ports.UpdateStatusInput{ GameID: "game-open", ExpectedFrom: game.StatusDraft, To: game.StatusEnrollmentOpen, Trigger: game.TriggerCommand, At: at, }) require.NoError(t, err) loaded, err := store.Get(ctx, "game-open") require.NoError(t, err) require.Equal(t, game.StatusEnrollmentOpen, loaded.Status) require.Equal(t, at.UTC(), loaded.UpdatedAt) } func TestStoreUpdateStatusInvalidTransition(t *testing.T) { t.Parallel() store := NewStore() ctx := context.Background() record := newDraftRecord(t, "game-invalid", time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)) require.NoError(t, store.Save(ctx, record)) err := store.UpdateStatus(ctx, ports.UpdateStatusInput{ GameID: "game-invalid", ExpectedFrom: game.StatusDraft, To: game.StatusRunning, Trigger: game.TriggerCommand, At: time.Now().UTC(), }) require.Error(t, err) require.ErrorIs(t, err, game.ErrInvalidTransition) } func TestStoreUpdateStatusCASMismatch(t *testing.T) { t.Parallel() store := NewStore() ctx := context.Background() created := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) record := newDraftRecord(t, "game-cas", created) require.NoError(t, store.Save(ctx, record)) err := store.UpdateStatus(ctx, ports.UpdateStatusInput{ GameID: "game-cas", ExpectedFrom: game.StatusEnrollmentOpen, To: game.StatusReadyToStart, Trigger: game.TriggerManual, At: created.Add(time.Hour), }) require.Error(t, err) require.ErrorIs(t, err, game.ErrConflict) } func TestStoreUpdateStatusMissing(t *testing.T) { t.Parallel() store := NewStore() err := store.UpdateStatus(context.Background(), ports.UpdateStatusInput{ GameID: "game-nope", ExpectedFrom: game.StatusDraft, To: game.StatusEnrollmentOpen, Trigger: game.TriggerCommand, At: time.Now().UTC(), }) require.ErrorIs(t, err, game.ErrNotFound) } func TestStoreUpdateRuntimeSnapshot(t *testing.T) { t.Parallel() store := NewStore() ctx := context.Background() created := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) record := newDraftRecord(t, "game-snap", created) require.NoError(t, store.Save(ctx, record)) err := store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{ GameID: "game-snap", Snapshot: game.RuntimeSnapshot{ CurrentTurn: 7, RuntimeStatus: "alive", EngineHealthSummary: "ok", }, At: created.Add(2 * time.Hour), }) require.NoError(t, err) loaded, err := store.Get(ctx, "game-snap") require.NoError(t, err) require.Equal(t, 7, loaded.RuntimeSnapshot.CurrentTurn) require.Equal(t, "alive", loaded.RuntimeSnapshot.RuntimeStatus) require.Equal(t, game.StatusDraft, loaded.Status, "snapshot update must not alter status") } func TestStoreValidateInputs(t *testing.T) { t.Parallel() store := NewStore() ctx := context.Background() err := store.UpdateStatus(ctx, ports.UpdateStatusInput{GameID: ""}) require.Error(t, err) err = store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{GameID: ""}) require.Error(t, err) _, err = store.GetByStatus(ctx, game.Status("ghost")) require.Error(t, err) require.True(t, errors.Is(game.ErrNotFound, game.ErrNotFound)) } func TestStoreUpdateStatusSetsStartedAtAndFinishedAt(t *testing.T) { t.Parallel() store := NewStore() ctx := context.Background() created := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC) record := newDraftRecord(t, "game-timeline", created) record.Status = game.StatusStarting record.UpdatedAt = created.Add(time.Hour) require.NoError(t, store.Save(ctx, record)) runningAt := created.Add(2 * time.Hour) err := store.UpdateStatus(ctx, ports.UpdateStatusInput{ GameID: "game-timeline", ExpectedFrom: game.StatusStarting, To: game.StatusRunning, Trigger: game.TriggerRuntimeEvent, At: runningAt, }) require.NoError(t, err) loaded, err := store.Get(ctx, "game-timeline") require.NoError(t, err) require.NotNil(t, loaded.StartedAt) require.Equal(t, runningAt.UTC(), loaded.StartedAt.UTC()) require.Nil(t, loaded.FinishedAt) finishAt := runningAt.Add(5 * time.Hour) err = store.UpdateStatus(ctx, ports.UpdateStatusInput{ GameID: "game-timeline", ExpectedFrom: game.StatusRunning, To: game.StatusFinished, Trigger: game.TriggerRuntimeEvent, At: finishAt, }) require.NoError(t, err) loaded, err = store.Get(ctx, "game-timeline") require.NoError(t, err) require.NotNil(t, loaded.FinishedAt) require.Equal(t, finishAt.UTC(), loaded.FinishedAt.UTC()) require.Equal(t, runningAt.UTC(), loaded.StartedAt.UTC(), "StartedAt must be preserved") }