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

206 lines
6.3 KiB
Go

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