Files
galaxy-game/gamemaster/internal/api/internalhttp/handlers/handlers_test.go
T
2026-05-03 07:59:03 +02:00

423 lines
13 KiB
Go

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)
}