package startgame_test import ( "context" "errors" "io" "log/slog" "sync" "testing" "time" "galaxy/lobby/internal/adapters/gameinmem" "galaxy/lobby/internal/adapters/mocks" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/engineimage" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" "galaxy/lobby/internal/service/startgame" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) const testImageTemplate = "registry.example.com/galaxy/game:{engine_version}" 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 } } func newReadyGame(t *testing.T, gameType game.GameType, ownerID string) (game.Game, time.Time) { t.Helper() now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) record, err := game.New(game.NewGameInput{ GameID: common.GameID("game-1"), GameName: "test game", GameType: gameType, OwnerUserID: ownerID, MinPlayers: 4, MaxPlayers: 8, StartGapHours: 12, StartGapPlayers: 2, EnrollmentEndsAt: now.Add(24 * time.Hour), TurnSchedule: "0 18 * * *", TargetEngineVersion: "v1.0.0", Now: now, }) require.NoError(t, err) record.Status = game.StatusReadyToStart return record, now } // runtimeRec captures every PublishStartJob/PublishStopJob call so tests // can assert which jobs ran. Per-test error injection sets startErr. type runtimeRec struct { mu sync.Mutex startIDs []string startRefs []string stopIDs []string stopReas []ports.StopReason startErr error } func (r *runtimeRec) recordStart(_ context.Context, gameID, imageRef string) error { r.mu.Lock() defer r.mu.Unlock() if r.startErr != nil { return r.startErr } r.startIDs = append(r.startIDs, gameID) r.startRefs = append(r.startRefs, imageRef) return nil } func (r *runtimeRec) recordStop(_ context.Context, gameID string, reason ports.StopReason) error { r.mu.Lock() defer r.mu.Unlock() r.stopIDs = append(r.stopIDs, gameID) r.stopReas = append(r.stopReas, reason) return nil } func (r *runtimeRec) startJobs() []string { r.mu.Lock() defer r.mu.Unlock() return append([]string(nil), r.startIDs...) } func (r *runtimeRec) startImageRefs() []string { r.mu.Lock() defer r.mu.Unlock() return append([]string(nil), r.startRefs...) } func (r *runtimeRec) stopJobs() []string { r.mu.Lock() defer r.mu.Unlock() return append([]string(nil), r.stopIDs...) } func newRuntimeMock(t *testing.T, rec *runtimeRec) *mocks.MockRuntimeManager { t.Helper() m := mocks.NewMockRuntimeManager(gomock.NewController(t)) m.EXPECT().PublishStartJob(gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(rec.recordStart).AnyTimes() m.EXPECT().PublishStopJob(gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(rec.recordStop).AnyTimes() return m } type fixture struct { games *gameinmem.Store rec *runtimeRec runtime *mocks.MockRuntimeManager service *startgame.Service now time.Time } func newFixture(t *testing.T, record game.Game, now time.Time) *fixture { t.Helper() games := gameinmem.NewStore() require.NoError(t, games.Save(context.Background(), record)) rec := &runtimeRec{} runtime := newRuntimeMock(t, rec) resolver, err := engineimage.NewResolver(testImageTemplate) require.NoError(t, err) service, err := startgame.NewService(startgame.Dependencies{ Games: games, RuntimeManager: runtime, ImageResolver: resolver, Clock: fixedClock(now.Add(time.Hour)), Logger: silentLogger(), }) require.NoError(t, err) return &fixture{games: games, rec: rec, runtime: runtime, service: service, now: now} } func TestNewServiceRejectsMissingDeps(t *testing.T) { resolver, err := engineimage.NewResolver(testImageTemplate) require.NoError(t, err) rec := &runtimeRec{} runtime := newRuntimeMock(t, rec) _, err = startgame.NewService(startgame.Dependencies{ RuntimeManager: runtime, ImageResolver: resolver, }) require.Error(t, err) _, err = startgame.NewService(startgame.Dependencies{ Games: gameinmem.NewStore(), ImageResolver: resolver, }) require.Error(t, err) _, err = startgame.NewService(startgame.Dependencies{ Games: gameinmem.NewStore(), RuntimeManager: runtime, }) require.Error(t, err) } func TestStartGamePublicAdminHappyPath(t *testing.T) { record, now := newReadyGame(t, game.GameTypePublic, "") f := newFixture(t, record, now) updated, err := f.service.Handle(context.Background(), startgame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.NoError(t, err) assert.Equal(t, game.StatusStarting, updated.Status) assert.Equal(t, []string{record.GameID.String()}, f.rec.startJobs()) assert.Equal(t, []string{"registry.example.com/galaxy/game:" + record.TargetEngineVersion}, f.rec.startImageRefs(), "resolved image_ref must propagate to publisher", ) assert.Empty(t, f.rec.stopJobs()) } func TestStartGamePrivateOwnerHappyPath(t *testing.T) { record, now := newReadyGame(t, game.GameTypePrivate, "user-owner") f := newFixture(t, record, now) updated, err := f.service.Handle(context.Background(), startgame.Input{ Actor: shared.NewUserActor("user-owner"), GameID: record.GameID, }) require.NoError(t, err) assert.Equal(t, game.StatusStarting, updated.Status) assert.Equal(t, []string{record.GameID.String()}, f.rec.startJobs()) } func TestStartGameRejectsNonOwnerUser(t *testing.T) { record, now := newReadyGame(t, game.GameTypePrivate, "user-owner") f := newFixture(t, record, now) _, err := f.service.Handle(context.Background(), startgame.Input{ Actor: shared.NewUserActor("user-other"), GameID: record.GameID, }) require.ErrorIs(t, err, shared.ErrForbidden) assert.Empty(t, f.rec.startJobs(), "no start job published on forbidden") stored, err := f.games.Get(context.Background(), record.GameID) require.NoError(t, err) assert.Equal(t, game.StatusReadyToStart, stored.Status, "status must remain ready_to_start") } func TestStartGameRejectsPublicUserActor(t *testing.T) { record, now := newReadyGame(t, game.GameTypePublic, "") f := newFixture(t, record, now) _, err := f.service.Handle(context.Background(), startgame.Input{ Actor: shared.NewUserActor("user-1"), GameID: record.GameID, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestStartGameRejectsWrongStatus(t *testing.T) { record, now := newReadyGame(t, game.GameTypePublic, "") record.Status = game.StatusEnrollmentOpen f := newFixture(t, record, now) _, err := f.service.Handle(context.Background(), startgame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.ErrorIs(t, err, game.ErrConflict) assert.Empty(t, f.rec.startJobs()) } func TestStartGameRejectsCASLossOnRecentTransition(t *testing.T) { record, now := newReadyGame(t, game.GameTypePublic, "") f := newFixture(t, record, now) // Concurrent start: simulate someone moved status to starting. require.NoError(t, f.games.UpdateStatus(context.Background(), ports.UpdateStatusInput{ GameID: record.GameID, ExpectedFrom: game.StatusReadyToStart, To: game.StatusStarting, Trigger: game.TriggerCommand, At: now.Add(time.Minute), })) _, err := f.service.Handle(context.Background(), startgame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.ErrorIs(t, err, game.ErrConflict) assert.Empty(t, f.rec.startJobs()) } func TestStartGamePublishFailureSurfacesUnavailable(t *testing.T) { record, now := newReadyGame(t, game.GameTypePublic, "") f := newFixture(t, record, now) f.rec.startErr = errors.New("redis down") _, err := f.service.Handle(context.Background(), startgame.Input{ Actor: shared.NewAdminActor(), GameID: record.GameID, }) require.Error(t, err) assert.ErrorIs(t, err, shared.ErrServiceUnavailable) stored, err := f.games.Get(context.Background(), record.GameID) require.NoError(t, err) assert.Equal(t, game.StatusStarting, stored.Status, "status remains starting; runtime worker resolves orphan via job result") } func TestStartGameRejectsMissingRecord(t *testing.T) { games := gameinmem.NewStore() rec := &runtimeRec{} runtime := newRuntimeMock(t, rec) resolver, err := engineimage.NewResolver(testImageTemplate) require.NoError(t, err) service, err := startgame.NewService(startgame.Dependencies{ Games: games, RuntimeManager: runtime, ImageResolver: resolver, Clock: fixedClock(time.Now().UTC()), Logger: silentLogger(), }) require.NoError(t, err) _, err = service.Handle(context.Background(), startgame.Input{ Actor: shared.NewAdminActor(), GameID: common.GameID("game-missing"), }) require.ErrorIs(t, err, game.ErrNotFound) }