package handlers import ( "context" "net/http" "testing" "galaxy/rtmanager/internal/api/internalhttp/handlers/mocks" "galaxy/rtmanager/internal/domain/operation" "galaxy/rtmanager/internal/domain/runtime" "galaxy/rtmanager/internal/service/cleanupcontainer" "galaxy/rtmanager/internal/service/patchruntime" "galaxy/rtmanager/internal/service/restartruntime" "galaxy/rtmanager/internal/service/startruntime" "galaxy/rtmanager/internal/service/stopruntime" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) // Tests for the mutating handlers (start, stop, restart, patch, // cleanup). Each handler delegates to one lifecycle service through a // narrow `mockgen`-backed interface; the handler layer is responsible // for input parsing, the `X-Galaxy-Caller` → `op_source` mapping, and // the canonical `ErrorCode` → HTTP status table documented in // `rtmanager/docs/services.md` §18. // --- start --- func TestStartHandlerReturnsRecordOnSuccess(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockStartService(ctrl) record := sampleRunningRecord(t) mock.EXPECT(). Handle(gomock.Any(), gomock.AssignableToTypeOf(startruntime.Input{})). DoAndReturn(func(_ context.Context, in startruntime.Input) (startruntime.Result, error) { assert.Equal(t, "game-test", in.GameID) assert.Equal(t, "galaxy/game:v1.2.3", in.ImageRef) assert.Equal(t, operation.OpSourceAdminRest, in.OpSource) return startruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil }) deps := Dependencies{StartRuntime: mock} rec := drive(t, deps, http.MethodPost, "/api/v1/internal/runtimes/game-test/start", jsonHeaders(), strReader(`{"image_ref":"galaxy/game:v1.2.3"}`), ) resp := decodeRecordResponse(t, rec) assert.Equal(t, "game-test", resp.GameID) assert.Equal(t, "running", resp.Status) } func TestStartHandlerReturnsRecordOnReplayNoOp(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockStartService(ctrl) record := sampleRunningRecord(t) mock.EXPECT(). Handle(gomock.Any(), gomock.Any()). Return(startruntime.Result{ Record: record, Outcome: operation.OutcomeSuccess, ErrorCode: startruntime.ErrorCodeReplayNoOp, }, nil) rec := drive(t, Dependencies{StartRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/start", jsonHeaders(), strReader(`{"image_ref":"galaxy/game:v1.2.3"}`), ) resp := decodeRecordResponse(t, rec) assert.Equal(t, "game-test", resp.GameID) } func TestStartHandlerMapsServiceFailures(t *testing.T) { t.Parallel() cases := []struct { name string errorCode string wantStatus int }{ {"start_config_invalid", startruntime.ErrorCodeStartConfigInvalid, http.StatusBadRequest}, {"image_pull_failed", startruntime.ErrorCodeImagePullFailed, http.StatusInternalServerError}, {"container_start_failed", startruntime.ErrorCodeContainerStartFailed, http.StatusInternalServerError}, {"conflict", startruntime.ErrorCodeConflict, http.StatusConflict}, {"service_unavailable", startruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable}, {"internal_error", startruntime.ErrorCodeInternal, http.StatusInternalServerError}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockStartService(ctrl) mock.EXPECT(). Handle(gomock.Any(), gomock.Any()). Return(startruntime.Result{ Outcome: operation.OutcomeFailure, ErrorCode: tc.errorCode, ErrorMessage: "synthetic " + tc.name, }, nil) rec := drive(t, Dependencies{StartRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/start", jsonHeaders(), strReader(`{"image_ref":"galaxy/game:v1.2.3"}`), ) body := decodeErrorBody(t, rec, tc.wantStatus) assert.Equal(t, tc.errorCode, body.Code) assert.Equal(t, "synthetic "+tc.name, body.Message) }) } } func TestStartHandlerRejectsUnknownJSONFields(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockStartService(ctrl) rec := drive(t, Dependencies{StartRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/start", jsonHeaders(), strReader(`{"image_ref":"x","extra":"y"}`), ) body := decodeErrorBody(t, rec, http.StatusBadRequest) assert.Equal(t, "invalid_request", body.Code) } func TestStartHandlerRejectsMalformedJSON(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockStartService(ctrl) rec := drive(t, Dependencies{StartRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/start", jsonHeaders(), strReader(`{"image_ref":`), ) body := decodeErrorBody(t, rec, http.StatusBadRequest) assert.Equal(t, "invalid_request", body.Code) } func TestStartHandlerHonoursXGalaxyCallerHeader(t *testing.T) { t.Parallel() cases := []struct { header string want operation.OpSource hdrLabel string }{ {"gm", operation.OpSourceGMRest, "gm"}, {"GM", operation.OpSourceGMRest, "uppercase gm"}, {"admin", operation.OpSourceAdminRest, "admin"}, {"unknown", operation.OpSourceAdminRest, "unknown value"}, {"", operation.OpSourceAdminRest, "missing header"}, } for _, tc := range cases { t.Run(tc.hdrLabel, func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockStartService(ctrl) record := sampleRunningRecord(t) mock.EXPECT(). Handle(gomock.Any(), gomock.AssignableToTypeOf(startruntime.Input{})). DoAndReturn(func(_ context.Context, in startruntime.Input) (startruntime.Result, error) { assert.Equal(t, tc.want, in.OpSource) return startruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil }) headers := jsonHeaders() if tc.header != "" { headers = withCaller(headers, tc.header) } rec := drive(t, Dependencies{StartRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/start", headers, strReader(`{"image_ref":"galaxy/game:v1.2.3"}`), ) require.Equal(t, http.StatusOK, rec.Code) }) } } func TestStartHandlerForwardsXRequestIDAsSourceRef(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockStartService(ctrl) mock.EXPECT(). Handle(gomock.Any(), gomock.AssignableToTypeOf(startruntime.Input{})). DoAndReturn(func(_ context.Context, in startruntime.Input) (startruntime.Result, error) { assert.Equal(t, "req-42", in.SourceRef) return startruntime.Result{Record: sampleRunningRecord(t), Outcome: operation.OutcomeSuccess}, nil }) headers := jsonHeaders() headers.Set("X-Request-ID", "req-42") rec := drive(t, Dependencies{StartRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/start", headers, strReader(`{"image_ref":"galaxy/game:v1.2.3"}`), ) require.Equal(t, http.StatusOK, rec.Code) } func TestStartHandlerReturnsInternalErrorWhenServiceErrors(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockStartService(ctrl) mock.EXPECT(). Handle(gomock.Any(), gomock.Any()). Return(startruntime.Result{}, assert.AnError) rec := drive(t, Dependencies{StartRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/start", jsonHeaders(), strReader(`{"image_ref":"galaxy/game:v1.2.3"}`), ) body := decodeErrorBody(t, rec, http.StatusInternalServerError) assert.Equal(t, "internal_error", body.Code) } func TestStartHandlerReturnsInternalErrorWhenServiceNotWired(t *testing.T) { t.Parallel() rec := drive(t, Dependencies{}, http.MethodPost, "/api/v1/internal/runtimes/game-test/start", jsonHeaders(), strReader(`{"image_ref":"galaxy/game:v1.2.3"}`), ) body := decodeErrorBody(t, rec, http.StatusInternalServerError) assert.Equal(t, "internal_error", body.Code) } // --- stop --- func TestStopHandlerReturnsRecordOnSuccess(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockStopService(ctrl) record := sampleStoppedRecord(t) mock.EXPECT(). Handle(gomock.Any(), gomock.AssignableToTypeOf(stopruntime.Input{})). DoAndReturn(func(_ context.Context, in stopruntime.Input) (stopruntime.Result, error) { assert.Equal(t, "game-test", in.GameID) assert.Equal(t, stopruntime.StopReasonAdminRequest, in.Reason) assert.Equal(t, operation.OpSourceAdminRest, in.OpSource) return stopruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil }) rec := drive(t, Dependencies{StopRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/stop", jsonHeaders(), strReader(`{"reason":"admin_request"}`), ) resp := decodeRecordResponse(t, rec) assert.Equal(t, "stopped", resp.Status) } func TestStopHandlerMapsServiceFailures(t *testing.T) { t.Parallel() cases := []struct { name string errorCode string wantStatus int }{ {"not_found", startruntime.ErrorCodeNotFound, http.StatusNotFound}, {"conflict", startruntime.ErrorCodeConflict, http.StatusConflict}, {"invalid_request", startruntime.ErrorCodeInvalidRequest, http.StatusBadRequest}, {"service_unavailable", startruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable}, {"internal_error", startruntime.ErrorCodeInternal, http.StatusInternalServerError}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockStopService(ctrl) mock.EXPECT().Handle(gomock.Any(), gomock.Any()).Return(stopruntime.Result{ Outcome: operation.OutcomeFailure, ErrorCode: tc.errorCode, ErrorMessage: tc.name, }, nil) rec := drive(t, Dependencies{StopRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/stop", jsonHeaders(), strReader(`{"reason":"admin_request"}`), ) body := decodeErrorBody(t, rec, tc.wantStatus) assert.Equal(t, tc.errorCode, body.Code) }) } } func TestStopHandlerRejectsUnknownJSONFields(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockStopService(ctrl) rec := drive(t, Dependencies{StopRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/stop", jsonHeaders(), strReader(`{"reason":"admin_request","extra":1}`), ) body := decodeErrorBody(t, rec, http.StatusBadRequest) assert.Equal(t, "invalid_request", body.Code) } func TestStopHandlerHonoursXGalaxyCallerHeader(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockStopService(ctrl) mock.EXPECT(). Handle(gomock.Any(), gomock.AssignableToTypeOf(stopruntime.Input{})). DoAndReturn(func(_ context.Context, in stopruntime.Input) (stopruntime.Result, error) { assert.Equal(t, operation.OpSourceGMRest, in.OpSource) return stopruntime.Result{Record: sampleStoppedRecord(t), Outcome: operation.OutcomeSuccess}, nil }) rec := drive(t, Dependencies{StopRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/stop", withCaller(jsonHeaders(), "gm"), strReader(`{"reason":"cancelled"}`), ) require.Equal(t, http.StatusOK, rec.Code) } func TestStopHandlerReturnsInternalErrorWhenServiceNotWired(t *testing.T) { t.Parallel() rec := drive(t, Dependencies{}, http.MethodPost, "/api/v1/internal/runtimes/game-test/stop", jsonHeaders(), strReader(`{"reason":"admin_request"}`), ) body := decodeErrorBody(t, rec, http.StatusInternalServerError) assert.Equal(t, "internal_error", body.Code) } // --- restart --- func TestRestartHandlerReturnsRecordOnSuccess(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockRestartService(ctrl) record := sampleRunningRecord(t) mock.EXPECT(). Handle(gomock.Any(), gomock.AssignableToTypeOf(restartruntime.Input{})). DoAndReturn(func(_ context.Context, in restartruntime.Input) (restartruntime.Result, error) { assert.Equal(t, "game-test", in.GameID) assert.Equal(t, operation.OpSourceAdminRest, in.OpSource) return restartruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil }) rec := drive(t, Dependencies{RestartRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/restart", nil, nil, ) resp := decodeRecordResponse(t, rec) assert.Equal(t, "running", resp.Status) } func TestRestartHandlerMapsServiceFailures(t *testing.T) { t.Parallel() cases := []struct { name string errorCode string wantStatus int }{ {"not_found", startruntime.ErrorCodeNotFound, http.StatusNotFound}, {"conflict", startruntime.ErrorCodeConflict, http.StatusConflict}, {"service_unavailable", startruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable}, {"internal_error", startruntime.ErrorCodeInternal, http.StatusInternalServerError}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockRestartService(ctrl) mock.EXPECT().Handle(gomock.Any(), gomock.Any()).Return(restartruntime.Result{ Outcome: operation.OutcomeFailure, ErrorCode: tc.errorCode, ErrorMessage: tc.name, }, nil) rec := drive(t, Dependencies{RestartRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/restart", nil, nil, ) body := decodeErrorBody(t, rec, tc.wantStatus) assert.Equal(t, tc.errorCode, body.Code) }) } } func TestRestartHandlerHonoursXGalaxyCallerHeader(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockRestartService(ctrl) mock.EXPECT(). Handle(gomock.Any(), gomock.AssignableToTypeOf(restartruntime.Input{})). DoAndReturn(func(_ context.Context, in restartruntime.Input) (restartruntime.Result, error) { assert.Equal(t, operation.OpSourceGMRest, in.OpSource) return restartruntime.Result{Record: sampleRunningRecord(t), Outcome: operation.OutcomeSuccess}, nil }) rec := drive(t, Dependencies{RestartRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/restart", withCaller(http.Header{}, "gm"), nil, ) require.Equal(t, http.StatusOK, rec.Code) } func TestRestartHandlerReturnsInternalErrorWhenServiceNotWired(t *testing.T) { t.Parallel() rec := drive(t, Dependencies{}, http.MethodPost, "/api/v1/internal/runtimes/game-test/restart", nil, nil, ) body := decodeErrorBody(t, rec, http.StatusInternalServerError) assert.Equal(t, "internal_error", body.Code) } // --- patch --- func TestPatchHandlerReturnsRecordOnSuccess(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockPatchService(ctrl) record := sampleRunningRecord(t) mock.EXPECT(). Handle(gomock.Any(), gomock.AssignableToTypeOf(patchruntime.Input{})). DoAndReturn(func(_ context.Context, in patchruntime.Input) (patchruntime.Result, error) { assert.Equal(t, "game-test", in.GameID) assert.Equal(t, "galaxy/game:v1.2.4", in.NewImageRef) return patchruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil }) rec := drive(t, Dependencies{PatchRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/patch", jsonHeaders(), strReader(`{"image_ref":"galaxy/game:v1.2.4"}`), ) resp := decodeRecordResponse(t, rec) assert.Equal(t, "running", resp.Status) } func TestPatchHandlerMapsServiceFailures(t *testing.T) { t.Parallel() cases := []struct { name string errorCode string wantStatus int }{ {"image_ref_not_semver", startruntime.ErrorCodeImageRefNotSemver, http.StatusBadRequest}, {"semver_patch_only", startruntime.ErrorCodeSemverPatchOnly, http.StatusConflict}, {"not_found", startruntime.ErrorCodeNotFound, http.StatusNotFound}, {"conflict", startruntime.ErrorCodeConflict, http.StatusConflict}, {"service_unavailable", startruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockPatchService(ctrl) mock.EXPECT().Handle(gomock.Any(), gomock.Any()).Return(patchruntime.Result{ Outcome: operation.OutcomeFailure, ErrorCode: tc.errorCode, ErrorMessage: tc.name, }, nil) rec := drive(t, Dependencies{PatchRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/patch", jsonHeaders(), strReader(`{"image_ref":"galaxy/game:v1.2.4"}`), ) body := decodeErrorBody(t, rec, tc.wantStatus) assert.Equal(t, tc.errorCode, body.Code) }) } } func TestPatchHandlerRejectsUnknownJSONFields(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockPatchService(ctrl) rec := drive(t, Dependencies{PatchRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/patch", jsonHeaders(), strReader(`{"image_ref":"x","unexpected":true}`), ) body := decodeErrorBody(t, rec, http.StatusBadRequest) assert.Equal(t, "invalid_request", body.Code) } func TestPatchHandlerHonoursXGalaxyCallerHeader(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockPatchService(ctrl) mock.EXPECT(). Handle(gomock.Any(), gomock.AssignableToTypeOf(patchruntime.Input{})). DoAndReturn(func(_ context.Context, in patchruntime.Input) (patchruntime.Result, error) { assert.Equal(t, operation.OpSourceGMRest, in.OpSource) return patchruntime.Result{Record: sampleRunningRecord(t), Outcome: operation.OutcomeSuccess}, nil }) rec := drive(t, Dependencies{PatchRuntime: mock}, http.MethodPost, "/api/v1/internal/runtimes/game-test/patch", withCaller(jsonHeaders(), "gm"), strReader(`{"image_ref":"galaxy/game:v1.2.4"}`), ) require.Equal(t, http.StatusOK, rec.Code) } func TestPatchHandlerReturnsInternalErrorWhenServiceNotWired(t *testing.T) { t.Parallel() rec := drive(t, Dependencies{}, http.MethodPost, "/api/v1/internal/runtimes/game-test/patch", jsonHeaders(), strReader(`{"image_ref":"galaxy/game:v1.2.4"}`), ) body := decodeErrorBody(t, rec, http.StatusInternalServerError) assert.Equal(t, "internal_error", body.Code) } // --- cleanup --- func TestCleanupHandlerReturnsRecordOnSuccess(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockCleanupService(ctrl) record := sampleStoppedRecord(t) record.Status = runtime.StatusRemoved record.CurrentContainerID = "" removed := record.LastOpAt record.RemovedAt = &removed mock.EXPECT(). Handle(gomock.Any(), gomock.AssignableToTypeOf(cleanupcontainer.Input{})). DoAndReturn(func(_ context.Context, in cleanupcontainer.Input) (cleanupcontainer.Result, error) { assert.Equal(t, "game-stopped", in.GameID) assert.Equal(t, operation.OpSourceAdminRest, in.OpSource) return cleanupcontainer.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil }) rec := drive(t, Dependencies{CleanupContainer: mock}, http.MethodDelete, "/api/v1/internal/runtimes/game-stopped/container", nil, nil, ) resp := decodeRecordResponse(t, rec) assert.Equal(t, "removed", resp.Status) assert.Nil(t, resp.CurrentContainerID, "container id must be null after cleanup") } func TestCleanupHandlerMapsServiceFailures(t *testing.T) { t.Parallel() cases := []struct { name string errorCode string wantStatus int }{ {"not_found", startruntime.ErrorCodeNotFound, http.StatusNotFound}, {"conflict", startruntime.ErrorCodeConflict, http.StatusConflict}, {"service_unavailable", startruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) mock := mocks.NewMockCleanupService(ctrl) mock.EXPECT().Handle(gomock.Any(), gomock.Any()).Return(cleanupcontainer.Result{ Outcome: operation.OutcomeFailure, ErrorCode: tc.errorCode, ErrorMessage: tc.name, }, nil) rec := drive(t, Dependencies{CleanupContainer: mock}, http.MethodDelete, "/api/v1/internal/runtimes/game-test/container", nil, nil, ) body := decodeErrorBody(t, rec, tc.wantStatus) assert.Equal(t, tc.errorCode, body.Code) }) } } func TestCleanupHandlerReturnsInternalErrorWhenServiceNotWired(t *testing.T) { t.Parallel() rec := drive(t, Dependencies{}, http.MethodDelete, "/api/v1/internal/runtimes/game-test/container", nil, nil, ) body := decodeErrorBody(t, rec, http.StatusInternalServerError) assert.Equal(t, "internal_error", body.Code) }