package resumegame_test import ( "context" "errors" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/gamestub" "galaxy/lobby/internal/adapters/gmclientstub" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/resumegame" "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 overrides Status to the // requested value so individual tests can exercise resume 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 resume 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, gm ports.GMClient, clock func() time.Time, ) *resumegame.Service { t.Helper() svc, err := resumegame.NewService(resumegame.Dependencies{ Games: store, GM: gm, Clock: clock, Logger: silentLogger(), }) require.NoError(t, err) return svc } func TestNewServiceRejectsMissingDeps(t *testing.T) { t.Parallel() _, err := resumegame.NewService(resumegame.Dependencies{}) require.Error(t, err) _, err = resumegame.NewService(resumegame.Dependencies{Games: gamestub.NewStore()}) require.Error(t, err) } func TestResumeGameAdminHappyPath(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.StatusPaused, now) gm := gmclientstub.NewClient() service := newService(t, store, gm, fixedClock(now.Add(time.Hour))) updated, err := service.Handle(context.Background(), resumegame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.NoError(t, err) assert.Equal(t, game.StatusRunning, updated.Status) assert.Equal(t, 1, gm.PingCalls()) } func TestResumeGamePrivateOwnerHappyPath(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.StatusPaused, now) gm := gmclientstub.NewClient() service := newService(t, store, gm, fixedClock(now.Add(time.Hour))) updated, err := service.Handle(context.Background(), resumegame.Input{ Actor: shared.NewUserActor("user-owner"), GameID: record.GameID, }) require.NoError(t, err) assert.Equal(t, game.StatusRunning, updated.Status) assert.Equal(t, 1, gm.PingCalls()) } func TestResumeGameRejectsNonOwnerUser(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.StatusPaused, now) gm := gmclientstub.NewClient() service := newService(t, store, gm, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), resumegame.Input{ Actor: shared.NewUserActor("user-other"), GameID: record.GameID, }) require.ErrorIs(t, err, shared.ErrForbidden) assert.Equal(t, 0, gm.PingCalls(), "ping must not run before authorization passes") } func TestResumeGameRejectsUserActorOnPublicGame(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.StatusPaused, now) gm := gmclientstub.NewClient() service := newService(t, store, gm, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), resumegame.Input{ Actor: shared.NewUserActor("user-x"), GameID: record.GameID, }) require.ErrorIs(t, err, shared.ErrForbidden) assert.Equal(t, 0, gm.PingCalls()) } func TestResumeGameRejectsWrongStatuses(t *testing.T) { t.Parallel() statuses := []game.Status{ game.StatusDraft, game.StatusEnrollmentOpen, game.StatusReadyToStart, game.StatusStarting, game.StatusStartFailed, game.StatusRunning, 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) gm := gmclientstub.NewClient() service := newService(t, store, gm, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), resumegame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.ErrorIs(t, err, game.ErrConflict) assert.Equal(t, 0, gm.PingCalls(), "ping must not run before status check passes") }) } } func TestResumeGameGMUnavailableKeepsPaused(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.StatusPaused, now) gm := gmclientstub.NewClient() gm.SetPingError(errors.Join(ports.ErrGMUnavailable, errors.New("dial tcp: connection refused"))) service := newService(t, store, gm, fixedClock(now.Add(time.Hour))) _, err := service.Handle(context.Background(), resumegame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.Error(t, err) assert.ErrorIs(t, err, shared.ErrServiceUnavailable) assert.ErrorIs(t, err, ports.ErrGMUnavailable) assert.Equal(t, 1, gm.PingCalls()) persisted, err := store.Get(context.Background(), record.GameID) require.NoError(t, err) assert.Equal(t, game.StatusPaused, persisted.Status, "game must remain paused when GM is unavailable") } func TestResumeGameRejectsMissingRecord(t *testing.T) { t.Parallel() gm := gmclientstub.NewClient() store := gamestub.NewStore() service := newService(t, store, gm, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC))) _, err := service.Handle(context.Background(), resumegame.Input{ Actor: shared.NewAdminActor(), GameID: common.GameID("game-missing"), }) require.ErrorIs(t, err, game.ErrNotFound) assert.Equal(t, 0, gm.PingCalls()) } func TestResumeGameInvalidActor(t *testing.T) { t.Parallel() gm := gmclientstub.NewClient() store := gamestub.NewStore() service := newService(t, store, gm, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC))) _, err := service.Handle(context.Background(), resumegame.Input{ Actor: shared.Actor{Kind: shared.ActorKindUser}, GameID: "game-x", }) require.Error(t, err) require.Contains(t, err.Error(), "actor") } func TestResumeGameInvalidGameID(t *testing.T) { t.Parallel() gm := gmclientstub.NewClient() store := gamestub.NewStore() service := newService(t, store, gm, fixedClock(time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC))) _, err := service.Handle(context.Background(), resumegame.Input{ Actor: shared.NewAdminActor(), GameID: "bad", }) require.Error(t, err) }