package handlers_test import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "galaxy/gamemaster/internal/api/internalhttp/handlers" "galaxy/gamemaster/internal/api/internalhttp/handlers/mocks" "galaxy/gamemaster/internal/domain/engineversion" "galaxy/gamemaster/internal/domain/operation" "galaxy/gamemaster/internal/domain/runtime" "galaxy/gamemaster/internal/service/adminstop" "galaxy/gamemaster/internal/service/commandexecute" engineversionsvc "galaxy/gamemaster/internal/service/engineversion" "galaxy/gamemaster/internal/service/livenessreply" "galaxy/gamemaster/internal/service/registerruntime" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) // driveHandler builds a fresh ServeMux + handler set bound to deps, // fires one request, and returns the recorder. func driveHandler(t *testing.T, deps handlers.Dependencies, method, path string, body io.Reader, headers map[string]string) *httptest.ResponseRecorder { t.Helper() mux := http.NewServeMux() handlers.Register(mux, deps) request := httptest.NewRequest(method, path, body) for key, value := range headers { request.Header.Set(key, value) } if body != nil { request.Header.Set("Content-Type", "application/json") } recorder := httptest.NewRecorder() mux.ServeHTTP(recorder, request) return recorder } func decodeErrorBody(t *testing.T, recorder *httptest.ResponseRecorder) (string, string) { t.Helper() var body struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) return body.Error.Code, body.Error.Message } func TestRegisterRuntimeHandlerHappyPath(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) moment := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) record := runtime.RuntimeRecord{ GameID: "game-1", Status: runtime.StatusRunning, EngineEndpoint: "http://engine:8080", CurrentImageRef: "galaxy/game:1.2.3", CurrentEngineVersion: "1.2.3", TurnSchedule: "0 18 * * *", CreatedAt: moment, UpdatedAt: moment, } registerSvc := mocks.NewMockRegisterRuntimeService(ctrl) registerSvc.EXPECT(). Handle(gomock.Any(), gomock.AssignableToTypeOf(registerruntime.Input{})). DoAndReturn(func(_ context.Context, in registerruntime.Input) (registerruntime.Result, error) { assert.Equal(t, "game-1", in.GameID) assert.Equal(t, "http://engine:8080", in.EngineEndpoint) assert.Equal(t, operation.OpSourceLobbyInternal, in.OpSource) require.Len(t, in.Members, 1) return registerruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil }) body := strings.NewReader(`{ "engine_endpoint": "http://engine:8080", "members": [{"user_id":"u1","race_name":"Aelinari"}], "target_engine_version": "1.2.3", "turn_schedule": "0 18 * * *" }`) recorder := driveHandler(t, handlers.Dependencies{RegisterRuntime: registerSvc}, http.MethodPost, "/api/v1/internal/games/game-1/register-runtime", body, map[string]string{"X-Galaxy-Caller": "lobby"}, ) require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) assert.Contains(t, recorder.Body.String(), `"game_id":"game-1"`) } func TestRegisterRuntimeHandlerRejectsUnknownFields(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) registerSvc := mocks.NewMockRegisterRuntimeService(ctrl) // no expectations — handler must short-circuit before calling. body := strings.NewReader(`{"engine_endpoint":"http://e","extra":1}`) recorder := driveHandler(t, handlers.Dependencies{RegisterRuntime: registerSvc}, http.MethodPost, "/api/v1/internal/games/game-1/register-runtime", body, nil, ) require.Equal(t, http.StatusBadRequest, recorder.Code) code, _ := decodeErrorBody(t, recorder) assert.Equal(t, "invalid_request", code) } func TestRegisterRuntimeHandlerWiresFailureCodes(t *testing.T) { t.Parallel() cases := []struct { name string errCode string wantStatus int }{ {"invalid_request", registerruntime.ErrorCodeInvalidRequest, http.StatusBadRequest}, {"conflict", registerruntime.ErrorCodeConflict, http.StatusConflict}, {"engine_version_not_found", registerruntime.ErrorCodeEngineVersionNotFound, http.StatusNotFound}, {"engine_unreachable", registerruntime.ErrorCodeEngineUnreachable, http.StatusBadGateway}, {"service_unavailable", registerruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable}, {"internal_error", registerruntime.ErrorCodeInternal, http.StatusInternalServerError}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) svc := mocks.NewMockRegisterRuntimeService(ctrl) svc.EXPECT(). Handle(gomock.Any(), gomock.Any()). Return(registerruntime.Result{ Outcome: operation.OutcomeFailure, ErrorCode: tc.errCode, ErrorMessage: tc.errCode + " details", }, nil) body := strings.NewReader(`{ "engine_endpoint": "http://e", "members":[{"user_id":"u1","race_name":"r"}], "target_engine_version":"1.0.0", "turn_schedule":"* * * * *" }`) recorder := driveHandler(t, handlers.Dependencies{RegisterRuntime: svc}, http.MethodPost, "/api/v1/internal/games/game-1/register-runtime", body, nil, ) assert.Equal(t, tc.wantStatus, recorder.Code) code, _ := decodeErrorBody(t, recorder) assert.Equal(t, tc.errCode, code) }) } } func TestRegisterRuntimeHandlerNilServiceReturns500(t *testing.T) { t.Parallel() body := strings.NewReader(`{"engine_endpoint":"http://e"}`) recorder := driveHandler(t, handlers.Dependencies{}, http.MethodPost, "/api/v1/internal/games/game-1/register-runtime", body, nil, ) require.Equal(t, http.StatusInternalServerError, recorder.Code) code, _ := decodeErrorBody(t, recorder) assert.Equal(t, "internal_error", code) } func TestStopRuntimeHandlerForwardsReason(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) moment := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) record := runtime.RuntimeRecord{ GameID: "game-1", Status: runtime.StatusStopped, EngineEndpoint: "http://engine:8080", CurrentImageRef: "galaxy/game:1.2.3", CurrentEngineVersion: "1.2.3", TurnSchedule: "0 18 * * *", CreatedAt: moment, UpdatedAt: moment, } stopSvc := mocks.NewMockStopRuntimeService(ctrl) stopSvc.EXPECT(). Handle(gomock.Any(), gomock.AssignableToTypeOf(adminstop.Input{})). DoAndReturn(func(_ context.Context, in adminstop.Input) (adminstop.Result, error) { assert.Equal(t, "admin_request", in.Reason) return adminstop.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil }) body := strings.NewReader(`{"reason":"admin_request"}`) recorder := driveHandler(t, handlers.Dependencies{StopRuntime: stopSvc}, http.MethodPost, "/api/v1/internal/runtimes/game-1/stop", body, nil, ) require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) } func TestGetEngineVersionHandlerMapsNotFound(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) svc := mocks.NewMockEngineVersionService(ctrl) svc.EXPECT(). Get(gomock.Any(), "9.9.9"). Return(engineversion.EngineVersion{}, engineversionsvc.ErrNotFound) recorder := driveHandler(t, handlers.Dependencies{EngineVersions: svc}, http.MethodGet, "/api/v1/internal/engine-versions/9.9.9", nil, nil, ) assert.Equal(t, http.StatusNotFound, recorder.Code) code, _ := decodeErrorBody(t, recorder) assert.Equal(t, "engine_version_not_found", code) } func TestListEngineVersionsHandlerRejectsUnknownStatus(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) svc := mocks.NewMockEngineVersionService(ctrl) // no expectations — short-circuits. recorder := driveHandler(t, handlers.Dependencies{EngineVersions: svc}, http.MethodGet, "/api/v1/internal/engine-versions?status=mystery", nil, nil, ) assert.Equal(t, http.StatusBadRequest, recorder.Code) code, _ := decodeErrorBody(t, recorder) assert.Equal(t, "invalid_request", code) } func TestDeprecateEngineVersionReturns204(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) svc := mocks.NewMockEngineVersionService(ctrl) svc.EXPECT(). Deprecate(gomock.Any(), gomock.AssignableToTypeOf(engineversionsvc.DeprecateInput{})). Return(nil) recorder := driveHandler(t, handlers.Dependencies{EngineVersions: svc}, http.MethodDelete, "/api/v1/internal/engine-versions/1.0.0", nil, nil, ) assert.Equal(t, http.StatusNoContent, recorder.Code) assert.Empty(t, recorder.Body.String()) } func TestDeprecateEngineVersionDoesNotReportInUse(t *testing.T) { t.Parallel() // D2: the DELETE endpoint flips status; the handler does not call // Service.Delete and therefore can never produce // `engine_version_in_use`. Deprecate's own error vocabulary is // limited to invalid_request / not_found / service_unavailable. ctrl := gomock.NewController(t) svc := mocks.NewMockEngineVersionService(ctrl) svc.EXPECT(). Deprecate(gomock.Any(), gomock.Any()). Return(engineversionsvc.ErrNotFound) recorder := driveHandler(t, handlers.Dependencies{EngineVersions: svc}, http.MethodDelete, "/api/v1/internal/engine-versions/9.9.9", nil, nil, ) assert.Equal(t, http.StatusNotFound, recorder.Code) } func TestExecuteCommandsRequiresUserIDHeader(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) svc := mocks.NewMockCommandExecuteService(ctrl) // short-circuit before service is touched. recorder := driveHandler(t, handlers.Dependencies{CommandExecute: svc}, http.MethodPost, "/api/v1/internal/games/game-1/commands", strings.NewReader(`{"commands":[]}`), nil, ) assert.Equal(t, http.StatusBadRequest, recorder.Code) code, msg := decodeErrorBody(t, recorder) assert.Equal(t, "invalid_request", code) assert.Contains(t, msg, "X-User-ID") } func TestExecuteCommandsRejectsInvalidJSONBody(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) svc := mocks.NewMockCommandExecuteService(ctrl) recorder := driveHandler(t, handlers.Dependencies{CommandExecute: svc}, http.MethodPost, "/api/v1/internal/games/game-1/commands", strings.NewReader("not json"), map[string]string{"X-User-ID": "u1"}, ) assert.Equal(t, http.StatusBadRequest, recorder.Code) code, _ := decodeErrorBody(t, recorder) assert.Equal(t, "invalid_request", code) } func TestExecuteCommandsForwardsRawResponseOnSuccess(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) svc := mocks.NewMockCommandExecuteService(ctrl) svc.EXPECT(). Handle(gomock.Any(), gomock.AssignableToTypeOf(commandexecute.Input{})). DoAndReturn(func(_ context.Context, in commandexecute.Input) (commandexecute.Result, error) { assert.Equal(t, "game-1", in.GameID) assert.Equal(t, "u1", in.UserID) assert.JSONEq(t, `{"commands":[{"name":"build"}]}`, string(in.Payload)) return commandexecute.Result{ Outcome: operation.OutcomeSuccess, RawResponse: []byte(`{"results":[{"ok":true}]}`), }, nil }) recorder := driveHandler(t, handlers.Dependencies{CommandExecute: svc}, http.MethodPost, "/api/v1/internal/games/game-1/commands", strings.NewReader(`{"commands":[{"name":"build"}]}`), map[string]string{"X-User-ID": "u1"}, ) require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) assert.JSONEq(t, `{"results":[{"ok":true}]}`, recorder.Body.String()) } func TestInvalidateMembershipsAlwaysReturns204(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) cache := mocks.NewMockMembershipInvalidator(ctrl) cache.EXPECT().Invalidate("game-7").Times(1) recorder := driveHandler(t, handlers.Dependencies{InvalidateMemberships: cache}, http.MethodPost, "/api/v1/internal/games/game-7/memberships/invalidate", nil, nil, ) assert.Equal(t, http.StatusNoContent, recorder.Code) } func TestGameLivenessHandlerMapsServiceUnavailable(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) svc := mocks.NewMockLivenessService(ctrl) svc.EXPECT(). Handle(gomock.Any(), livenessreply.Input{GameID: "game-1"}). Return(livenessreply.Result{}, errors.New(livenessreply.ErrorCodeServiceUnavailable+": store ping")) recorder := driveHandler(t, handlers.Dependencies{GameLiveness: svc}, http.MethodGet, "/api/v1/internal/games/game-1/liveness", nil, nil, ) assert.Equal(t, http.StatusServiceUnavailable, recorder.Code) code, _ := decodeErrorBody(t, recorder) assert.Equal(t, "service_unavailable", code) } func TestGetReportRejectsNegativeTurn(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) svc := mocks.NewMockReportGetService(ctrl) // short-circuits. recorder := driveHandler(t, handlers.Dependencies{GetReport: svc}, http.MethodGet, "/api/v1/internal/games/game-1/reports/-3", nil, map[string]string{"X-User-ID": "u1"}, ) assert.Equal(t, http.StatusBadRequest, recorder.Code) code, _ := decodeErrorBody(t, recorder) assert.Equal(t, "invalid_request", code) }