package handlers import ( "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "galaxy/gamemaster/internal/domain/engineversion" "galaxy/gamemaster/internal/domain/operation" "galaxy/gamemaster/internal/domain/runtime" engineversionsvc "galaxy/gamemaster/internal/service/engineversion" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestMapErrorCodeToStatusCoversEveryDocumentedCode(t *testing.T) { t.Parallel() cases := map[string]int{ errorCodeInvalidRequest: http.StatusBadRequest, errorCodeForbidden: http.StatusForbidden, errorCodeRuntimeNotFound: http.StatusNotFound, errorCodeEngineVersionNotFound: http.StatusNotFound, errorCodeConflict: http.StatusConflict, errorCodeRuntimeNotRunning: http.StatusConflict, errorCodeSemverPatchOnly: http.StatusConflict, errorCodeEngineVersionInUse: http.StatusConflict, errorCodeEngineUnreachable: http.StatusBadGateway, errorCodeEngineValidationError: http.StatusBadGateway, errorCodeEngineProtocolError: http.StatusBadGateway, errorCodeServiceUnavailable: http.StatusServiceUnavailable, errorCodeInternal: http.StatusInternalServerError, "unknown_code": http.StatusInternalServerError, } for code, expected := range cases { assert.Equalf(t, expected, mapErrorCodeToStatus(code), "code %q", code) } } func TestMapServiceErrorMapsEverySentinel(t *testing.T) { t.Parallel() cases := []struct { err error status int code string }{ {engineversionsvc.ErrInvalidRequest, http.StatusBadRequest, errorCodeInvalidRequest}, {engineversionsvc.ErrNotFound, http.StatusNotFound, errorCodeEngineVersionNotFound}, {engineversionsvc.ErrConflict, http.StatusConflict, errorCodeConflict}, {engineversionsvc.ErrInUse, http.StatusConflict, errorCodeEngineVersionInUse}, {engineversionsvc.ErrServiceUnavailable, http.StatusServiceUnavailable, errorCodeServiceUnavailable}, {errors.New("plain go error"), http.StatusInternalServerError, errorCodeInternal}, } for _, tc := range cases { status, code, _ := mapServiceError(tc.err) assert.Equalf(t, tc.status, status, "status for %v", tc.err) assert.Equalf(t, tc.code, code, "code for %v", tc.err) } } func TestResolveOpSourceMapsCallerHeader(t *testing.T) { t.Parallel() cases := map[string]operation.OpSource{ "": operation.OpSourceAdminRest, "unknown": operation.OpSourceAdminRest, "GATEWAY": operation.OpSourceGatewayPlayer, " lobby ": operation.OpSourceLobbyInternal, "admin": operation.OpSourceAdminRest, } for value, expected := range cases { request := httptest.NewRequest(http.MethodGet, "/", nil) if value != "" { request.Header.Set(callerHeader, value) } assert.Equalf(t, expected, resolveOpSource(request), "header %q", value) } } func TestRequestSourceRefReadsXRequestID(t *testing.T) { t.Parallel() request := httptest.NewRequest(http.MethodGet, "/", nil) assert.Empty(t, requestSourceRef(request)) request.Header.Set(requestIDHeader, " trace-123 ") assert.Equal(t, "trace-123", requestSourceRef(request)) } func TestDecodeStrictJSONRejectsUnknownFieldsAndTrailingContent(t *testing.T) { t.Parallel() type input struct { Field string `json:"field"` } var ok input require.NoError(t, decodeStrictJSON(strings.NewReader(`{"field":"value"}`), &ok)) assert.Equal(t, "value", ok.Field) var rejected input err := decodeStrictJSON(strings.NewReader(`{"field":"v","extra":1}`), &rejected) require.Error(t, err) var trailing input err = decodeStrictJSON(strings.NewReader(`{"field":"v"}{"another":true}`), &trailing) require.Error(t, err) } func TestReadRawJSONBodyValidatesPayload(t *testing.T) { t.Parallel() body, err := readRawJSONBody(strings.NewReader(`{"commands":[]}`)) require.NoError(t, err) assert.JSONEq(t, `{"commands":[]}`, string(body)) _, err = readRawJSONBody(strings.NewReader("")) require.Error(t, err) _, err = readRawJSONBody(strings.NewReader("not json")) require.Error(t, err) } func TestEncodeRuntimeRecordIncludesEveryRequiredField(t *testing.T) { t.Parallel() moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC) next := moment.Add(time.Minute) record := runtime.RuntimeRecord{ GameID: "game-1", Status: runtime.StatusRunning, EngineEndpoint: "http://example:8080", CurrentImageRef: "galaxy/game:1.2.3", CurrentEngineVersion: "1.2.3", TurnSchedule: "0 18 * * *", CurrentTurn: 7, NextGenerationAt: &next, SkipNextTick: true, EngineHealth: "healthy", CreatedAt: moment, UpdatedAt: moment, StartedAt: &moment, } encoded := encodeRuntimeRecord(record) assert.Equal(t, "game-1", encoded.GameID) assert.Equal(t, "running", encoded.RuntimeStatus) assert.Equal(t, moment.UnixMilli(), encoded.CreatedAt) assert.Equal(t, next.UnixMilli(), encoded.NextGenerationAt) require.NotNil(t, encoded.StartedAt) assert.Equal(t, moment.UnixMilli(), *encoded.StartedAt) assert.Nil(t, encoded.StoppedAt) assert.Nil(t, encoded.FinishedAt) } func TestEncodeRuntimeRecordZerosNextGenerationWhenNil(t *testing.T) { t.Parallel() moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC) record := runtime.RuntimeRecord{ GameID: "game-1", Status: runtime.StatusStarting, EngineEndpoint: "http://example:8080", CurrentImageRef: "galaxy/game:1.2.3", CurrentEngineVersion: "1.2.3", TurnSchedule: "0 18 * * *", CreatedAt: moment, UpdatedAt: moment, } encoded := encodeRuntimeRecord(record) assert.Equal(t, int64(0), encoded.NextGenerationAt) assert.Nil(t, encoded.StartedAt) } func TestEncodeEngineVersionDefaultsEmptyOptionsToObject(t *testing.T) { t.Parallel() moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC) encoded := encodeEngineVersion(engineversion.EngineVersion{ Version: "1.2.3", ImageRef: "galaxy/game:1.2.3", Status: engineversion.StatusActive, CreatedAt: moment, UpdatedAt: moment, }) assert.Equal(t, "{}", string(encoded.Options)) assert.Equal(t, "active", encoded.Status) } func TestEncodeRuntimeListAlwaysReturnsNonNilSlice(t *testing.T) { t.Parallel() resp := encodeRuntimeList(nil) require.NotNil(t, resp.Runtimes) assert.Empty(t, resp.Runtimes) }