feat: gamemaster
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user