package pausegame_test import ( "context" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/gamestub" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/pausegame" "galaxy/lobby/internal/service/shared" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func fixedClock(at time.Time) func() time.Time { return func() time.Time { return at } } // seedGameWithStatus persists a fresh game and, when wanted differs from // `draft`, overwrites the Status field directly via Save. This bypasses // the transition gate so individual tests can exercise pause against // any source status. func seedGameWithStatus( t *testing.T, store *gamestub.Store, id common.GameID, gameType game.GameType, ownerUserID string, status game.Status, now time.Time, ) game.Game { t.Helper() record, err := game.New(game.NewGameInput{ GameID: id, GameName: "test pause game", GameType: gameType, OwnerUserID: ownerUserID, MinPlayers: 2, MaxPlayers: 4, StartGapHours: 4, StartGapPlayers: 1, EnrollmentEndsAt: now.Add(24 * time.Hour), TurnSchedule: "0 */6 * * *", TargetEngineVersion: "1.0.0", Now: now, }) require.NoError(t, err) if status != game.StatusDraft { record.Status = status record.UpdatedAt = now.Add(time.Minute) if status == game.StatusRunning || status == game.StatusPaused || status == game.StatusFinished { startedAt := now.Add(time.Minute) record.StartedAt = &startedAt } if status == game.StatusFinished { finishedAt := now.Add(2 * time.Minute) record.FinishedAt = &finishedAt } } require.NoError(t, store.Save(context.Background(), record)) return record } func newService(t *testing.T, store ports.GameStore, clock func() time.Time) *pausegame.Service { t.Helper() svc, err := pausegame.NewService(pausegame.Dependencies{ Games: store, Clock: clock, Logger: silentLogger(), }) require.NoError(t, err) return svc } func TestNewServiceRejectsMissingDeps(t *testing.T) { t.Parallel() _, err := pausegame.NewService(pausegame.Dependencies{}) require.Error(t, err) } func TestPauseGameAdminHappyPath(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) store := gamestub.NewStore() record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusRunning, now) at := now.Add(time.Hour) service := newService(t, store, fixedClock(at)) updated, err := service.Handle(context.Background(), pausegame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.NoError(t, err) assert.Equal(t, game.StatusPaused, updated.Status) assert.Equal(t, at.UTC(), updated.UpdatedAt) } func TestPauseGamePrivateOwnerHappyPath(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) store := gamestub.NewStore() record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-owner", game.StatusRunning, now) service := newService(t, store, fixedClock(now.Add(time.Hour))) updated, err := service.Handle(context.Background(), pausegame.Input{ Actor: shared.NewUserActor("user-owner"), GameID: record.GameID, }) require.NoError(t, err) assert.Equal(t, game.StatusPaused, updated.Status) } func TestPauseGameRejectsNonOwnerUser(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) store := gamestub.NewStore() record := seedGameWithStatus(t, store, "game-priv", game.GameTypePrivate, "user-owner", game.StatusRunning, now) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), pausegame.Input{ Actor: shared.NewUserActor("user-other"), GameID: record.GameID, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestPauseGameRejectsUserActorOnPublicGame(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) store := gamestub.NewStore() record := seedGameWithStatus(t, store, "game-pub", game.GameTypePublic, "", game.StatusRunning, now) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), pausegame.Input{ Actor: shared.NewUserActor("user-x"), GameID: record.GameID, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestPauseGameRejectsWrongStatuses(t *testing.T) { t.Parallel() statuses := []game.Status{ game.StatusDraft, game.StatusEnrollmentOpen, game.StatusReadyToStart, game.StatusStarting, game.StatusStartFailed, game.StatusPaused, game.StatusFinished, game.StatusCancelled, } for _, status := range statuses { t.Run(string(status), func(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) store := gamestub.NewStore() record := seedGameWithStatus(t, store, "game-x", game.GameTypePublic, "", status, now) service := newService(t, store, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), pausegame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.ErrorIs(t, err, game.ErrConflict) }) } } func TestPauseGameRejectsMissingRecord(t *testing.T) { t.Parallel() store := gamestub.NewStore() service := newService(t, store, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC))) _, err := service.Handle(context.Background(), pausegame.Input{ Actor: shared.NewAdminActor(), GameID: common.GameID("game-missing"), }) require.ErrorIs(t, err, game.ErrNotFound) } func TestPauseGameInvalidActor(t *testing.T) { t.Parallel() store := gamestub.NewStore() service := newService(t, store, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC))) _, err := service.Handle(context.Background(), pausegame.Input{ Actor: shared.Actor{Kind: shared.ActorKindUser}, GameID: "game-x", }) require.Error(t, err) require.Contains(t, err.Error(), "actor") } func TestPauseGameInvalidGameID(t *testing.T) { t.Parallel() store := gamestub.NewStore() service := newService(t, store, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC))) _, err := service.Handle(context.Background(), pausegame.Input{ Actor: shared.NewAdminActor(), GameID: "bad", }) require.Error(t, err) }