package turngeneration_test import ( "context" "errors" "fmt" "sync" "testing" "time" "galaxy/gamemaster/internal/adapters/mocks" "galaxy/gamemaster/internal/domain/operation" "galaxy/gamemaster/internal/domain/playermapping" "galaxy/gamemaster/internal/domain/runtime" "galaxy/gamemaster/internal/ports" "galaxy/gamemaster/internal/service/scheduler" "galaxy/gamemaster/internal/service/turngeneration" "galaxy/gamemaster/internal/telemetry" "galaxy/notificationintent" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) // --- test doubles ----------------------------------------------------- type fakeRuntimeRecords struct { mu sync.Mutex stored map[string]runtime.RuntimeRecord getErr error updErr error schErr error insErr error updates []ports.UpdateStatusInput scheds []ports.UpdateSchedulingInput getCalls int } func newFakeRuntimeRecords() *fakeRuntimeRecords { return &fakeRuntimeRecords{stored: map[string]runtime.RuntimeRecord{}} } func (s *fakeRuntimeRecords) seed(record runtime.RuntimeRecord) { s.mu.Lock() defer s.mu.Unlock() s.stored[record.GameID] = record } func (s *fakeRuntimeRecords) Get(_ context.Context, gameID string) (runtime.RuntimeRecord, error) { s.mu.Lock() defer s.mu.Unlock() s.getCalls++ if s.getErr != nil { return runtime.RuntimeRecord{}, s.getErr } record, ok := s.stored[gameID] if !ok { return runtime.RuntimeRecord{}, runtime.ErrNotFound } return record, nil } func (s *fakeRuntimeRecords) Insert(_ context.Context, record runtime.RuntimeRecord) error { s.mu.Lock() defer s.mu.Unlock() if s.insErr != nil { return s.insErr } if _, ok := s.stored[record.GameID]; ok { return runtime.ErrConflict } s.stored[record.GameID] = record return nil } func (s *fakeRuntimeRecords) UpdateStatus(_ context.Context, input ports.UpdateStatusInput) error { s.mu.Lock() defer s.mu.Unlock() s.updates = append(s.updates, input) if s.updErr != nil { return s.updErr } record, ok := s.stored[input.GameID] if !ok { return runtime.ErrNotFound } if record.Status != input.ExpectedFrom { return runtime.ErrConflict } record.Status = input.To record.UpdatedAt = input.Now if input.To == runtime.StatusFinished { finishedAt := input.Now record.FinishedAt = &finishedAt } if input.To == runtime.StatusRunning && record.StartedAt == nil { startedAt := input.Now record.StartedAt = &startedAt } s.stored[input.GameID] = record return nil } func (s *fakeRuntimeRecords) UpdateScheduling(_ context.Context, input ports.UpdateSchedulingInput) error { s.mu.Lock() defer s.mu.Unlock() s.scheds = append(s.scheds, input) if s.schErr != nil { return s.schErr } record, ok := s.stored[input.GameID] if !ok { return runtime.ErrNotFound } if input.NextGenerationAt != nil { next := *input.NextGenerationAt record.NextGenerationAt = &next } else { record.NextGenerationAt = nil } record.SkipNextTick = input.SkipNextTick record.CurrentTurn = input.CurrentTurn record.UpdatedAt = input.Now s.stored[input.GameID] = record return nil } func (s *fakeRuntimeRecords) UpdateImage(_ context.Context, _ ports.UpdateImageInput) error { return errors.New("not used in turngeneration tests") } func (s *fakeRuntimeRecords) UpdateEngineHealth(_ context.Context, _ ports.UpdateEngineHealthInput) error { return errors.New("not used in turngeneration tests") } func (s *fakeRuntimeRecords) Delete(_ context.Context, _ string) error { return errors.New("not used in turngeneration tests") } func (s *fakeRuntimeRecords) ListDueRunning(_ context.Context, _ time.Time) ([]runtime.RuntimeRecord, error) { return nil, errors.New("not used in turngeneration tests") } func (s *fakeRuntimeRecords) ListByStatus(_ context.Context, _ runtime.Status) ([]runtime.RuntimeRecord, error) { return nil, errors.New("not used in turngeneration tests") } func (s *fakeRuntimeRecords) List(_ context.Context) ([]runtime.RuntimeRecord, error) { return nil, errors.New("not used in turngeneration tests") } func (s *fakeRuntimeRecords) record(gameID string) (runtime.RuntimeRecord, bool) { s.mu.Lock() defer s.mu.Unlock() record, ok := s.stored[gameID] return record, ok } func (s *fakeRuntimeRecords) statusUpdates() []ports.UpdateStatusInput { s.mu.Lock() defer s.mu.Unlock() out := make([]ports.UpdateStatusInput, len(s.updates)) copy(out, s.updates) return out } func (s *fakeRuntimeRecords) scheduling() []ports.UpdateSchedulingInput { s.mu.Lock() defer s.mu.Unlock() out := make([]ports.UpdateSchedulingInput, len(s.scheds)) copy(out, s.scheds) return out } type fakePlayerMappings struct { mu sync.Mutex stored map[string][]playermapping.PlayerMapping listErr error } func newFakePlayerMappings() *fakePlayerMappings { return &fakePlayerMappings{stored: map[string][]playermapping.PlayerMapping{}} } func (s *fakePlayerMappings) seed(gameID string, members ...playermapping.PlayerMapping) { s.mu.Lock() defer s.mu.Unlock() s.stored[gameID] = append([]playermapping.PlayerMapping(nil), members...) } func (s *fakePlayerMappings) BulkInsert(_ context.Context, _ []playermapping.PlayerMapping) error { return errors.New("not used in turngeneration tests") } func (s *fakePlayerMappings) Get(_ context.Context, _, _ string) (playermapping.PlayerMapping, error) { return playermapping.PlayerMapping{}, errors.New("not used in turngeneration tests") } func (s *fakePlayerMappings) GetByRace(_ context.Context, _, _ string) (playermapping.PlayerMapping, error) { return playermapping.PlayerMapping{}, errors.New("not used in turngeneration tests") } func (s *fakePlayerMappings) ListByGame(_ context.Context, gameID string) ([]playermapping.PlayerMapping, error) { s.mu.Lock() defer s.mu.Unlock() if s.listErr != nil { return nil, s.listErr } return append([]playermapping.PlayerMapping(nil), s.stored[gameID]...), nil } func (s *fakePlayerMappings) DeleteByGame(_ context.Context, _ string) error { return errors.New("not used in turngeneration tests") } type fakeOperationLogs struct { mu sync.Mutex appErr error entries []operation.OperationEntry } func (s *fakeOperationLogs) Append(_ context.Context, entry operation.OperationEntry) (int64, error) { s.mu.Lock() defer s.mu.Unlock() if s.appErr != nil { return 0, s.appErr } if err := entry.Validate(); err != nil { return 0, err } s.entries = append(s.entries, entry) return int64(len(s.entries)), nil } func (s *fakeOperationLogs) ListByGame(_ context.Context, _ string, _ int) ([]operation.OperationEntry, error) { return nil, errors.New("not used in turngeneration tests") } func (s *fakeOperationLogs) lastEntry() (operation.OperationEntry, bool) { s.mu.Lock() defer s.mu.Unlock() if len(s.entries) == 0 { return operation.OperationEntry{}, false } return s.entries[len(s.entries)-1], true } // --- harness ---------------------------------------------------------- type harness struct { t *testing.T ctrl *gomock.Controller runtimeStore *fakeRuntimeRecords mappings *fakePlayerMappings logs *fakeOperationLogs engine *mocks.MockEngineClient lobbyEvents *mocks.MockLobbyEventsPublisher notifications *mocks.MockNotificationIntentPublisher lobby *mocks.MockLobbyClient telemetry *telemetry.Runtime now time.Time service *turngeneration.Service } const ( testGameID = "game-001" testEngineEndpoint = "http://galaxy-game-game-001:8080" testTurnSchedule = "0 18 * * *" testGameName = "Andromeda Conquest" ) func newHarness(t *testing.T) *harness { t.Helper() ctrl := gomock.NewController(t) telemetryRuntime, err := telemetry.NewWithProviders(nil, nil) require.NoError(t, err) h := &harness{ t: t, ctrl: ctrl, runtimeStore: newFakeRuntimeRecords(), mappings: newFakePlayerMappings(), logs: &fakeOperationLogs{}, engine: mocks.NewMockEngineClient(ctrl), lobbyEvents: mocks.NewMockLobbyEventsPublisher(ctrl), notifications: mocks.NewMockNotificationIntentPublisher(ctrl), lobby: mocks.NewMockLobbyClient(ctrl), telemetry: telemetryRuntime, now: time.Date(2026, time.April, 30, 12, 0, 0, 0, time.UTC), } service, err := turngeneration.NewService(turngeneration.Dependencies{ RuntimeRecords: h.runtimeStore, PlayerMappings: h.mappings, OperationLogs: h.logs, Engine: h.engine, LobbyEvents: h.lobbyEvents, Notifications: h.notifications, Lobby: h.lobby, Scheduler: scheduler.New(), Telemetry: h.telemetry, Clock: func() time.Time { return h.now }, }) require.NoError(t, err) h.service = service return h } func (h *harness) seedRunningRecord(skip bool) { startedAt := h.now.Add(-1 * time.Hour) h.runtimeStore.seed(runtime.RuntimeRecord{ GameID: testGameID, Status: runtime.StatusRunning, EngineEndpoint: testEngineEndpoint, CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3", CurrentEngineVersion: "v1.2.3", TurnSchedule: testTurnSchedule, CurrentTurn: 0, SkipNextTick: skip, EngineHealth: "healthy", CreatedAt: h.now.Add(-2 * time.Hour), UpdatedAt: h.now.Add(-2 * time.Hour), StartedAt: &startedAt, }) h.mappings.seed(testGameID, playermapping.PlayerMapping{ GameID: testGameID, UserID: "user-1", RaceName: "Aelinari", EnginePlayerUUID: "uuid-1", CreatedAt: h.now.Add(-2 * time.Hour), }, playermapping.PlayerMapping{ GameID: testGameID, UserID: "user-2", RaceName: "Drazi", EnginePlayerUUID: "uuid-2", CreatedAt: h.now.Add(-2 * time.Hour), }, ) } func successInput() turngeneration.Input { return turngeneration.Input{ GameID: testGameID, Trigger: turngeneration.TriggerScheduler, OpSource: operation.OpSourceAdminRest, SourceRef: "tick-1", } } func enginePlayers() []ports.PlayerState { return []ports.PlayerState{ {RaceName: "Aelinari", EnginePlayerUUID: "uuid-1", Planets: 3, Population: 100}, {RaceName: "Drazi", EnginePlayerUUID: "uuid-2", Planets: 2, Population: 80}, } } func (h *harness) expectGameSummary() { h.lobby.EXPECT(). GetGameSummary(gomock.Any(), testGameID). Return(ports.GameSummary{GameID: testGameID, GameName: testGameName, Status: "running"}, nil) } // --- tests ------------------------------------------------------------ func TestNewServiceRejectsMissingDeps(t *testing.T) { telemetryRuntime, err := telemetry.NewWithProviders(nil, nil) require.NoError(t, err) cases := []struct { name string mut func(*turngeneration.Dependencies) }{ {"runtime records", func(d *turngeneration.Dependencies) { d.RuntimeRecords = nil }}, {"player mappings", func(d *turngeneration.Dependencies) { d.PlayerMappings = nil }}, {"operation logs", func(d *turngeneration.Dependencies) { d.OperationLogs = nil }}, {"engine", func(d *turngeneration.Dependencies) { d.Engine = nil }}, {"lobby events", func(d *turngeneration.Dependencies) { d.LobbyEvents = nil }}, {"notifications", func(d *turngeneration.Dependencies) { d.Notifications = nil }}, {"lobby", func(d *turngeneration.Dependencies) { d.Lobby = nil }}, {"scheduler", func(d *turngeneration.Dependencies) { d.Scheduler = nil }}, {"telemetry", func(d *turngeneration.Dependencies) { d.Telemetry = nil }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctrl := gomock.NewController(t) deps := turngeneration.Dependencies{ RuntimeRecords: newFakeRuntimeRecords(), PlayerMappings: newFakePlayerMappings(), OperationLogs: &fakeOperationLogs{}, Engine: mocks.NewMockEngineClient(ctrl), LobbyEvents: mocks.NewMockLobbyEventsPublisher(ctrl), Notifications: mocks.NewMockNotificationIntentPublisher(ctrl), Lobby: mocks.NewMockLobbyClient(ctrl), Scheduler: scheduler.New(), Telemetry: telemetryRuntime, } tc.mut(&deps) service, err := turngeneration.NewService(deps) require.Error(t, err) require.Nil(t, service) }) } } func TestHandleRejectsInvalidInput(t *testing.T) { cases := []struct { name string mut func(*turngeneration.Input) }{ {"empty game id", func(i *turngeneration.Input) { i.GameID = "" }}, {"unknown trigger", func(i *turngeneration.Input) { i.Trigger = "exotic" }}, {"unknown op source", func(i *turngeneration.Input) { i.OpSource = "exotic" }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h := newHarness(t) input := successInput() tc.mut(&input) result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, turngeneration.ErrorCodeInvalidRequest, result.ErrorCode) }) } } func TestHandleHappyPathScheduler(t *testing.T) { h := newHarness(t) h.seedRunningRecord(false) h.engine.EXPECT(). Turn(gomock.Any(), testEngineEndpoint). Return(ports.StateResponse{Turn: 1, Players: enginePlayers(), Finished: false}, nil) var snapshot ports.RuntimeSnapshotUpdate h.lobbyEvents.EXPECT(). PublishSnapshotUpdate(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, msg ports.RuntimeSnapshotUpdate) error { snapshot = msg return nil }) h.expectGameSummary() var publishedIntent notificationintent.Intent h.notifications.EXPECT(). Publish(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, intent notificationintent.Intent) error { publishedIntent = intent return nil }) result, err := h.service.Handle(context.Background(), successInput()) require.NoError(t, err) require.True(t, result.IsSuccess(), "outcome %q error_code=%q", result.Outcome, result.ErrorCode) assert.False(t, result.Finished) assert.Equal(t, turngeneration.TriggerScheduler, result.Trigger) assert.Equal(t, runtime.StatusRunning, result.Record.Status) assert.Equal(t, 1, result.Record.CurrentTurn) require.NotNil(t, result.Record.NextGenerationAt) assert.Equal(t, time.Date(2026, time.April, 30, 18, 0, 0, 0, time.UTC), *result.Record.NextGenerationAt) assert.False(t, result.Record.SkipNextTick) updates := h.runtimeStore.statusUpdates() require.Len(t, updates, 2) assert.Equal(t, runtime.StatusRunning, updates[0].ExpectedFrom) assert.Equal(t, runtime.StatusGenerationInProgress, updates[0].To) assert.Equal(t, runtime.StatusGenerationInProgress, updates[1].ExpectedFrom) assert.Equal(t, runtime.StatusRunning, updates[1].To) scheds := h.runtimeStore.scheduling() require.Len(t, scheds, 1) require.NotNil(t, scheds[0].NextGenerationAt) assert.False(t, scheds[0].SkipNextTick) assert.Equal(t, 1, scheds[0].CurrentTurn) assert.Equal(t, runtime.StatusRunning, snapshot.RuntimeStatus) assert.Equal(t, 1, snapshot.CurrentTurn) assert.Equal(t, "healthy", snapshot.EngineHealthSummary) require.Len(t, snapshot.PlayerTurnStats, 2) assert.Equal(t, "user-1", snapshot.PlayerTurnStats[0].UserID) assert.Equal(t, 3, snapshot.PlayerTurnStats[0].Planets) assert.Equal(t, 100, snapshot.PlayerTurnStats[0].Population) assert.Equal(t, "user-2", snapshot.PlayerTurnStats[1].UserID) assert.Equal(t, notificationintent.NotificationTypeGameTurnReady, publishedIntent.NotificationType) assert.Equal(t, []string{"user-1", "user-2"}, publishedIntent.RecipientUserIDs) assert.Equal(t, notificationintent.AudienceKindUser, publishedIntent.AudienceKind) assert.Contains(t, publishedIntent.PayloadJSON, fmt.Sprintf(`"game_name":%q`, testGameName)) assert.Contains(t, publishedIntent.PayloadJSON, `"turn_number":1`) entry, ok := h.logs.lastEntry() require.True(t, ok) assert.Equal(t, operation.OpKindTurnGeneration, entry.OpKind) assert.Equal(t, operation.OutcomeSuccess, entry.Outcome) assert.Equal(t, "tick-1", entry.SourceRef) } func TestHandleConsumesSkipNextTick(t *testing.T) { h := newHarness(t) h.seedRunningRecord(true) h.engine.EXPECT(). Turn(gomock.Any(), testEngineEndpoint). Return(ports.StateResponse{Turn: 5, Players: enginePlayers(), Finished: false}, nil) h.lobbyEvents.EXPECT(). PublishSnapshotUpdate(gomock.Any(), gomock.Any()). Return(nil) h.expectGameSummary() h.notifications.EXPECT(). Publish(gomock.Any(), gomock.Any()). Return(nil) result, err := h.service.Handle(context.Background(), successInput()) require.NoError(t, err) require.True(t, result.IsSuccess(), "outcome %q error_code=%q", result.Outcome, result.ErrorCode) require.NotNil(t, result.Record.NextGenerationAt) expected := time.Date(2026, time.May, 1, 18, 0, 0, 0, time.UTC) assert.Equal(t, expected, *result.Record.NextGenerationAt, "skip advances by one extra cron step") assert.False(t, result.Record.SkipNextTick, "skip flag cleared after consumption") } func TestHandleForceTriggerLabelsTelemetry(t *testing.T) { h := newHarness(t) h.seedRunningRecord(false) h.engine.EXPECT(). Turn(gomock.Any(), testEngineEndpoint). Return(ports.StateResponse{Turn: 1, Players: enginePlayers()}, nil) h.lobbyEvents.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Return(nil) h.expectGameSummary() h.notifications.EXPECT().Publish(gomock.Any(), gomock.Any()).Return(nil) input := successInput() input.Trigger = turngeneration.TriggerForce result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) require.True(t, result.IsSuccess()) assert.Equal(t, turngeneration.TriggerForce, result.Trigger) } func TestHandleFinishedTransitionsAndClearsTick(t *testing.T) { h := newHarness(t) h.seedRunningRecord(false) h.engine.EXPECT(). Turn(gomock.Any(), testEngineEndpoint). Return(ports.StateResponse{Turn: 42, Players: enginePlayers(), Finished: true}, nil) var finishedMsg ports.GameFinished h.lobbyEvents.EXPECT(). PublishGameFinished(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, msg ports.GameFinished) error { finishedMsg = msg return nil }) h.expectGameSummary() var publishedIntent notificationintent.Intent h.notifications.EXPECT(). Publish(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, intent notificationintent.Intent) error { publishedIntent = intent return nil }) result, err := h.service.Handle(context.Background(), successInput()) require.NoError(t, err) require.True(t, result.IsSuccess(), "outcome %q error_code=%q", result.Outcome, result.ErrorCode) assert.True(t, result.Finished) assert.Equal(t, runtime.StatusFinished, result.Record.Status) assert.Nil(t, result.Record.NextGenerationAt) require.NotNil(t, result.Record.FinishedAt) assert.Equal(t, h.now, *result.Record.FinishedAt) assert.Equal(t, runtime.StatusFinished, finishedMsg.RuntimeStatus) assert.Equal(t, 42, finishedMsg.FinalTurnNumber) require.Len(t, finishedMsg.PlayerTurnStats, 2) assert.Equal(t, h.now, finishedMsg.FinishedAt) assert.Equal(t, notificationintent.NotificationTypeGameFinished, publishedIntent.NotificationType) assert.Contains(t, publishedIntent.PayloadJSON, `"final_turn_number":42`) } func TestHandleEngineUnreachable(t *testing.T) { h := newHarness(t) h.seedRunningRecord(false) h.engine.EXPECT(). Turn(gomock.Any(), testEngineEndpoint). Return(ports.StateResponse{}, fmt.Errorf("dial: %w", ports.ErrEngineUnreachable)) var snapshot ports.RuntimeSnapshotUpdate h.lobbyEvents.EXPECT(). PublishSnapshotUpdate(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, msg ports.RuntimeSnapshotUpdate) error { snapshot = msg return nil }) h.expectGameSummary() var publishedIntent notificationintent.Intent h.notifications.EXPECT(). Publish(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, intent notificationintent.Intent) error { publishedIntent = intent return nil }) result, err := h.service.Handle(context.Background(), successInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, turngeneration.ErrorCodeEngineUnreachable, result.ErrorCode) stored, ok := h.runtimeStore.record(testGameID) require.True(t, ok) assert.Equal(t, runtime.StatusGenerationFailed, stored.Status) assert.Equal(t, runtime.StatusGenerationFailed, snapshot.RuntimeStatus) assert.Empty(t, snapshot.PlayerTurnStats) assert.Equal(t, notificationintent.NotificationTypeGameGenerationFailed, publishedIntent.NotificationType) assert.Equal(t, notificationintent.AudienceKindAdminEmail, publishedIntent.AudienceKind) assert.Empty(t, publishedIntent.RecipientUserIDs) } func TestHandleEngineValidationError(t *testing.T) { h := newHarness(t) h.seedRunningRecord(false) h.engine.EXPECT(). Turn(gomock.Any(), testEngineEndpoint). Return(ports.StateResponse{}, fmt.Errorf("400: %w", ports.ErrEngineValidation)) h.lobbyEvents.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Return(nil) h.expectGameSummary() h.notifications.EXPECT().Publish(gomock.Any(), gomock.Any()).Return(nil) result, err := h.service.Handle(context.Background(), successInput()) require.NoError(t, err) assert.Equal(t, turngeneration.ErrorCodeEngineValidationError, result.ErrorCode) stored, ok := h.runtimeStore.record(testGameID) require.True(t, ok) assert.Equal(t, runtime.StatusGenerationFailed, stored.Status) } func TestHandleEngineProtocolViolationOnRosterMismatch(t *testing.T) { h := newHarness(t) h.seedRunningRecord(false) h.engine.EXPECT(). Turn(gomock.Any(), testEngineEndpoint). Return(ports.StateResponse{ Turn: 1, Players: []ports.PlayerState{ {RaceName: "Aelinari", EnginePlayerUUID: "uuid-1", Planets: 1, Population: 10}, {RaceName: "Unknown", EnginePlayerUUID: "uuid-x", Planets: 1, Population: 5}, }, }, nil) h.lobbyEvents.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Return(nil) h.expectGameSummary() h.notifications.EXPECT().Publish(gomock.Any(), gomock.Any()).Return(nil) result, err := h.service.Handle(context.Background(), successInput()) require.NoError(t, err) assert.Equal(t, turngeneration.ErrorCodeEngineProtocolViolation, result.ErrorCode) stored, ok := h.runtimeStore.record(testGameID) require.True(t, ok) assert.Equal(t, runtime.StatusGenerationFailed, stored.Status) } func TestHandleEngineProtocolViolationOnCountMismatch(t *testing.T) { h := newHarness(t) h.seedRunningRecord(false) h.engine.EXPECT(). Turn(gomock.Any(), testEngineEndpoint). Return(ports.StateResponse{ Turn: 1, Players: []ports.PlayerState{ {RaceName: "Aelinari", EnginePlayerUUID: "uuid-1", Planets: 1, Population: 10}, }, }, nil) h.lobbyEvents.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Return(nil) h.expectGameSummary() h.notifications.EXPECT().Publish(gomock.Any(), gomock.Any()).Return(nil) result, err := h.service.Handle(context.Background(), successInput()) require.NoError(t, err) assert.Equal(t, turngeneration.ErrorCodeEngineProtocolViolation, result.ErrorCode) } func TestHandleConflictOnInitialCAS(t *testing.T) { h := newHarness(t) startedAt := h.now.Add(-1 * time.Hour) h.runtimeStore.seed(runtime.RuntimeRecord{ GameID: testGameID, Status: runtime.StatusStopped, EngineEndpoint: testEngineEndpoint, CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3", CurrentEngineVersion: "v1.2.3", TurnSchedule: testTurnSchedule, CreatedAt: h.now.Add(-2 * time.Hour), UpdatedAt: h.now.Add(-1 * time.Hour), StartedAt: &startedAt, StoppedAt: &startedAt, }) result, err := h.service.Handle(context.Background(), successInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, turngeneration.ErrorCodeRuntimeNotRunning, result.ErrorCode) assert.Empty(t, h.runtimeStore.statusUpdates(), "no CAS attempted on non-running record") } func TestHandleConflictOnPostEngineCAS(t *testing.T) { h := newHarness(t) h.seedRunningRecord(false) // Simulate a concurrent admin stop that wins the race during the // engine call by mutating the stored row mid-flight. h.engine.EXPECT(). Turn(gomock.Any(), testEngineEndpoint). DoAndReturn(func(_ context.Context, _ string) (ports.StateResponse, error) { h.runtimeStore.mu.Lock() rec := h.runtimeStore.stored[testGameID] rec.Status = runtime.StatusStopped h.runtimeStore.stored[testGameID] = rec h.runtimeStore.mu.Unlock() return ports.StateResponse{Turn: 1, Players: enginePlayers()}, nil }) result, err := h.service.Handle(context.Background(), successInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, turngeneration.ErrorCodeConflict, result.ErrorCode) } func TestHandleRuntimeNotFound(t *testing.T) { h := newHarness(t) result, err := h.service.Handle(context.Background(), successInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, turngeneration.ErrorCodeRuntimeNotFound, result.ErrorCode) } func TestHandleServiceUnavailableOnGet(t *testing.T) { h := newHarness(t) h.runtimeStore.getErr = errors.New("postgres dial timeout") result, err := h.service.Handle(context.Background(), successInput()) require.NoError(t, err) assert.Equal(t, turngeneration.ErrorCodeServiceUnavailable, result.ErrorCode) } func TestHandleLobbyFallbackToGameID(t *testing.T) { h := newHarness(t) h.seedRunningRecord(false) h.engine.EXPECT(). Turn(gomock.Any(), testEngineEndpoint). Return(ports.StateResponse{Turn: 1, Players: enginePlayers()}, nil) h.lobbyEvents.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Return(nil) h.lobby.EXPECT(). GetGameSummary(gomock.Any(), testGameID). Return(ports.GameSummary{}, fmt.Errorf("dial: %w", ports.ErrLobbyUnavailable)) var publishedIntent notificationintent.Intent h.notifications.EXPECT(). Publish(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, intent notificationintent.Intent) error { publishedIntent = intent return nil }) result, err := h.service.Handle(context.Background(), successInput()) require.NoError(t, err) require.True(t, result.IsSuccess()) assert.Contains(t, publishedIntent.PayloadJSON, fmt.Sprintf(`"game_name":%q`, testGameID)) } func TestHandleLobbyEventPublishFailureDoesNotRollBack(t *testing.T) { h := newHarness(t) h.seedRunningRecord(false) h.engine.EXPECT(). Turn(gomock.Any(), testEngineEndpoint). Return(ports.StateResponse{Turn: 1, Players: enginePlayers()}, nil) h.lobbyEvents.EXPECT(). PublishSnapshotUpdate(gomock.Any(), gomock.Any()). Return(errors.New("redis broken")) h.expectGameSummary() h.notifications.EXPECT().Publish(gomock.Any(), gomock.Any()).Return(nil) result, err := h.service.Handle(context.Background(), successInput()) require.NoError(t, err) require.True(t, result.IsSuccess(), "outcome %q error_code=%q", result.Outcome, result.ErrorCode) assert.Equal(t, runtime.StatusRunning, result.Record.Status) assert.Equal(t, 1, result.Record.CurrentTurn) } func TestHandleNotificationFailureDoesNotRollBack(t *testing.T) { h := newHarness(t) h.seedRunningRecord(false) h.engine.EXPECT(). Turn(gomock.Any(), testEngineEndpoint). Return(ports.StateResponse{Turn: 1, Players: enginePlayers()}, nil) h.lobbyEvents.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Return(nil) h.expectGameSummary() h.notifications.EXPECT(). Publish(gomock.Any(), gomock.Any()). Return(errors.New("notification stream broken")) result, err := h.service.Handle(context.Background(), successInput()) require.NoError(t, err) require.True(t, result.IsSuccess(), "outcome %q error_code=%q", result.Outcome, result.ErrorCode) } func TestHandleNilContext(t *testing.T) { h := newHarness(t) _, err := h.service.Handle(nil, successInput()) //nolint:staticcheck // intentional nil context require.Error(t, err) } func TestHandleNilService(t *testing.T) { var service *turngeneration.Service _, err := service.Handle(context.Background(), successInput()) require.Error(t, err) }