Files
galaxy-game/rtmanager/internal/api/internalhttp/handlers/handlers_mutation_test.go
T
2026-04-28 20:39:18 +02:00

611 lines
20 KiB
Go

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