package adminpatch_test import ( "context" "errors" "sync" "testing" "time" "galaxy/gamemaster/internal/adapters/mocks" "galaxy/gamemaster/internal/domain/engineversion" "galaxy/gamemaster/internal/domain/operation" "galaxy/gamemaster/internal/domain/runtime" "galaxy/gamemaster/internal/ports" "galaxy/gamemaster/internal/service/adminpatch" "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 imgErr error images []ports.UpdateImageInput } 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, input ports.UpdateImageInput) error { s.mu.Lock() defer s.mu.Unlock() if s.imgErr != nil { s.images = append(s.images, input) return s.imgErr } record, ok := s.stored[input.GameID] if !ok { s.images = append(s.images, input) return runtime.ErrNotFound } if record.Status != input.ExpectedStatus { s.images = append(s.images, input) return runtime.ErrConflict } record.CurrentImageRef = input.CurrentImageRef record.CurrentEngineVersion = input.CurrentEngineVersion record.UpdatedAt = input.Now s.stored[input.GameID] = record s.images = append(s.images, input) return nil } 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 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(record engineversion.EngineVersion) { s.mu.Lock() defer s.mu.Unlock() s.versions[record.Version] = record } 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 } rec, ok := s.versions[version] if !ok { return engineversion.EngineVersion{}, engineversion.ErrNotFound } return rec, nil } func (s *fakeEngineVersions) List(context.Context, *engineversion.Status) ([]engineversion.EngineVersion, error) { return nil, errors.New("not used") } func (s *fakeEngineVersions) Insert(context.Context, engineversion.EngineVersion) error { return errors.New("not used") } func (s *fakeEngineVersions) Update(context.Context, ports.UpdateEngineVersionInput) error { return errors.New("not used") } func (s *fakeEngineVersions) Deprecate(context.Context, string, time.Time) error { return errors.New("not used") } func (s *fakeEngineVersions) Delete(context.Context, string) error { return errors.New("not used") } func (s *fakeEngineVersions) IsReferencedByActiveRuntime(context.Context, string) (bool, error) { return false, 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 } 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 versions *fakeEngineVersions logs *fakeOperationLogs rtm *mocks.MockRTMClient telemetry *telemetry.Runtime now time.Time service *adminpatch.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(), logs: &fakeOperationLogs{}, rtm: mocks.NewMockRTMClient(ctrl), telemetry: telemetryRuntime, now: time.Date(2026, time.May, 1, 12, 0, 0, 0, time.UTC), } service, err := adminpatch.NewService(adminpatch.Dependencies{ RuntimeRecords: h.runtime, EngineVersions: h.versions, OperationLogs: h.logs, RTM: h.rtm, Telemetry: h.telemetry, Clock: func() time.Time { return h.now }, }) require.NoError(t, err) h.service = service return h } func (h *harness) seedRunningOnVersion(version, image string) 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: runtime.StatusRunning, EngineEndpoint: "http://galaxy-game-game-001:8080", CurrentImageRef: image, CurrentEngineVersion: version, TurnSchedule: "0 18 * * *", CurrentTurn: 7, NextGenerationAt: &next, EngineHealth: "healthy", CreatedAt: created, UpdatedAt: started, StartedAt: &started, } h.runtime.seed(record) return record } func (h *harness) seedTarget(version, image string, status engineversion.Status) { h.versions.seed(engineversion.EngineVersion{ Version: version, ImageRef: image, Status: status, CreatedAt: h.now.Add(-24 * time.Hour), UpdatedAt: h.now.Add(-24 * time.Hour), }) } func baseInput(version string) adminpatch.Input { return adminpatch.Input{ GameID: "game-001", Version: version, OpSource: operation.OpSourceAdminRest, SourceRef: "req-patch-001", } } // --- tests ------------------------------------------------------------ func TestNewServiceRejectsMissingDeps(t *testing.T) { telemetryRuntime, err := telemetry.NewWithProviders(nil, nil) require.NoError(t, err) cases := []struct { name string mut func(*adminpatch.Dependencies) }{ {"runtime records", func(d *adminpatch.Dependencies) { d.RuntimeRecords = nil }}, {"engine versions", func(d *adminpatch.Dependencies) { d.EngineVersions = nil }}, {"operation logs", func(d *adminpatch.Dependencies) { d.OperationLogs = nil }}, {"rtm", func(d *adminpatch.Dependencies) { d.RTM = nil }}, {"telemetry", func(d *adminpatch.Dependencies) { d.Telemetry = nil }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctrl := gomock.NewController(t) deps := adminpatch.Dependencies{ RuntimeRecords: newFakeRuntimeRecords(), EngineVersions: newFakeEngineVersions(), OperationLogs: &fakeOperationLogs{}, RTM: mocks.NewMockRTMClient(ctrl), Telemetry: telemetryRuntime, } tc.mut(&deps) service, err := adminpatch.NewService(deps) require.Error(t, err) require.Nil(t, service) }) } } func TestHandleHappyPathRotatesImage(t *testing.T) { h := newHarness(t) h.seedRunningOnVersion("v1.2.3", "ghcr.io/galaxy/game:v1.2.3") h.seedTarget("v1.2.4", "ghcr.io/galaxy/game:v1.2.4", engineversion.StatusActive) h.rtm.EXPECT().Patch(gomock.Any(), "game-001", "ghcr.io/galaxy/game:v1.2.4").Return(nil) result, err := h.service.Handle(context.Background(), baseInput("v1.2.4")) require.NoError(t, err) require.True(t, result.IsSuccess(), "want success, got %+v", result) assert.Equal(t, "ghcr.io/galaxy/game:v1.2.4", result.Record.CurrentImageRef) assert.Equal(t, "v1.2.4", result.Record.CurrentEngineVersion) assert.Equal(t, runtime.StatusRunning, result.Record.Status) require.Len(t, h.runtime.images, 1) assert.Equal(t, runtime.StatusRunning, h.runtime.images[0].ExpectedStatus) assert.Equal(t, "ghcr.io/galaxy/game:v1.2.4", h.runtime.images[0].CurrentImageRef) assert.Equal(t, "v1.2.4", h.runtime.images[0].CurrentEngineVersion) entry, ok := h.logs.lastEntry() require.True(t, ok) assert.Equal(t, operation.OpKindPatch, entry.OpKind) assert.Equal(t, operation.OutcomeSuccess, entry.Outcome) } func TestHandleRuntimeNotFound(t *testing.T) { h := newHarness(t) h.seedTarget("v1.2.4", "ghcr.io/galaxy/game:v1.2.4", engineversion.StatusActive) result, err := h.service.Handle(context.Background(), baseInput("v1.2.4")) require.NoError(t, err) assert.Equal(t, adminpatch.ErrorCodeRuntimeNotFound, result.ErrorCode) } func TestHandleRuntimeNotRunning(t *testing.T) { h := newHarness(t) rec := h.seedRunningOnVersion("v1.2.3", "ghcr.io/galaxy/game:v1.2.3") rec.Status = runtime.StatusStopped h.runtime.seed(rec) h.seedTarget("v1.2.4", "ghcr.io/galaxy/game:v1.2.4", engineversion.StatusActive) result, err := h.service.Handle(context.Background(), baseInput("v1.2.4")) require.NoError(t, err) assert.Equal(t, adminpatch.ErrorCodeRuntimeNotRunning, result.ErrorCode) assert.Empty(t, h.runtime.images, "no UpdateImage when status precondition fails") } func TestHandleEngineVersionMissing(t *testing.T) { h := newHarness(t) h.seedRunningOnVersion("v1.2.3", "ghcr.io/galaxy/game:v1.2.3") result, err := h.service.Handle(context.Background(), baseInput("v1.2.4")) require.NoError(t, err) assert.Equal(t, adminpatch.ErrorCodeEngineVersionNotFound, result.ErrorCode) } func TestHandleEngineVersionDeprecated(t *testing.T) { h := newHarness(t) h.seedRunningOnVersion("v1.2.3", "ghcr.io/galaxy/game:v1.2.3") h.seedTarget("v1.2.4", "ghcr.io/galaxy/game:v1.2.4", engineversion.StatusDeprecated) result, err := h.service.Handle(context.Background(), baseInput("v1.2.4")) require.NoError(t, err) assert.Equal(t, adminpatch.ErrorCodeEngineVersionNotFound, result.ErrorCode) assert.Contains(t, result.ErrorMessage, "deprecated") } func TestHandleSemverPatchOnlyMajor(t *testing.T) { h := newHarness(t) h.seedRunningOnVersion("v1.2.3", "ghcr.io/galaxy/game:v1.2.3") h.seedTarget("v2.0.0", "ghcr.io/galaxy/game:v2.0.0", engineversion.StatusActive) result, err := h.service.Handle(context.Background(), baseInput("v2.0.0")) require.NoError(t, err) assert.Equal(t, adminpatch.ErrorCodeSemverPatchOnly, result.ErrorCode) assert.Empty(t, h.runtime.images) } func TestHandleSemverPatchOnlyMinor(t *testing.T) { h := newHarness(t) h.seedRunningOnVersion("v1.2.3", "ghcr.io/galaxy/game:v1.2.3") h.seedTarget("v1.3.0", "ghcr.io/galaxy/game:v1.3.0", engineversion.StatusActive) result, err := h.service.Handle(context.Background(), baseInput("v1.3.0")) require.NoError(t, err) assert.Equal(t, adminpatch.ErrorCodeSemverPatchOnly, result.ErrorCode) } func TestHandleRTMUnavailable(t *testing.T) { h := newHarness(t) h.seedRunningOnVersion("v1.2.3", "ghcr.io/galaxy/game:v1.2.3") h.seedTarget("v1.2.4", "ghcr.io/galaxy/game:v1.2.4", engineversion.StatusActive) h.rtm.EXPECT().Patch(gomock.Any(), "game-001", "ghcr.io/galaxy/game:v1.2.4"). Return(ports.ErrRTMUnavailable) result, err := h.service.Handle(context.Background(), baseInput("v1.2.4")) require.NoError(t, err) assert.Equal(t, adminpatch.ErrorCodeServiceUnavailable, result.ErrorCode) assert.Empty(t, h.runtime.images, "no UpdateImage when RTM fails") } func TestHandleCASLostAfterRTM(t *testing.T) { h := newHarness(t) h.seedRunningOnVersion("v1.2.3", "ghcr.io/galaxy/game:v1.2.3") h.seedTarget("v1.2.4", "ghcr.io/galaxy/game:v1.2.4", engineversion.StatusActive) h.rtm.EXPECT().Patch(gomock.Any(), "game-001", "ghcr.io/galaxy/game:v1.2.4").Return(nil) h.runtime.imgErr = runtime.ErrConflict result, err := h.service.Handle(context.Background(), baseInput("v1.2.4")) require.NoError(t, err) assert.Equal(t, adminpatch.ErrorCodeConflict, result.ErrorCode) require.Len(t, h.runtime.images, 1) } func TestHandleInvalidRequest(t *testing.T) { cases := []struct { name string input adminpatch.Input }{ {"empty game id", adminpatch.Input{GameID: "", Version: "v1.2.4", OpSource: operation.OpSourceAdminRest}}, {"malformed version", adminpatch.Input{GameID: "game-001", Version: "not-a-semver", OpSource: operation.OpSourceAdminRest}}, } 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, adminpatch.ErrorCodeInvalidRequest, result.ErrorCode) }) } } func TestHandleNilContextReturnsError(t *testing.T) { h := newHarness(t) _, err := h.service.Handle(nil, baseInput("v1.2.4")) //nolint:staticcheck // guard test require.Error(t, err) } func TestHandleStoreReadFailure(t *testing.T) { h := newHarness(t) h.runtime.getErr = errors.New("connection refused") result, err := h.service.Handle(context.Background(), baseInput("v1.2.4")) require.NoError(t, err) assert.Equal(t, adminpatch.ErrorCodeServiceUnavailable, result.ErrorCode) }