package schedulerticker_test import ( "context" "errors" "sync" "sync/atomic" "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/gamemaster/internal/worker/schedulerticker" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) // fakeRuntimeRecordsBackend is a minimal in-memory implementation of // the RuntimeRecordStore subset the ticker exercises plus the // turn-generation orchestrator hooks. The fake mirrors the runtime CAS // semantics so the in-flight set test can run a full // running→generation_in_progress→running cycle. type fakeRuntimeRecordsBackend struct { mu sync.Mutex stored map[string]runtime.RuntimeRecord listErr error listCalls atomic.Int32 listCustom func(ctx context.Context, now time.Time) ([]runtime.RuntimeRecord, error) } func newFakeRuntimeRecordsBackend() *fakeRuntimeRecordsBackend { return &fakeRuntimeRecordsBackend{stored: map[string]runtime.RuntimeRecord{}} } func (s *fakeRuntimeRecordsBackend) seed(record runtime.RuntimeRecord) { s.mu.Lock() defer s.mu.Unlock() s.stored[record.GameID] = record } func (s *fakeRuntimeRecordsBackend) Get(_ context.Context, gameID string) (runtime.RuntimeRecord, error) { s.mu.Lock() defer s.mu.Unlock() record, ok := s.stored[gameID] if !ok { return runtime.RuntimeRecord{}, runtime.ErrNotFound } return record, nil } func (s *fakeRuntimeRecordsBackend) Insert(_ context.Context, record runtime.RuntimeRecord) error { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.stored[record.GameID]; ok { return runtime.ErrConflict } s.stored[record.GameID] = record return nil } func (s *fakeRuntimeRecordsBackend) UpdateStatus(_ context.Context, input ports.UpdateStatusInput) error { s.mu.Lock() defer s.mu.Unlock() 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.StatusRunning && record.StartedAt == nil { startedAt := input.Now record.StartedAt = &startedAt } if input.To == runtime.StatusFinished { finishedAt := input.Now record.FinishedAt = &finishedAt } s.stored[input.GameID] = record return nil } func (s *fakeRuntimeRecordsBackend) UpdateScheduling(_ context.Context, input ports.UpdateSchedulingInput) error { s.mu.Lock() defer s.mu.Unlock() 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 *fakeRuntimeRecordsBackend) UpdateImage(_ context.Context, _ ports.UpdateImageInput) error { return errors.New("not used in schedulerticker tests") } func (s *fakeRuntimeRecordsBackend) UpdateEngineHealth(_ context.Context, _ ports.UpdateEngineHealthInput) error { return errors.New("not used in schedulerticker tests") } func (s *fakeRuntimeRecordsBackend) Delete(_ context.Context, gameID string) error { s.mu.Lock() defer s.mu.Unlock() delete(s.stored, gameID) return nil } func (s *fakeRuntimeRecordsBackend) ListDueRunning(ctx context.Context, now time.Time) ([]runtime.RuntimeRecord, error) { s.listCalls.Add(1) if s.listCustom != nil { return s.listCustom(ctx, now) } if s.listErr != nil { return nil, s.listErr } s.mu.Lock() defer s.mu.Unlock() var due []runtime.RuntimeRecord for _, record := range s.stored { if record.Status != runtime.StatusRunning { continue } if record.NextGenerationAt == nil || record.NextGenerationAt.After(now) { continue } due = append(due, record) } return due, nil } func (s *fakeRuntimeRecordsBackend) ListByStatus(_ context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error) { s.mu.Lock() defer s.mu.Unlock() var matching []runtime.RuntimeRecord for _, record := range s.stored { if record.Status == status { matching = append(matching, record) } } return matching, nil } func (s *fakeRuntimeRecordsBackend) List(_ context.Context) ([]runtime.RuntimeRecord, error) { s.mu.Lock() defer s.mu.Unlock() all := make([]runtime.RuntimeRecord, 0, len(s.stored)) for _, record := range s.stored { all = append(all, record) } return all, nil } type stubMappings struct { rows map[string][]playermapping.PlayerMapping } func (s *stubMappings) BulkInsert(_ context.Context, _ []playermapping.PlayerMapping) error { return errors.New("not used") } func (s *stubMappings) Get(_ context.Context, _, _ string) (playermapping.PlayerMapping, error) { return playermapping.PlayerMapping{}, errors.New("not used") } func (s *stubMappings) GetByRace(_ context.Context, _, _ string) (playermapping.PlayerMapping, error) { return playermapping.PlayerMapping{}, errors.New("not used") } func (s *stubMappings) ListByGame(_ context.Context, gameID string) ([]playermapping.PlayerMapping, error) { return append([]playermapping.PlayerMapping(nil), s.rows[gameID]...), nil } func (s *stubMappings) DeleteByGame(_ context.Context, _ string) error { return errors.New("not used") } type stubLogs struct{} func (stubLogs) Append(_ context.Context, _ operation.OperationEntry) (int64, error) { return 1, nil } func (stubLogs) ListByGame(_ context.Context, _ string, _ int) ([]operation.OperationEntry, error) { return nil, errors.New("not used") } // --- helpers ---------------------------------------------------------- func newTelemetry(t *testing.T) *telemetry.Runtime { t.Helper() tm, err := telemetry.NewWithProviders(nil, nil) require.NoError(t, err) return tm } func seedRunningRecord(t *testing.T, store *fakeRuntimeRecordsBackend, mappings *stubMappings, gameID string, due time.Time) { t.Helper() startedAt := due.Add(-1 * time.Hour) store.seed(runtime.RuntimeRecord{ GameID: gameID, Status: runtime.StatusRunning, EngineEndpoint: "http://galaxy-game-" + gameID + ":8080", CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3", CurrentEngineVersion: "v1.2.3", TurnSchedule: "0 18 * * *", CurrentTurn: 0, NextGenerationAt: &due, EngineHealth: "healthy", CreatedAt: due.Add(-2 * time.Hour), UpdatedAt: due.Add(-2 * time.Hour), StartedAt: &startedAt, }) if mappings.rows == nil { mappings.rows = map[string][]playermapping.PlayerMapping{} } mappings.rows[gameID] = []playermapping.PlayerMapping{ {GameID: gameID, UserID: "user-1", RaceName: "Aelinari", EnginePlayerUUID: "uuid-1", CreatedAt: startedAt}, {GameID: gameID, UserID: "user-2", RaceName: "Drazi", EnginePlayerUUID: "uuid-2", CreatedAt: startedAt}, } } // --- tests ------------------------------------------------------------ func TestNewWorkerRejectsMissingDeps(t *testing.T) { telem := newTelemetry(t) cases := []struct { name string mut func(*schedulerticker.Dependencies) }{ {"runtime records", func(d *schedulerticker.Dependencies) { d.RuntimeRecords = nil }}, {"turn generation", func(d *schedulerticker.Dependencies) { d.TurnGeneration = nil }}, {"telemetry", func(d *schedulerticker.Dependencies) { d.Telemetry = nil }}, {"non-positive interval", func(d *schedulerticker.Dependencies) { d.Interval = 0 }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctrl := gomock.NewController(t) turn := buildTurnService(t, ctrl, newFakeRuntimeRecordsBackend(), &stubMappings{}, telem) deps := schedulerticker.Dependencies{ RuntimeRecords: newFakeRuntimeRecordsBackend(), TurnGeneration: turn, Telemetry: telem, Interval: time.Second, } tc.mut(&deps) worker, err := schedulerticker.NewWorker(deps) require.Error(t, err) require.Nil(t, worker) }) } } func TestTickDispatchesDueGames(t *testing.T) { ctrl := gomock.NewController(t) telem := newTelemetry(t) store := newFakeRuntimeRecordsBackend() mappings := &stubMappings{} now := time.Date(2026, time.April, 30, 12, 0, 0, 0, time.UTC) due := now.Add(-5 * time.Minute) seedRunningRecord(t, store, mappings, "game-a", due) seedRunningRecord(t, store, mappings, "game-b", due) engine := mocks.NewMockEngineClient(ctrl) lobbyEvents := mocks.NewMockLobbyEventsPublisher(ctrl) notifications := mocks.NewMockNotificationIntentPublisher(ctrl) lobby := mocks.NewMockLobbyClient(ctrl) engine.EXPECT(). Turn(gomock.Any(), gomock.Any()). Times(2). Return(ports.StateResponse{Turn: 1, Players: []ports.PlayerState{ {RaceName: "Aelinari", EnginePlayerUUID: "uuid-1", Planets: 1, Population: 10}, {RaceName: "Drazi", EnginePlayerUUID: "uuid-2", Planets: 1, Population: 10}, }}, nil) lobbyEvents.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Times(2).Return(nil) lobby.EXPECT().GetGameSummary(gomock.Any(), gomock.Any()).Times(2). Return(ports.GameSummary{GameID: "g", GameName: "Game", Status: "running"}, nil) notifications.EXPECT().Publish(gomock.Any(), gomock.Any()).Times(2).Return(nil) turn, err := turngeneration.NewService(turngeneration.Dependencies{ RuntimeRecords: store, PlayerMappings: mappings, OperationLogs: stubLogs{}, Engine: engine, LobbyEvents: lobbyEvents, Notifications: notifications, Lobby: lobby, Scheduler: scheduler.New(), Telemetry: telem, Clock: func() time.Time { return now }, }) require.NoError(t, err) worker, err := schedulerticker.NewWorker(schedulerticker.Dependencies{ RuntimeRecords: store, TurnGeneration: turn, Telemetry: telem, Interval: time.Second, Clock: func() time.Time { return now }, }) require.NoError(t, err) worker.Tick(context.Background()) worker.Wait() // Both games should have advanced from running → running with // current_turn=1. for _, gameID := range []string{"game-a", "game-b"} { record, err := store.Get(context.Background(), gameID) require.NoError(t, err) assert.Equal(t, runtime.StatusRunning, record.Status, "game %s", gameID) assert.Equal(t, 1, record.CurrentTurn, "game %s", gameID) } } func TestTickDeduplicatesInflightGame(t *testing.T) { ctrl := gomock.NewController(t) telem := newTelemetry(t) store := newFakeRuntimeRecordsBackend() mappings := &stubMappings{} now := time.Date(2026, time.April, 30, 12, 0, 0, 0, time.UTC) due := now.Add(-5 * time.Minute) seedRunningRecord(t, store, mappings, "game-a", due) engine := mocks.NewMockEngineClient(ctrl) lobbyEvents := mocks.NewMockLobbyEventsPublisher(ctrl) notifications := mocks.NewMockNotificationIntentPublisher(ctrl) lobby := mocks.NewMockLobbyClient(ctrl) releaseEngine := make(chan struct{}) engine.EXPECT(). Turn(gomock.Any(), gomock.Any()). Times(1). DoAndReturn(func(ctx context.Context, _ string) (ports.StateResponse, error) { select { case <-releaseEngine: case <-ctx.Done(): } return ports.StateResponse{Turn: 1, Players: []ports.PlayerState{ {RaceName: "Aelinari", EnginePlayerUUID: "uuid-1", Planets: 1, Population: 10}, {RaceName: "Drazi", EnginePlayerUUID: "uuid-2", Planets: 1, Population: 10}, }}, nil }) lobbyEvents.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Times(1).Return(nil) lobby.EXPECT().GetGameSummary(gomock.Any(), gomock.Any()).Times(1). Return(ports.GameSummary{GameID: "game-a", GameName: "Game A", Status: "running"}, nil) notifications.EXPECT().Publish(gomock.Any(), gomock.Any()).Times(1).Return(nil) turn, err := turngeneration.NewService(turngeneration.Dependencies{ RuntimeRecords: store, PlayerMappings: mappings, OperationLogs: stubLogs{}, Engine: engine, LobbyEvents: lobbyEvents, Notifications: notifications, Lobby: lobby, Scheduler: scheduler.New(), Telemetry: telem, Clock: func() time.Time { return now }, }) require.NoError(t, err) worker, err := schedulerticker.NewWorker(schedulerticker.Dependencies{ RuntimeRecords: store, TurnGeneration: turn, Telemetry: telem, Interval: time.Second, Clock: func() time.Time { return now }, }) require.NoError(t, err) worker.Tick(context.Background()) // Reset the runtime row to running so the second Tick would normally // re-dispatch; the in-flight set must still skip it. store.mu.Lock() rec := store.stored["game-a"] rec.Status = runtime.StatusRunning rec.NextGenerationAt = &due store.stored["game-a"] = rec store.mu.Unlock() worker.Tick(context.Background()) close(releaseEngine) worker.Wait() // Only one engine call must have happened despite two ticks. assert.GreaterOrEqual(t, store.listCalls.Load(), int32(2), "ListDueRunning observed both ticks") } func TestTickAbsorbsListError(t *testing.T) { ctrl := gomock.NewController(t) telem := newTelemetry(t) store := newFakeRuntimeRecordsBackend() store.listErr = errors.New("postgres timeout") engine := mocks.NewMockEngineClient(ctrl) lobbyEvents := mocks.NewMockLobbyEventsPublisher(ctrl) notifications := mocks.NewMockNotificationIntentPublisher(ctrl) lobby := mocks.NewMockLobbyClient(ctrl) turn, err := turngeneration.NewService(turngeneration.Dependencies{ RuntimeRecords: store, PlayerMappings: &stubMappings{}, OperationLogs: stubLogs{}, Engine: engine, LobbyEvents: lobbyEvents, Notifications: notifications, Lobby: lobby, Scheduler: scheduler.New(), Telemetry: telem, }) require.NoError(t, err) worker, err := schedulerticker.NewWorker(schedulerticker.Dependencies{ RuntimeRecords: store, TurnGeneration: turn, Telemetry: telem, Interval: time.Second, }) require.NoError(t, err) worker.Tick(context.Background()) worker.Wait() } func TestTickEmptyDueListIsNoOp(t *testing.T) { ctrl := gomock.NewController(t) telem := newTelemetry(t) store := newFakeRuntimeRecordsBackend() engine := mocks.NewMockEngineClient(ctrl) lobbyEvents := mocks.NewMockLobbyEventsPublisher(ctrl) notifications := mocks.NewMockNotificationIntentPublisher(ctrl) lobby := mocks.NewMockLobbyClient(ctrl) turn, err := turngeneration.NewService(turngeneration.Dependencies{ RuntimeRecords: store, PlayerMappings: &stubMappings{}, OperationLogs: stubLogs{}, Engine: engine, LobbyEvents: lobbyEvents, Notifications: notifications, Lobby: lobby, Scheduler: scheduler.New(), Telemetry: telem, }) require.NoError(t, err) worker, err := schedulerticker.NewWorker(schedulerticker.Dependencies{ RuntimeRecords: store, TurnGeneration: turn, Telemetry: telem, Interval: time.Second, }) require.NoError(t, err) worker.Tick(context.Background()) worker.Wait() } func TestRunStopsOnContextCancellation(t *testing.T) { ctrl := gomock.NewController(t) telem := newTelemetry(t) store := newFakeRuntimeRecordsBackend() engine := mocks.NewMockEngineClient(ctrl) lobbyEvents := mocks.NewMockLobbyEventsPublisher(ctrl) notifications := mocks.NewMockNotificationIntentPublisher(ctrl) lobby := mocks.NewMockLobbyClient(ctrl) turn, err := turngeneration.NewService(turngeneration.Dependencies{ RuntimeRecords: store, PlayerMappings: &stubMappings{}, OperationLogs: stubLogs{}, Engine: engine, LobbyEvents: lobbyEvents, Notifications: notifications, Lobby: lobby, Scheduler: scheduler.New(), Telemetry: telem, }) require.NoError(t, err) worker, err := schedulerticker.NewWorker(schedulerticker.Dependencies{ RuntimeRecords: store, TurnGeneration: turn, Telemetry: telem, Interval: 10 * time.Millisecond, }) require.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- worker.Run(ctx) }() cancel() select { case err := <-done: assert.ErrorIs(t, err, context.Canceled) case <-time.After(2 * time.Second): t.Fatal("worker did not exit on context cancellation") } } // buildTurnService is a thin helper for the missing-deps test cases — // it does not exercise the engine because the deps test never reaches // the work path. func buildTurnService(t *testing.T, ctrl *gomock.Controller, store *fakeRuntimeRecordsBackend, mappings *stubMappings, telem *telemetry.Runtime) *turngeneration.Service { t.Helper() turn, err := turngeneration.NewService(turngeneration.Dependencies{ RuntimeRecords: store, PlayerMappings: mappings, OperationLogs: stubLogs{}, Engine: mocks.NewMockEngineClient(ctrl), LobbyEvents: mocks.NewMockLobbyEventsPublisher(ctrl), Notifications: mocks.NewMockNotificationIntentPublisher(ctrl), Lobby: mocks.NewMockLobbyClient(ctrl), Scheduler: scheduler.New(), Telemetry: telem, }) require.NoError(t, err) return turn }