package adminstop_test import ( "context" "errors" "sync" "testing" "time" "galaxy/gamemaster/internal/adapters/mocks" "galaxy/gamemaster/internal/domain/operation" "galaxy/gamemaster/internal/domain/runtime" "galaxy/gamemaster/internal/ports" "galaxy/gamemaster/internal/service/adminstop" "galaxy/gamemaster/internal/telemetry" "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 updates []ports.UpdateStatusInput } 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() 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, runtime.RuntimeRecord) error { return errors.New("not used") } func (s *fakeRuntimeRecords) UpdateStatus(_ context.Context, input ports.UpdateStatusInput) error { s.mu.Lock() defer s.mu.Unlock() 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.StatusStopped { stopped := input.Now record.StoppedAt = &stopped } s.stored[input.GameID] = record s.updates = append(s.updates, input) return nil } func (s *fakeRuntimeRecords) UpdateScheduling(context.Context, ports.UpdateSchedulingInput) error { return errors.New("not used") } func (s *fakeRuntimeRecords) UpdateImage(context.Context, ports.UpdateImageInput) error { return errors.New("not used") } func (s *fakeRuntimeRecords) UpdateEngineHealth(context.Context, ports.UpdateEngineHealthInput) error { return errors.New("not used") } func (s *fakeRuntimeRecords) Delete(context.Context, string) error { return errors.New("not used") } func (s *fakeRuntimeRecords) ListDueRunning(context.Context, time.Time) ([]runtime.RuntimeRecord, error) { return nil, errors.New("not used") } func (s *fakeRuntimeRecords) ListByStatus(context.Context, runtime.Status) ([]runtime.RuntimeRecord, error) { return nil, errors.New("not used") } func (s *fakeRuntimeRecords) List(context.Context) ([]runtime.RuntimeRecord, error) { return nil, errors.New("not used") } func (s *fakeRuntimeRecords) updateCount() int { s.mu.Lock() defer s.mu.Unlock() return len(s.updates) } type fakeOperationLogs struct { mu sync.Mutex entries []operation.OperationEntry appErr error } 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") } 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 } func (s *fakeOperationLogs) snapshot() []operation.OperationEntry { s.mu.Lock() defer s.mu.Unlock() out := make([]operation.OperationEntry, len(s.entries)) copy(out, s.entries) return out } // --- harness ---------------------------------------------------------- type harness struct { t *testing.T ctrl *gomock.Controller runtime *fakeRuntimeRecords logs *fakeOperationLogs rtm *mocks.MockRTMClient lobby *mocks.MockLobbyEventsPublisher telemetry *telemetry.Runtime now time.Time service *adminstop.Service } 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, runtime: newFakeRuntimeRecords(), logs: &fakeOperationLogs{}, rtm: mocks.NewMockRTMClient(ctrl), lobby: mocks.NewMockLobbyEventsPublisher(ctrl), telemetry: telemetryRuntime, now: time.Date(2026, time.May, 1, 12, 0, 0, 0, time.UTC), } service, err := adminstop.NewService(adminstop.Dependencies{ RuntimeRecords: h.runtime, OperationLogs: h.logs, RTM: h.rtm, LobbyEvents: h.lobby, Telemetry: h.telemetry, Clock: func() time.Time { return h.now }, }) require.NoError(t, err) h.service = service return h } func (h *harness) seedRecord(status runtime.Status) runtime.RuntimeRecord { created := h.now.Add(-time.Hour) started := h.now.Add(-30 * time.Minute) next := h.now.Add(30 * time.Minute) record := runtime.RuntimeRecord{ GameID: "game-001", Status: status, EngineEndpoint: "http://galaxy-game-game-001:8080", CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3", CurrentEngineVersion: "v1.2.3", TurnSchedule: "0 18 * * *", CurrentTurn: 7, NextGenerationAt: &next, EngineHealth: "healthy", CreatedAt: created, UpdatedAt: started, StartedAt: &started, } h.runtime.seed(record) return record } func baseInput() adminstop.Input { return adminstop.Input{ GameID: "game-001", Reason: adminstop.ReasonAdminRequest, OpSource: operation.OpSourceAdminRest, SourceRef: "req-stop-001", } } // --- tests ------------------------------------------------------------ func TestNewServiceRejectsMissingDeps(t *testing.T) { telemetryRuntime, err := telemetry.NewWithProviders(nil, nil) require.NoError(t, err) cases := []struct { name string mut func(*adminstop.Dependencies) }{ {"runtime records", func(d *adminstop.Dependencies) { d.RuntimeRecords = nil }}, {"operation logs", func(d *adminstop.Dependencies) { d.OperationLogs = nil }}, {"rtm", func(d *adminstop.Dependencies) { d.RTM = nil }}, {"lobby events", func(d *adminstop.Dependencies) { d.LobbyEvents = nil }}, {"telemetry", func(d *adminstop.Dependencies) { d.Telemetry = nil }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctrl := gomock.NewController(t) deps := adminstop.Dependencies{ RuntimeRecords: newFakeRuntimeRecords(), OperationLogs: &fakeOperationLogs{}, RTM: mocks.NewMockRTMClient(ctrl), LobbyEvents: mocks.NewMockLobbyEventsPublisher(ctrl), Telemetry: telemetryRuntime, } tc.mut(&deps) service, err := adminstop.NewService(deps) require.Error(t, err) require.Nil(t, service) }) } } func TestHandleHappyPath(t *testing.T) { h := newHarness(t) original := h.seedRecord(runtime.StatusRunning) h.rtm.EXPECT().Stop(gomock.Any(), "game-001", adminstop.ReasonAdminRequest).Return(nil) h.lobby.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.AssignableToTypeOf(ports.RuntimeSnapshotUpdate{})). DoAndReturn(func(_ context.Context, msg ports.RuntimeSnapshotUpdate) error { assert.Equal(t, "game-001", msg.GameID) assert.Equal(t, runtime.StatusStopped, msg.RuntimeStatus) assert.Equal(t, original.CurrentTurn, msg.CurrentTurn) assert.Equal(t, original.EngineHealth, msg.EngineHealthSummary) assert.Empty(t, msg.PlayerTurnStats) assert.True(t, msg.OccurredAt.Equal(h.now)) return nil }) result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) require.True(t, result.IsSuccess(), "want success, got %+v", result) assert.Equal(t, runtime.StatusStopped, result.Record.Status) assert.Equal(t, 1, h.runtime.updateCount(), "exactly one CAS call expected") entry, ok := h.logs.lastEntry() require.True(t, ok, "operation log entry must be appended") assert.Equal(t, operation.OpKindStop, entry.OpKind) assert.Equal(t, operation.OpSourceAdminRest, entry.OpSource) assert.Equal(t, operation.OutcomeSuccess, entry.Outcome) assert.Empty(t, entry.ErrorCode) } func TestHandleHappyPathFromGenerationFailed(t *testing.T) { h := newHarness(t) h.seedRecord(runtime.StatusGenerationFailed) h.rtm.EXPECT().Stop(gomock.Any(), "game-001", adminstop.ReasonAdminRequest).Return(nil) h.lobby.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Return(nil) result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) require.True(t, result.IsSuccess()) assert.Equal(t, runtime.StatusStopped, result.Record.Status) require.Len(t, h.runtime.updates, 1) assert.Equal(t, runtime.StatusGenerationFailed, h.runtime.updates[0].ExpectedFrom) } func TestHandleEmptyReasonDefaultsToAdminRequest(t *testing.T) { h := newHarness(t) h.seedRecord(runtime.StatusRunning) h.rtm.EXPECT().Stop(gomock.Any(), "game-001", adminstop.ReasonAdminRequest).Return(nil) h.lobby.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()).Return(nil) input := baseInput() input.Reason = "" result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) require.True(t, result.IsSuccess()) } func TestHandleIdempotentOnAlreadyStopped(t *testing.T) { h := newHarness(t) original := h.seedRecord(runtime.StatusStopped) // No RTM call, no snapshot publication expected. result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) require.True(t, result.IsSuccess()) assert.Equal(t, runtime.StatusStopped, result.Record.Status) assert.Equal(t, original.UpdatedAt, result.Record.UpdatedAt, "no mutation expected") assert.Zero(t, h.runtime.updateCount(), "no CAS expected on idempotent path") entry, ok := h.logs.lastEntry() require.True(t, ok) assert.Equal(t, operation.OutcomeSuccess, entry.Outcome) } func TestHandleIdempotentOnFinished(t *testing.T) { h := newHarness(t) h.seedRecord(runtime.StatusFinished) result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) require.True(t, result.IsSuccess()) assert.Equal(t, runtime.StatusFinished, result.Record.Status) } func TestHandleConflictOnStarting(t *testing.T) { h := newHarness(t) h.seedRecord(runtime.StatusStarting) result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, adminstop.ErrorCodeConflict, result.ErrorCode) assert.Zero(t, h.runtime.updateCount()) entry, ok := h.logs.lastEntry() require.True(t, ok) assert.Equal(t, operation.OutcomeFailure, entry.Outcome) assert.Equal(t, adminstop.ErrorCodeConflict, entry.ErrorCode) } func TestHandleRuntimeNotFound(t *testing.T) { h := newHarness(t) result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, adminstop.ErrorCodeRuntimeNotFound, result.ErrorCode) } func TestHandleRTMUnavailable(t *testing.T) { h := newHarness(t) h.seedRecord(runtime.StatusRunning) h.rtm.EXPECT().Stop(gomock.Any(), "game-001", adminstop.ReasonAdminRequest). Return(ports.ErrRTMUnavailable) result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, adminstop.ErrorCodeServiceUnavailable, result.ErrorCode) assert.Zero(t, h.runtime.updateCount(), "CAS must not run after RTM failure") } func TestHandleCASLostRace(t *testing.T) { h := newHarness(t) h.seedRecord(runtime.StatusRunning) // RTM stop succeeds, but a concurrent mutation flipped the row out // of `running` before our CAS lands. h.rtm.EXPECT().Stop(gomock.Any(), "game-001", adminstop.ReasonAdminRequest).Return(nil) h.runtime.updErr = runtime.ErrConflict result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, adminstop.ErrorCodeConflict, result.ErrorCode) } func TestHandleStoreReadFailure(t *testing.T) { h := newHarness(t) h.runtime.getErr = errors.New("connection refused") result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, adminstop.ErrorCodeServiceUnavailable, result.ErrorCode) } func TestHandleInvalidRequest(t *testing.T) { cases := []struct { name string mut func(*adminstop.Input) }{ {"empty game id", func(in *adminstop.Input) { in.GameID = "" }}, {"unknown reason", func(in *adminstop.Input) { in.Reason = "panic" }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h := newHarness(t) input := baseInput() 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, adminstop.ErrorCodeInvalidRequest, result.ErrorCode) // Audit log uses the validated game id; for the empty-id // case it would fail entry validation, so we only assert // when game id is present. if input.GameID != "" { _, ok := h.logs.lastEntry() assert.True(t, ok) } }) } } func TestHandleNilContextReturnsError(t *testing.T) { h := newHarness(t) _, err := h.service.Handle(nil, baseInput()) //nolint:staticcheck // intentional nil for guard test require.Error(t, err) } func TestHandleSnapshotPublishFailureSurfacesSuccess(t *testing.T) { h := newHarness(t) h.seedRecord(runtime.StatusRunning) h.rtm.EXPECT().Stop(gomock.Any(), "game-001", adminstop.ReasonAdminRequest).Return(nil) h.lobby.EXPECT().PublishSnapshotUpdate(gomock.Any(), gomock.Any()). Return(errors.New("redis down")) result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) require.True(t, result.IsSuccess(), "snapshot publication is best-effort") assert.Equal(t, runtime.StatusStopped, result.Record.Status) }