423 lines
13 KiB
Go
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)
|
|
}
|