package cleanupcontainer_test import ( "context" "errors" "sync" "testing" "time" "galaxy/rtmanager/internal/adapters/docker/mocks" "galaxy/rtmanager/internal/config" "galaxy/rtmanager/internal/domain/operation" "galaxy/rtmanager/internal/domain/runtime" "galaxy/rtmanager/internal/ports" "galaxy/rtmanager/internal/service/cleanupcontainer" "galaxy/rtmanager/internal/service/startruntime" "galaxy/rtmanager/internal/telemetry" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) // --- shared fake doubles ---------------------------------------------- type fakeRuntimeRecords struct { mu sync.Mutex stored map[string]runtime.RuntimeRecord getErr error updateStatusErr error updates []ports.UpdateStatusInput } 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) Upsert(_ context.Context, _ runtime.RuntimeRecord) error { return errors.New("not used in cleanup tests") } func (s *fakeRuntimeRecords) UpdateStatus(_ context.Context, input ports.UpdateStatusInput) error { s.mu.Lock() defer s.mu.Unlock() s.updates = append(s.updates, input) if s.updateStatusErr != nil { return s.updateStatusErr } record, ok := s.stored[input.GameID] if !ok { return runtime.ErrNotFound } if record.Status != input.ExpectedFrom { return runtime.ErrConflict } if input.ExpectedContainerID != "" && record.CurrentContainerID != input.ExpectedContainerID { return runtime.ErrConflict } record.Status = input.To record.LastOpAt = input.Now if input.To == runtime.StatusRemoved { removedAt := input.Now record.RemovedAt = &removedAt record.CurrentContainerID = "" } s.stored[input.GameID] = record return nil } func (s *fakeRuntimeRecords) ListByStatus(_ context.Context, _ runtime.Status) ([]runtime.RuntimeRecord, error) { return nil, errors.New("not used in cleanup tests") } func (s *fakeRuntimeRecords) List(_ context.Context) ([]runtime.RuntimeRecord, error) { return nil, errors.New("not used in cleanup tests") } type fakeOperationLogs struct { mu sync.Mutex appendErr error appends []operation.OperationEntry } func (s *fakeOperationLogs) Append(_ context.Context, entry operation.OperationEntry) (int64, error) { s.mu.Lock() defer s.mu.Unlock() if s.appendErr != nil { return 0, s.appendErr } s.appends = append(s.appends, entry) return int64(len(s.appends)), nil } func (s *fakeOperationLogs) ListByGame(_ context.Context, _ string, _ int) ([]operation.OperationEntry, error) { return nil, errors.New("not used in cleanup tests") } func (s *fakeOperationLogs) lastAppend() (operation.OperationEntry, bool) { s.mu.Lock() defer s.mu.Unlock() if len(s.appends) == 0 { return operation.OperationEntry{}, false } return s.appends[len(s.appends)-1], true } type fakeLeases struct { mu sync.Mutex acquired bool acquireErr error releaseErr error acquires []string releases []string } func (l *fakeLeases) TryAcquire(_ context.Context, _, token string, _ time.Duration) (bool, error) { l.mu.Lock() defer l.mu.Unlock() l.acquires = append(l.acquires, token) if l.acquireErr != nil { return false, l.acquireErr } return l.acquired, nil } func (l *fakeLeases) Release(_ context.Context, _, token string) error { l.mu.Lock() defer l.mu.Unlock() l.releases = append(l.releases, token) return l.releaseErr } // --- harness ---------------------------------------------------------- type harness struct { records *fakeRuntimeRecords operationLogs *fakeOperationLogs docker *mocks.MockDockerClient leases *fakeLeases telemetry *telemetry.Runtime now time.Time } func newHarness(t *testing.T) *harness { t.Helper() ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) telemetryRuntime, err := telemetry.NewWithProviders(nil, nil) require.NoError(t, err) return &harness{ records: newFakeRuntimeRecords(), operationLogs: &fakeOperationLogs{}, docker: mocks.NewMockDockerClient(ctrl), leases: &fakeLeases{acquired: true}, telemetry: telemetryRuntime, now: time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC), } } func (h *harness) build(t *testing.T) *cleanupcontainer.Service { t.Helper() service, err := cleanupcontainer.NewService(cleanupcontainer.Dependencies{ RuntimeRecords: h.records, OperationLogs: h.operationLogs, Docker: h.docker, Leases: h.leases, Coordination: config.CoordinationConfig{GameLeaseTTL: time.Minute}, Telemetry: h.telemetry, Clock: func() time.Time { return h.now }, NewToken: func() string { return "token-A" }, }) require.NoError(t, err) return service } func basicInput() cleanupcontainer.Input { return cleanupcontainer.Input{ GameID: "game-1", OpSource: operation.OpSourceAdminRest, SourceRef: "rest-cleanup-1", } } func stoppedRecord(now time.Time) runtime.RuntimeRecord { startedAt := now.Add(-2 * time.Hour) stoppedAt := now.Add(-time.Hour) return runtime.RuntimeRecord{ GameID: "game-1", Status: runtime.StatusStopped, CurrentContainerID: "ctr-old", CurrentImageRef: "registry.example.com/galaxy/game:1.4.7", EngineEndpoint: "http://galaxy-game-game-1:8080", StatePath: "/var/lib/galaxy/games/game-1", DockerNetwork: "galaxy-net", StartedAt: &startedAt, StoppedAt: &stoppedAt, LastOpAt: stoppedAt, CreatedAt: startedAt, } } // --- happy path ----------------------------------------------------- func TestHandleCleanupHappyPath(t *testing.T) { h := newHarness(t) h.records.stored["game-1"] = stoppedRecord(h.now) h.docker.EXPECT().Remove(gomock.Any(), "ctr-old").Return(nil) service := h.build(t) result, err := service.Handle(context.Background(), basicInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeSuccess, result.Outcome) assert.Empty(t, result.ErrorCode) assert.Equal(t, runtime.StatusRemoved, result.Record.Status) assert.Empty(t, result.Record.CurrentContainerID) require.Len(t, h.records.updates, 1) assert.Equal(t, runtime.StatusStopped, h.records.updates[0].ExpectedFrom) assert.Equal(t, runtime.StatusRemoved, h.records.updates[0].To) require.Len(t, h.operationLogs.appends, 1) last, _ := h.operationLogs.lastAppend() assert.Equal(t, operation.OpKindCleanupContainer, last.OpKind) assert.Equal(t, operation.OutcomeSuccess, last.Outcome) assert.Empty(t, last.ErrorCode) } // --- replay --------------------------------------------------------- func TestHandleReplayNoOpForRemovedRecord(t *testing.T) { h := newHarness(t) removed := stoppedRecord(h.now) removed.Status = runtime.StatusRemoved removed.CurrentContainerID = "" removedAt := h.now.Add(-30 * time.Minute) removed.RemovedAt = &removedAt h.records.stored["game-1"] = removed service := h.build(t) result, err := service.Handle(context.Background(), basicInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeSuccess, result.Outcome) assert.Equal(t, startruntime.ErrorCodeReplayNoOp, result.ErrorCode) assert.Empty(t, h.records.updates) last, _ := h.operationLogs.lastAppend() assert.Equal(t, startruntime.ErrorCodeReplayNoOp, last.ErrorCode) } func TestHandleReplayNoOpOnUpdateStatusConflict(t *testing.T) { h := newHarness(t) h.records.stored["game-1"] = stoppedRecord(h.now) h.records.updateStatusErr = runtime.ErrConflict h.docker.EXPECT().Remove(gomock.Any(), "ctr-old").Return(nil) service := h.build(t) result, err := service.Handle(context.Background(), basicInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeSuccess, result.Outcome) assert.Equal(t, startruntime.ErrorCodeReplayNoOp, result.ErrorCode) } // --- failure paths -------------------------------------------------- func TestHandleConflictOnRunningRecord(t *testing.T) { h := newHarness(t) running := stoppedRecord(h.now) running.Status = runtime.StatusRunning startedAt := h.now.Add(-time.Hour) running.StartedAt = &startedAt running.StoppedAt = nil h.records.stored["game-1"] = running service := h.build(t) result, err := service.Handle(context.Background(), basicInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, startruntime.ErrorCodeConflict, result.ErrorCode) assert.Contains(t, result.ErrorMessage, "stop the runtime first") } func TestHandleNotFoundForMissingRecord(t *testing.T) { h := newHarness(t) service := h.build(t) result, err := service.Handle(context.Background(), basicInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, startruntime.ErrorCodeNotFound, result.ErrorCode) } func TestHandleServiceUnavailableOnDockerRemoveFailure(t *testing.T) { h := newHarness(t) h.records.stored["game-1"] = stoppedRecord(h.now) h.docker.EXPECT().Remove(gomock.Any(), "ctr-old").Return(errors.New("disk i/o")) service := h.build(t) result, err := service.Handle(context.Background(), basicInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, startruntime.ErrorCodeServiceUnavailable, result.ErrorCode) assert.Empty(t, h.records.updates, "no record mutation on docker remove failure") } func TestHandleInternalErrorOnGenericUpdateError(t *testing.T) { h := newHarness(t) h.records.stored["game-1"] = stoppedRecord(h.now) h.records.updateStatusErr = errors.New("postgres down") h.docker.EXPECT().Remove(gomock.Any(), "ctr-old").Return(nil) service := h.build(t) result, err := service.Handle(context.Background(), basicInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, startruntime.ErrorCodeInternal, result.ErrorCode) } func TestHandleConflictWhenLeaseBusy(t *testing.T) { h := newHarness(t) h.leases.acquired = false service := h.build(t) result, err := service.Handle(context.Background(), basicInput()) require.NoError(t, err) assert.Equal(t, operation.OutcomeFailure, result.Outcome) assert.Equal(t, startruntime.ErrorCodeConflict, result.ErrorCode) } // --- input validation ---------------------------------------------- func TestHandleRejectsInvalidInput(t *testing.T) { h := newHarness(t) service := h.build(t) cases := []cleanupcontainer.Input{ {GameID: "", OpSource: operation.OpSourceAdminRest}, {GameID: "g", OpSource: operation.OpSource("bogus")}, } for _, input := range cases { result, err := service.Handle(context.Background(), input) require.NoError(t, err) assert.Equal(t, startruntime.ErrorCodeInvalidRequest, result.ErrorCode) } } // --- constructor --------------------------------------------------- func TestNewServiceRejectsMissingDependencies(t *testing.T) { h := newHarness(t) deps := cleanupcontainer.Dependencies{ Coordination: config.CoordinationConfig{GameLeaseTTL: time.Minute}, Telemetry: h.telemetry, } _, err := cleanupcontainer.NewService(deps) require.Error(t, err) }