package registerruntime_test import ( "context" "errors" "fmt" "sort" "sync" "testing" "time" "galaxy/gamemaster/internal/adapters/mocks" "galaxy/gamemaster/internal/domain/engineversion" "galaxy/gamemaster/internal/domain/operation" "galaxy/gamemaster/internal/domain/playermapping" "galaxy/gamemaster/internal/domain/runtime" "galaxy/gamemaster/internal/ports" "galaxy/gamemaster/internal/service/registerruntime" "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 insErr error updErr error schErr error delErr error deletes []string updates []ports.UpdateStatusInput scheds []ports.UpdateSchedulingInput } func newFakeRuntimeRecords() *fakeRuntimeRecords { return &fakeRuntimeRecords{stored: map[string]runtime.RuntimeRecord{}} } 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, 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() 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.StatusRunning && record.StartedAt == nil { started := input.Now record.StartedAt = &started } s.stored[input.GameID] = record s.updates = append(s.updates, input) return nil } func (s *fakeRuntimeRecords) UpdateScheduling(_ context.Context, input ports.UpdateSchedulingInput) error { s.mu.Lock() defer s.mu.Unlock() 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 s.scheds = append(s.scheds, input) return nil } func (s *fakeRuntimeRecords) UpdateImage(_ context.Context, input ports.UpdateImageInput) error { s.mu.Lock() defer s.mu.Unlock() record, ok := s.stored[input.GameID] if !ok { return runtime.ErrNotFound } if record.Status != input.ExpectedStatus { return runtime.ErrConflict } record.CurrentImageRef = input.CurrentImageRef record.CurrentEngineVersion = input.CurrentEngineVersion record.UpdatedAt = input.Now s.stored[input.GameID] = record return nil } func (s *fakeRuntimeRecords) UpdateEngineHealth(context.Context, ports.UpdateEngineHealthInput) error { return errors.New("not used") } func (s *fakeRuntimeRecords) Delete(_ context.Context, gameID string) error { s.mu.Lock() defer s.mu.Unlock() s.deletes = append(s.deletes, gameID) if s.delErr != nil { return s.delErr } delete(s.stored, gameID) return nil } func (s *fakeRuntimeRecords) ListDueRunning(_ context.Context, _ time.Time) ([]runtime.RuntimeRecord, error) { return nil, errors.New("not used in registerruntime tests") } func (s *fakeRuntimeRecords) ListByStatus(_ context.Context, _ runtime.Status) ([]runtime.RuntimeRecord, error) { return nil, errors.New("not used in registerruntime tests") } func (s *fakeRuntimeRecords) List(_ context.Context) ([]runtime.RuntimeRecord, error) { return nil, errors.New("not used in registerruntime tests") } func (s *fakeRuntimeRecords) deleteCount() int { s.mu.Lock() defer s.mu.Unlock() return len(s.deletes) } func (s *fakeRuntimeRecords) hasRecord(gameID string) bool { s.mu.Lock() defer s.mu.Unlock() _, ok := s.stored[gameID] return ok } func (s *fakeRuntimeRecords) record(gameID string) (runtime.RuntimeRecord, bool) { s.mu.Lock() defer s.mu.Unlock() record, ok := s.stored[gameID] return record, ok } type fakeEngineVersions struct { mu sync.Mutex versions map[string]engineversion.EngineVersion getErr error } func newFakeEngineVersions() *fakeEngineVersions { return &fakeEngineVersions{versions: map[string]engineversion.EngineVersion{}} } func (s *fakeEngineVersions) seed(version, imageRef string) { s.mu.Lock() defer s.mu.Unlock() s.versions[version] = engineversion.EngineVersion{ Version: version, ImageRef: imageRef, Status: engineversion.StatusActive, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } } func (s *fakeEngineVersions) Get(_ context.Context, version string) (engineversion.EngineVersion, error) { s.mu.Lock() defer s.mu.Unlock() if s.getErr != nil { return engineversion.EngineVersion{}, s.getErr } record, ok := s.versions[version] if !ok { return engineversion.EngineVersion{}, engineversion.ErrNotFound } return record, nil } func (s *fakeEngineVersions) List(_ context.Context, _ *engineversion.Status) ([]engineversion.EngineVersion, error) { return nil, errors.New("not used in registerruntime tests") } func (s *fakeEngineVersions) Insert(_ context.Context, _ engineversion.EngineVersion) error { return errors.New("not used in registerruntime tests") } func (s *fakeEngineVersions) Update(_ context.Context, _ ports.UpdateEngineVersionInput) error { return errors.New("not used in registerruntime tests") } func (s *fakeEngineVersions) Deprecate(_ context.Context, _ string, _ time.Time) error { return errors.New("not used in registerruntime tests") } func (s *fakeEngineVersions) Delete(_ context.Context, _ string) error { return errors.New("not used in registerruntime tests") } func (s *fakeEngineVersions) IsReferencedByActiveRuntime(_ context.Context, _ string) (bool, error) { return false, errors.New("not used in registerruntime tests") } type fakePlayerMappings struct { mu sync.Mutex stored map[string][]playermapping.PlayerMapping bulkErr error delErr error deletes []string inserted [][]playermapping.PlayerMapping } func newFakePlayerMappings() *fakePlayerMappings { return &fakePlayerMappings{stored: map[string][]playermapping.PlayerMapping{}} } func (s *fakePlayerMappings) BulkInsert(_ context.Context, records []playermapping.PlayerMapping) error { s.mu.Lock() defer s.mu.Unlock() if s.bulkErr != nil { return s.bulkErr } if len(records) == 0 { return nil } for _, record := range records { s.stored[record.GameID] = append(s.stored[record.GameID], record) } copyOf := make([]playermapping.PlayerMapping, len(records)) copy(copyOf, records) s.inserted = append(s.inserted, copyOf) return nil } func (s *fakePlayerMappings) Get(_ context.Context, _, _ string) (playermapping.PlayerMapping, error) { return playermapping.PlayerMapping{}, errors.New("not used in registerruntime tests") } func (s *fakePlayerMappings) GetByRace(_ context.Context, _, _ string) (playermapping.PlayerMapping, error) { return playermapping.PlayerMapping{}, errors.New("not used in registerruntime tests") } func (s *fakePlayerMappings) ListByGame(_ context.Context, gameID string) ([]playermapping.PlayerMapping, error) { s.mu.Lock() defer s.mu.Unlock() return append([]playermapping.PlayerMapping(nil), s.stored[gameID]...), nil } func (s *fakePlayerMappings) DeleteByGame(_ context.Context, gameID string) error { s.mu.Lock() defer s.mu.Unlock() s.deletes = append(s.deletes, gameID) if s.delErr != nil { return s.delErr } delete(s.stored, gameID) return nil } func (s *fakePlayerMappings) deleteCount() int { s.mu.Lock() defer s.mu.Unlock() return len(s.deletes) } func (s *fakePlayerMappings) hasRecords(gameID string) bool { s.mu.Lock() defer s.mu.Unlock() return len(s.stored[gameID]) > 0 } 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 registerruntime 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 runtime *fakeRuntimeRecords versions *fakeEngineVersions mappings *fakePlayerMappings logs *fakeOperationLogs engine *mocks.MockEngineClient lobby *mocks.MockLobbyEventsPublisher telemetry *telemetry.Runtime now time.Time service *registerruntime.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(), versions: newFakeEngineVersions(), mappings: newFakePlayerMappings(), logs: &fakeOperationLogs{}, engine: mocks.NewMockEngineClient(ctrl), lobby: mocks.NewMockLobbyEventsPublisher(ctrl), telemetry: telemetryRuntime, now: time.Date(2026, time.April, 30, 12, 0, 0, 0, time.UTC), } h.versions.seed("v1.2.3", "ghcr.io/galaxy/game:v1.2.3") service, err := registerruntime.NewService(registerruntime.Dependencies{ RuntimeRecords: h.runtime, EngineVersions: h.versions, PlayerMappings: h.mappings, OperationLogs: h.logs, Engine: h.engine, LobbyEvents: h.lobby, Telemetry: h.telemetry, Clock: func() time.Time { return h.now }, }) require.NoError(t, err) h.service = service return h } func baseInput() registerruntime.Input { return registerruntime.Input{ GameID: "game-001", EngineEndpoint: "http://galaxy-game-game-001:8080", Members: []registerruntime.Member{ {UserID: "user-1", RaceName: "Aelinari"}, {UserID: "user-2", RaceName: "Drazi"}, }, TargetEngineVersion: "v1.2.3", TurnSchedule: "0 18 * * *", OpSource: operation.OpSourceLobbyInternal, SourceRef: "req-abc", } } 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}, } } // --- tests ------------------------------------------------------------ func TestNewServiceRejectsMissingDeps(t *testing.T) { telemetryRuntime, err := telemetry.NewWithProviders(nil, nil) require.NoError(t, err) cases := []struct { name string mut func(*registerruntime.Dependencies) }{ {"runtime records", func(d *registerruntime.Dependencies) { d.RuntimeRecords = nil }}, {"engine versions", func(d *registerruntime.Dependencies) { d.EngineVersions = nil }}, {"player mappings", func(d *registerruntime.Dependencies) { d.PlayerMappings = nil }}, {"operation logs", func(d *registerruntime.Dependencies) { d.OperationLogs = nil }}, {"engine", func(d *registerruntime.Dependencies) { d.Engine = nil }}, {"lobby events", func(d *registerruntime.Dependencies) { d.LobbyEvents = nil }}, {"telemetry", func(d *registerruntime.Dependencies) { d.Telemetry = nil }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctrl := gomock.NewController(t) deps := registerruntime.Dependencies{ RuntimeRecords: newFakeRuntimeRecords(), EngineVersions: newFakeEngineVersions(), PlayerMappings: newFakePlayerMappings(), OperationLogs: &fakeOperationLogs{}, Engine: mocks.NewMockEngineClient(ctrl), LobbyEvents: mocks.NewMockLobbyEventsPublisher(ctrl), Telemetry: telemetryRuntime, } tc.mut(&deps) service, err := registerruntime.NewService(deps) require.Error(t, err) require.Nil(t, service) }) } } func TestHandleHappyPath(t *testing.T) { h := newHarness(t) input := baseInput() h.engine.EXPECT(). Init(gomock.Any(), input.EngineEndpoint, ports.InitRequest{ Races: []ports.InitRace{{RaceName: "Aelinari"}, {RaceName: "Drazi"}}, }). Return(ports.StateResponse{ Turn: 0, Players: enginePlayers(), }, nil) var captured ports.RuntimeSnapshotUpdate h.lobby.EXPECT(). PublishSnapshotUpdate(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, msg ports.RuntimeSnapshotUpdate) error { captured = msg return nil }) result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) require.True(t, result.IsSuccess(), "outcome %q error_code=%q", result.Outcome, result.ErrorCode) require.Equal(t, runtime.StatusRunning, result.Record.Status) require.Equal(t, "ghcr.io/galaxy/game:v1.2.3", result.Record.CurrentImageRef) require.NotNil(t, result.Record.NextGenerationAt) require.NotNil(t, result.Record.StartedAt) stored, ok := h.runtime.record(input.GameID) require.True(t, ok) assert.Equal(t, runtime.StatusRunning, stored.Status) assert.Equal(t, 0, stored.CurrentTurn) assert.False(t, stored.SkipNextTick) require.NotNil(t, stored.NextGenerationAt) assert.True(t, stored.NextGenerationAt.After(h.now)) mappings, err := h.mappings.ListByGame(context.Background(), input.GameID) require.NoError(t, err) require.Len(t, mappings, 2) sort.Slice(mappings, func(i, j int) bool { return mappings[i].UserID < mappings[j].UserID }) assert.Equal(t, "user-1", mappings[0].UserID) assert.Equal(t, "Aelinari", mappings[0].RaceName) assert.Equal(t, "uuid-1", mappings[0].EnginePlayerUUID) assert.Equal(t, "user-2", mappings[1].UserID) assert.Equal(t, "Drazi", mappings[1].RaceName) assert.Equal(t, "uuid-2", mappings[1].EnginePlayerUUID) entry, ok := h.logs.lastEntry() require.True(t, ok) assert.Equal(t, operation.OutcomeSuccess, entry.Outcome) assert.Equal(t, operation.OpKindRegisterRuntime, entry.OpKind) assert.Equal(t, operation.OpSourceLobbyInternal, entry.OpSource) assert.Equal(t, "req-abc", entry.SourceRef) assert.Equal(t, input.GameID, captured.GameID) assert.Equal(t, runtime.StatusRunning, captured.RuntimeStatus) assert.Equal(t, 0, captured.CurrentTurn) assert.Equal(t, "", captured.EngineHealthSummary) require.Len(t, captured.PlayerTurnStats, 2) assert.Equal(t, "user-1", captured.PlayerTurnStats[0].UserID) assert.Equal(t, 3, captured.PlayerTurnStats[0].Planets) assert.Equal(t, 100, captured.PlayerTurnStats[0].Population) assert.Equal(t, "user-2", captured.PlayerTurnStats[1].UserID) assert.Equal(t, 2, captured.PlayerTurnStats[1].Planets) assert.Equal(t, 80, captured.PlayerTurnStats[1].Population) assert.Equal(t, h.now.UTC(), captured.OccurredAt) } func TestHandleRejectsInvalidInput(t *testing.T) { cases := []struct { name string mut func(*registerruntime.Input) }{ {"empty game id", func(i *registerruntime.Input) { i.GameID = "" }}, {"empty engine endpoint", func(i *registerruntime.Input) { i.EngineEndpoint = "" }}, {"empty members", func(i *registerruntime.Input) { i.Members = nil }}, {"empty target version", func(i *registerruntime.Input) { i.TargetEngineVersion = "" }}, {"empty turn schedule", func(i *registerruntime.Input) { i.TurnSchedule = "" }}, {"missing user id", func(i *registerruntime.Input) { i.Members = []registerruntime.Member{{UserID: "", RaceName: "Aelinari"}} }}, {"missing race name", func(i *registerruntime.Input) { i.Members = []registerruntime.Member{{UserID: "user-1", RaceName: ""}} }}, {"unknown op source", func(i *registerruntime.Input) { i.OpSource = "exotic" }}, {"duplicate user id", func(i *registerruntime.Input) { i.Members = []registerruntime.Member{ {UserID: "user-1", RaceName: "Aelinari"}, {UserID: "user-1", RaceName: "Drazi"}, } }}, {"duplicate race name", func(i *registerruntime.Input) { i.Members = []registerruntime.Member{ {UserID: "user-1", RaceName: "Aelinari"}, {UserID: "user-2", RaceName: "Aelinari"}, } }}, } 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, registerruntime.ErrorCodeInvalidRequest, result.ErrorCode) // No persistence should have happened. assert.False(t, h.runtime.hasRecord(input.GameID)) assert.False(t, h.mappings.hasRecords(input.GameID)) }) } } func TestHandleRejectsExistingRuntime(t *testing.T) { h := newHarness(t) input := baseInput() require.NoError(t, h.runtime.Insert(context.Background(), runtime.RuntimeRecord{ GameID: input.GameID, Status: runtime.StatusRunning, EngineEndpoint: input.EngineEndpoint, CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3", CurrentEngineVersion: "v1.2.3", TurnSchedule: input.TurnSchedule, CreatedAt: h.now, UpdatedAt: h.now, StartedAt: &h.now, })) result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, registerruntime.ErrorCodeConflict, result.ErrorCode) assert.True(t, h.runtime.hasRecord(input.GameID), "existing record must not be removed") assert.Equal(t, 0, h.runtime.deleteCount()) assert.Equal(t, 0, h.mappings.deleteCount()) entry, ok := h.logs.lastEntry() require.True(t, ok) assert.Equal(t, operation.OutcomeFailure, entry.Outcome) assert.Equal(t, registerruntime.ErrorCodeConflict, entry.ErrorCode) } func TestHandleRejectsMissingEngineVersion(t *testing.T) { h := newHarness(t) input := baseInput() input.TargetEngineVersion = "v9.9.9" result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, registerruntime.ErrorCodeEngineVersionNotFound, result.ErrorCode) assert.False(t, h.runtime.hasRecord(input.GameID)) assert.Equal(t, 0, h.runtime.deleteCount()) } func TestHandleRollsBackOnEngineUnreachable(t *testing.T) { h := newHarness(t) input := baseInput() h.engine.EXPECT(). Init(gomock.Any(), input.EngineEndpoint, gomock.Any()). Return(ports.StateResponse{}, fmt.Errorf("dial: %w", ports.ErrEngineUnreachable)) result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, registerruntime.ErrorCodeEngineUnreachable, result.ErrorCode) assert.False(t, h.runtime.hasRecord(input.GameID)) assert.Equal(t, 1, h.runtime.deleteCount()) // player_mappings were never installed; rollback skips them. assert.Equal(t, 0, h.mappings.deleteCount()) } func TestHandleRollsBackOnEngineValidationError(t *testing.T) { h := newHarness(t) input := baseInput() h.engine.EXPECT(). Init(gomock.Any(), input.EngineEndpoint, gomock.Any()). Return(ports.StateResponse{}, fmt.Errorf("init body: %w", ports.ErrEngineValidation)) result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, registerruntime.ErrorCodeEngineValidationError, result.ErrorCode) assert.False(t, h.runtime.hasRecord(input.GameID)) assert.Equal(t, 1, h.runtime.deleteCount()) } func TestHandleRollsBackOnEngineProtocolViolation(t *testing.T) { h := newHarness(t) input := baseInput() h.engine.EXPECT(). Init(gomock.Any(), input.EngineEndpoint, gomock.Any()). Return(ports.StateResponse{ Players: []ports.PlayerState{ {RaceName: "Unknown", EnginePlayerUUID: "uuid-x", Planets: 1, Population: 10}, {RaceName: "Aelinari", EnginePlayerUUID: "uuid-1", Planets: 2, Population: 50}, }, }, nil) result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, registerruntime.ErrorCodeEngineProtocolViolation, result.ErrorCode) assert.False(t, h.runtime.hasRecord(input.GameID)) assert.Equal(t, 1, h.runtime.deleteCount()) } func TestHandleRollsBackOnPlayerCountMismatch(t *testing.T) { h := newHarness(t) input := baseInput() h.engine.EXPECT(). Init(gomock.Any(), input.EngineEndpoint, gomock.Any()). Return(ports.StateResponse{ Players: []ports.PlayerState{ {RaceName: "Aelinari", EnginePlayerUUID: "uuid-1", Planets: 1, Population: 10}, }, }, nil) result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, registerruntime.ErrorCodeEngineProtocolViolation, result.ErrorCode) assert.False(t, h.runtime.hasRecord(input.GameID)) } func TestHandleRollsBackOnPlayerMappingConflict(t *testing.T) { h := newHarness(t) input := baseInput() h.mappings.bulkErr = fmt.Errorf("duplicate row: %w", playermapping.ErrConflict) h.engine.EXPECT(). Init(gomock.Any(), input.EngineEndpoint, gomock.Any()). Return(ports.StateResponse{Players: enginePlayers()}, nil) result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, registerruntime.ErrorCodeConflict, result.ErrorCode) assert.False(t, h.runtime.hasRecord(input.GameID)) assert.Equal(t, 1, h.runtime.deleteCount()) // BulkInsert is per-statement atomic, so a failure leaves no rows // to clean up. assert.Equal(t, 0, h.mappings.deleteCount()) } func TestHandleRollsBackOnSchedulingUpdateFailure(t *testing.T) { h := newHarness(t) input := baseInput() h.runtime.schErr = errors.New("postgres timeout") h.engine.EXPECT(). Init(gomock.Any(), input.EngineEndpoint, gomock.Any()). Return(ports.StateResponse{Players: enginePlayers()}, nil) result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, registerruntime.ErrorCodeServiceUnavailable, result.ErrorCode) assert.False(t, h.runtime.hasRecord(input.GameID)) assert.Equal(t, 1, h.runtime.deleteCount()) assert.Equal(t, 1, h.mappings.deleteCount()) } func TestHandleRollsBackOnInvalidTurnSchedule(t *testing.T) { h := newHarness(t) input := baseInput() input.TurnSchedule = "not-a-cron" // Engine init still happens because TurnSchedule is parsed only // after the engine roster validation step. h.engine.EXPECT(). Init(gomock.Any(), input.EngineEndpoint, gomock.Any()). Return(ports.StateResponse{Players: enginePlayers()}, nil) result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, registerruntime.ErrorCodeInvalidRequest, result.ErrorCode) assert.False(t, h.runtime.hasRecord(input.GameID)) assert.Equal(t, 1, h.runtime.deleteCount()) assert.Equal(t, 1, h.mappings.deleteCount()) } func TestHandleAppendsOperationLogOnFailure(t *testing.T) { h := newHarness(t) input := baseInput() h.engine.EXPECT(). Init(gomock.Any(), input.EngineEndpoint, gomock.Any()). Return(ports.StateResponse{}, fmt.Errorf("dial: %w", ports.ErrEngineUnreachable)) result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) require.Equal(t, operation.OutcomeFailure, result.Outcome) entry, ok := h.logs.lastEntry() require.True(t, ok) assert.Equal(t, operation.OpKindRegisterRuntime, entry.OpKind) assert.Equal(t, operation.OpSourceLobbyInternal, entry.OpSource) assert.Equal(t, operation.OutcomeFailure, entry.Outcome) assert.Equal(t, registerruntime.ErrorCodeEngineUnreachable, entry.ErrorCode) require.NotNil(t, entry.FinishedAt) assert.False(t, entry.FinishedAt.Before(entry.StartedAt)) } func TestHandleSurfaceServiceUnavailableOnGetRuntimeError(t *testing.T) { h := newHarness(t) input := baseInput() h.runtime.getErr = errors.New("postgres dial timeout") result, err := h.service.Handle(context.Background(), input) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, registerruntime.ErrorCodeServiceUnavailable, result.ErrorCode) } func TestHandleRejectsNilContext(t *testing.T) { h := newHarness(t) _, err := h.service.Handle(nil, baseInput()) //nolint:staticcheck // intentional nil context require.Error(t, err) } func TestHandleNilServiceReturnsError(t *testing.T) { var service *registerruntime.Service _, err := service.Handle(context.Background(), baseInput()) require.Error(t, err) }