package adminbanish_test import ( "context" "errors" "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/adminbanish" "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 } 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, ports.UpdateStatusInput) error { return errors.New("not used") } 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") } type fakePlayerMappings struct { mu sync.Mutex races map[string]map[string]playermapping.PlayerMapping getErr error } func newFakePlayerMappings() *fakePlayerMappings { return &fakePlayerMappings{races: map[string]map[string]playermapping.PlayerMapping{}} } func (s *fakePlayerMappings) seedRace(gameID, raceName, userID, uuid string) { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.races[gameID]; !ok { s.races[gameID] = map[string]playermapping.PlayerMapping{} } s.races[gameID][raceName] = playermapping.PlayerMapping{ GameID: gameID, UserID: userID, RaceName: raceName, EnginePlayerUUID: uuid, CreatedAt: time.Now(), } } func (s *fakePlayerMappings) BulkInsert(context.Context, []playermapping.PlayerMapping) error { return errors.New("not used") } func (s *fakePlayerMappings) Get(context.Context, string, string) (playermapping.PlayerMapping, error) { return playermapping.PlayerMapping{}, errors.New("not used") } func (s *fakePlayerMappings) GetByRace(_ context.Context, gameID, raceName string) (playermapping.PlayerMapping, error) { s.mu.Lock() defer s.mu.Unlock() if s.getErr != nil { return playermapping.PlayerMapping{}, s.getErr } gameRaces, ok := s.races[gameID] if !ok { return playermapping.PlayerMapping{}, playermapping.ErrNotFound } rec, ok := gameRaces[raceName] if !ok { return playermapping.PlayerMapping{}, playermapping.ErrNotFound } return rec, nil } func (s *fakePlayerMappings) ListByGame(context.Context, string) ([]playermapping.PlayerMapping, error) { return nil, errors.New("not used") } func (s *fakePlayerMappings) DeleteByGame(context.Context, string) error { return errors.New("not used") } type fakeOperationLogs struct { mu sync.Mutex entries []operation.OperationEntry } func (s *fakeOperationLogs) Append(_ context.Context, entry operation.OperationEntry) (int64, error) { s.mu.Lock() defer s.mu.Unlock() 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 } // --- harness ---------------------------------------------------------- type harness struct { t *testing.T ctrl *gomock.Controller runtime *fakeRuntimeRecords mappings *fakePlayerMappings logs *fakeOperationLogs engine *mocks.MockEngineClient telemetry *telemetry.Runtime now time.Time service *adminbanish.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(), mappings: newFakePlayerMappings(), logs: &fakeOperationLogs{}, engine: mocks.NewMockEngineClient(ctrl), telemetry: telemetryRuntime, now: time.Date(2026, time.May, 1, 12, 0, 0, 0, time.UTC), } service, err := adminbanish.NewService(adminbanish.Dependencies{ RuntimeRecords: h.runtime, PlayerMappings: h.mappings, OperationLogs: h.logs, Engine: h.engine, Telemetry: h.telemetry, Clock: func() time.Time { return h.now }, }) require.NoError(t, err) h.service = service return h } const ( testGameID = "game-001" testRaceName = "Aelinari" testEndpoint = "http://galaxy-game-game-001:8080" ) func (h *harness) seedRuntime(status runtime.Status) { created := h.now.Add(-time.Hour) started := h.now.Add(-30 * time.Minute) record := runtime.RuntimeRecord{ GameID: testGameID, Status: status, EngineEndpoint: testEndpoint, CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3", CurrentEngineVersion: "v1.2.3", TurnSchedule: "0 18 * * *", CurrentTurn: 7, CreatedAt: created, UpdatedAt: started, StartedAt: &started, } h.runtime.seed(record) } func baseInput() adminbanish.Input { return adminbanish.Input{ GameID: testGameID, RaceName: testRaceName, OpSource: operation.OpSourceLobbyInternal, SourceRef: "req-banish-001", } } // --- tests ------------------------------------------------------------ func TestNewServiceRejectsMissingDeps(t *testing.T) { telemetryRuntime, err := telemetry.NewWithProviders(nil, nil) require.NoError(t, err) cases := []struct { name string mut func(*adminbanish.Dependencies) }{ {"runtime records", func(d *adminbanish.Dependencies) { d.RuntimeRecords = nil }}, {"player mappings", func(d *adminbanish.Dependencies) { d.PlayerMappings = nil }}, {"operation logs", func(d *adminbanish.Dependencies) { d.OperationLogs = nil }}, {"engine", func(d *adminbanish.Dependencies) { d.Engine = nil }}, {"telemetry", func(d *adminbanish.Dependencies) { d.Telemetry = nil }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctrl := gomock.NewController(t) deps := adminbanish.Dependencies{ RuntimeRecords: newFakeRuntimeRecords(), PlayerMappings: newFakePlayerMappings(), OperationLogs: &fakeOperationLogs{}, Engine: mocks.NewMockEngineClient(ctrl), Telemetry: telemetryRuntime, } tc.mut(&deps) service, err := adminbanish.NewService(deps) require.Error(t, err) require.Nil(t, service) }) } } func TestHandleHappyPath(t *testing.T) { h := newHarness(t) h.seedRuntime(runtime.StatusRunning) h.mappings.seedRace(testGameID, testRaceName, "user-1", "uuid-1") h.engine.EXPECT().BanishRace(gomock.Any(), testEndpoint, testRaceName).Return(nil) result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) require.True(t, result.IsSuccess(), "want success, got %+v", result) entry, ok := h.logs.lastEntry() require.True(t, ok) assert.Equal(t, operation.OpKindBanish, entry.OpKind) assert.Equal(t, operation.OpSourceLobbyInternal, entry.OpSource) assert.Equal(t, operation.OutcomeSuccess, entry.Outcome) } func TestHandleHappyPathOnStoppedRuntime(t *testing.T) { // README §Banish does not check status; the engine call may fail // later with engine_unreachable, but the service runs the call. h := newHarness(t) h.seedRuntime(runtime.StatusStopped) h.mappings.seedRace(testGameID, testRaceName, "user-1", "uuid-1") h.engine.EXPECT().BanishRace(gomock.Any(), testEndpoint, testRaceName).Return(nil) result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) require.True(t, result.IsSuccess()) } func TestHandleRuntimeNotFound(t *testing.T) { h := newHarness(t) h.mappings.seedRace(testGameID, testRaceName, "user-1", "uuid-1") result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) assert.Equal(t, adminbanish.ErrorCodeRuntimeNotFound, result.ErrorCode) } func TestHandleForbiddenWhenRaceMissing(t *testing.T) { h := newHarness(t) h.seedRuntime(runtime.StatusRunning) result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) assert.Equal(t, adminbanish.ErrorCodeForbidden, result.ErrorCode) entry, ok := h.logs.lastEntry() require.True(t, ok) assert.Equal(t, operation.OutcomeFailure, entry.Outcome) assert.Equal(t, adminbanish.ErrorCodeForbidden, entry.ErrorCode) } func TestHandleEngineUnreachable(t *testing.T) { h := newHarness(t) h.seedRuntime(runtime.StatusRunning) h.mappings.seedRace(testGameID, testRaceName, "user-1", "uuid-1") h.engine.EXPECT().BanishRace(gomock.Any(), testEndpoint, testRaceName). Return(ports.ErrEngineUnreachable) result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) assert.Equal(t, adminbanish.ErrorCodeEngineUnreachable, result.ErrorCode) } func TestHandleEngineValidation(t *testing.T) { h := newHarness(t) h.seedRuntime(runtime.StatusRunning) h.mappings.seedRace(testGameID, testRaceName, "user-1", "uuid-1") h.engine.EXPECT().BanishRace(gomock.Any(), testEndpoint, testRaceName). Return(ports.ErrEngineValidation) result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) assert.Equal(t, adminbanish.ErrorCodeEngineValidationError, result.ErrorCode) } func TestHandleEngineProtocolViolation(t *testing.T) { h := newHarness(t) h.seedRuntime(runtime.StatusRunning) h.mappings.seedRace(testGameID, testRaceName, "user-1", "uuid-1") h.engine.EXPECT().BanishRace(gomock.Any(), testEndpoint, testRaceName). Return(ports.ErrEngineProtocolViolation) result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) assert.Equal(t, adminbanish.ErrorCodeEngineProtocolViolation, 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, adminbanish.ErrorCodeServiceUnavailable, result.ErrorCode) } func TestHandleMappingStoreFailure(t *testing.T) { h := newHarness(t) h.seedRuntime(runtime.StatusRunning) h.mappings.getErr = errors.New("connection refused") result, err := h.service.Handle(context.Background(), baseInput()) require.NoError(t, err) assert.Equal(t, adminbanish.ErrorCodeServiceUnavailable, result.ErrorCode) } func TestHandleInvalidRequest(t *testing.T) { cases := []struct { name string input adminbanish.Input }{ {"empty game id", adminbanish.Input{GameID: "", RaceName: "X", OpSource: operation.OpSourceLobbyInternal}}, {"empty race", adminbanish.Input{GameID: testGameID, RaceName: "", OpSource: operation.OpSourceLobbyInternal}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h := newHarness(t) result, err := h.service.Handle(context.Background(), tc.input) require.NoError(t, err) assert.Equal(t, adminbanish.ErrorCodeInvalidRequest, result.ErrorCode) }) } } func TestHandleNilContextReturnsError(t *testing.T) { h := newHarness(t) _, err := h.service.Handle(nil, baseInput()) //nolint:staticcheck // guard test require.Error(t, err) } func TestHandleDefaultsOpSourceToLobbyInternal(t *testing.T) { h := newHarness(t) h.seedRuntime(runtime.StatusRunning) h.mappings.seedRace(testGameID, testRaceName, "user-1", "uuid-1") h.engine.EXPECT().BanishRace(gomock.Any(), testEndpoint, testRaceName).Return(nil) input := baseInput() input.OpSource = "" result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) require.True(t, result.IsSuccess()) entry, ok := h.logs.lastEntry() require.True(t, ok) assert.Equal(t, operation.OpSourceLobbyInternal, entry.OpSource) }