feat: gamemaster
This commit is contained in:
@@ -0,0 +1,718 @@
|
||||
package gamemaster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var expectedInternalOperationIDs = []string{
|
||||
"internalHealthz",
|
||||
"internalReadyz",
|
||||
"internalRegisterRuntime",
|
||||
"internalGetRuntime",
|
||||
"internalListRuntimes",
|
||||
"internalForceNextTurn",
|
||||
"internalStopRuntime",
|
||||
"internalPatchRuntime",
|
||||
"internalBanishRace",
|
||||
"internalInvalidateMemberships",
|
||||
"internalGameLiveness",
|
||||
"internalListEngineVersions",
|
||||
"internalCreateEngineVersion",
|
||||
"internalGetEngineVersion",
|
||||
"internalUpdateEngineVersion",
|
||||
"internalDeprecateEngineVersion",
|
||||
"internalResolveEngineVersionImageRef",
|
||||
"internalExecuteCommands",
|
||||
"internalPutOrders",
|
||||
"internalGetReport",
|
||||
}
|
||||
|
||||
// gmOwnedClosedSchemas lists every component schema for which Game Master
|
||||
// owns the wire shape and therefore must reject unknown fields. The list
|
||||
// is curated; the matching test fails if any schema in this list opens up.
|
||||
var gmOwnedClosedSchemas = []string{
|
||||
"ProbeResponse",
|
||||
"LivenessResponse",
|
||||
"ImageRefResponse",
|
||||
"RegisterRuntimeMember",
|
||||
"RegisterRuntimeRequest",
|
||||
"RuntimeRecord",
|
||||
"RuntimeListResponse",
|
||||
"StopRuntimeRequest",
|
||||
"PatchRuntimeRequest",
|
||||
"EngineVersion",
|
||||
"EngineVersionListResponse",
|
||||
"CreateEngineVersionRequest",
|
||||
"UpdateEngineVersionRequest",
|
||||
"ErrorResponse",
|
||||
"ErrorBody",
|
||||
}
|
||||
|
||||
// engineOwnedPassthroughSchemas lists every component schema that forwards
|
||||
// engine-owned payloads verbatim and therefore deliberately uses
|
||||
// `additionalProperties: true`. The matching test fails if any schema in
|
||||
// this list closes up.
|
||||
var engineOwnedPassthroughSchemas = []string{
|
||||
"ExecuteCommandsRequest",
|
||||
"ExecuteCommandsResponse",
|
||||
"PutOrdersRequest",
|
||||
"PutOrdersResponse",
|
||||
"ReportResponse",
|
||||
}
|
||||
|
||||
// TestInternalOpenAPISpecValidates loads internal-openapi.yaml and verifies
|
||||
// it is a syntactically valid OpenAPI 3.0 document.
|
||||
func TestInternalOpenAPISpecValidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
loadInternalSpec(t)
|
||||
}
|
||||
|
||||
// TestInternalSpecHasAllOperationIDs verifies that the spec declares every
|
||||
// operationId required by gamemaster/PLAN.md Stage 06 and no extras. Adding
|
||||
// a new operation requires updating expectedInternalOperationIDs in the same
|
||||
// patch as the spec change.
|
||||
func TestInternalSpecHasAllOperationIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
|
||||
got := make([]string, 0, len(expectedInternalOperationIDs))
|
||||
for _, pathItem := range doc.Paths.Map() {
|
||||
for _, op := range pathItem.Operations() {
|
||||
require.NotEmpty(t, op.OperationID, "every operation must declare a non-empty operationId")
|
||||
got = append(got, op.OperationID)
|
||||
}
|
||||
}
|
||||
|
||||
require.ElementsMatch(t, expectedInternalOperationIDs, got)
|
||||
}
|
||||
|
||||
// TestInternalSpecRegisterRuntime verifies the register-runtime contract
|
||||
// used by Game Lobby after a successful container start.
|
||||
func TestInternalSpecRegisterRuntime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
op := getOperation(t, doc, "/api/v1/internal/games/{game_id}/register-runtime", http.MethodPost)
|
||||
|
||||
require.Equal(t, "internalRegisterRuntime", op.OperationID)
|
||||
assertOperationParameterRefs(t, op, "#/components/parameters/GameIDPath")
|
||||
assertSchemaRef(t, requestSchemaRef(t, op), "#/components/schemas/RegisterRuntimeRequest", "internalRegisterRuntime request")
|
||||
assertSchemaRef(t, responseSchemaRef(t, op, http.StatusOK), "#/components/schemas/RuntimeRecord", "internalRegisterRuntime 200")
|
||||
assertResponseRef(t, op, http.StatusBadRequest, "#/components/responses/InvalidRequestError")
|
||||
assertResponseRef(t, op, http.StatusNotFound, "#/components/responses/EngineVersionNotFoundError")
|
||||
assertResponseRef(t, op, http.StatusConflict, "#/components/responses/ConflictError")
|
||||
assertResponseRef(t, op, http.StatusBadGateway, "#/components/responses/EngineUnreachableError")
|
||||
assertResponseRef(t, op, http.StatusInternalServerError, "#/components/responses/InternalError")
|
||||
assertResponseRef(t, op, http.StatusServiceUnavailable, "#/components/responses/ServiceUnavailableError")
|
||||
|
||||
req := componentSchemaRef(t, doc, "RegisterRuntimeRequest")
|
||||
assertRequiredFields(t, req,
|
||||
"engine_endpoint", "members", "target_engine_version", "turn_schedule")
|
||||
|
||||
member := componentSchemaRef(t, doc, "RegisterRuntimeMember")
|
||||
assertRequiredFields(t, member, "user_id", "race_name")
|
||||
}
|
||||
|
||||
// TestInternalSpecGetRuntime verifies the runtime read contract.
|
||||
func TestInternalSpecGetRuntime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
op := getOperation(t, doc, "/api/v1/internal/runtimes/{game_id}", http.MethodGet)
|
||||
|
||||
require.Equal(t, "internalGetRuntime", op.OperationID)
|
||||
assertOperationParameterRefs(t, op, "#/components/parameters/GameIDPath")
|
||||
assertSchemaRef(t, responseSchemaRef(t, op, http.StatusOK), "#/components/schemas/RuntimeRecord", "internalGetRuntime 200")
|
||||
assertResponseRef(t, op, http.StatusNotFound, "#/components/responses/NotFoundError")
|
||||
assertResponseRef(t, op, http.StatusInternalServerError, "#/components/responses/InternalError")
|
||||
}
|
||||
|
||||
// TestInternalSpecListRuntimes verifies the list contract and the optional
|
||||
// status query parameter.
|
||||
func TestInternalSpecListRuntimes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
op := getOperation(t, doc, "/api/v1/internal/runtimes", http.MethodGet)
|
||||
|
||||
require.Equal(t, "internalListRuntimes", op.OperationID)
|
||||
assertOperationParameterRefs(t, op, "#/components/parameters/RuntimeStatusQuery")
|
||||
assertSchemaRef(t, responseSchemaRef(t, op, http.StatusOK), "#/components/schemas/RuntimeListResponse", "internalListRuntimes 200")
|
||||
assertResponseRef(t, op, http.StatusBadRequest, "#/components/responses/InvalidRequestError")
|
||||
assertResponseRef(t, op, http.StatusInternalServerError, "#/components/responses/InternalError")
|
||||
|
||||
param := componentParameterRef(t, doc, "RuntimeStatusQuery")
|
||||
require.Equal(t, "status", param.Value.Name)
|
||||
require.Equal(t, "query", param.Value.In)
|
||||
require.False(t, param.Value.Required, "status filter must be optional")
|
||||
require.Equal(t, "#/components/schemas/RuntimeStatus", param.Value.Schema.Ref,
|
||||
"status filter schema must reference RuntimeStatus")
|
||||
}
|
||||
|
||||
// TestInternalSpecForceNextTurn verifies the force-next-turn admin contract.
|
||||
func TestInternalSpecForceNextTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
op := getOperation(t, doc, "/api/v1/internal/runtimes/{game_id}/force-next-turn", http.MethodPost)
|
||||
|
||||
require.Equal(t, "internalForceNextTurn", op.OperationID)
|
||||
require.Nil(t, op.RequestBody, "internalForceNextTurn must have no request body")
|
||||
assertOperationParameterRefs(t, op, "#/components/parameters/GameIDPath")
|
||||
assertSchemaRef(t, responseSchemaRef(t, op, http.StatusOK), "#/components/schemas/RuntimeRecord", "internalForceNextTurn 200")
|
||||
assertResponseRef(t, op, http.StatusNotFound, "#/components/responses/NotFoundError")
|
||||
assertResponseRef(t, op, http.StatusConflict, "#/components/responses/ConflictError")
|
||||
assertResponseRef(t, op, http.StatusBadGateway, "#/components/responses/EngineUnreachableError")
|
||||
assertResponseRef(t, op, http.StatusInternalServerError, "#/components/responses/InternalError")
|
||||
}
|
||||
|
||||
// TestInternalSpecStopRuntime verifies the stop admin contract.
|
||||
func TestInternalSpecStopRuntime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
op := getOperation(t, doc, "/api/v1/internal/runtimes/{game_id}/stop", http.MethodPost)
|
||||
|
||||
require.Equal(t, "internalStopRuntime", op.OperationID)
|
||||
assertOperationParameterRefs(t, op, "#/components/parameters/GameIDPath")
|
||||
assertSchemaRef(t, requestSchemaRef(t, op), "#/components/schemas/StopRuntimeRequest", "internalStopRuntime request")
|
||||
assertSchemaRef(t, responseSchemaRef(t, op, http.StatusOK), "#/components/schemas/RuntimeRecord", "internalStopRuntime 200")
|
||||
assertResponseRef(t, op, http.StatusBadRequest, "#/components/responses/InvalidRequestError")
|
||||
assertResponseRef(t, op, http.StatusNotFound, "#/components/responses/NotFoundError")
|
||||
assertResponseRef(t, op, http.StatusInternalServerError, "#/components/responses/InternalError")
|
||||
assertResponseRef(t, op, http.StatusServiceUnavailable, "#/components/responses/ServiceUnavailableError")
|
||||
|
||||
req := componentSchemaRef(t, doc, "StopRuntimeRequest")
|
||||
assertRequiredFields(t, req, "reason")
|
||||
reason := req.Value.Properties["reason"]
|
||||
require.NotNil(t, reason)
|
||||
require.Equal(t, "#/components/schemas/StopReason", reason.Ref,
|
||||
"StopRuntimeRequest.reason must reference StopReason")
|
||||
}
|
||||
|
||||
// TestInternalSpecPatchRuntime verifies the patch admin contract.
|
||||
func TestInternalSpecPatchRuntime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
op := getOperation(t, doc, "/api/v1/internal/runtimes/{game_id}/patch", http.MethodPost)
|
||||
|
||||
require.Equal(t, "internalPatchRuntime", op.OperationID)
|
||||
assertOperationParameterRefs(t, op, "#/components/parameters/GameIDPath")
|
||||
assertSchemaRef(t, requestSchemaRef(t, op), "#/components/schemas/PatchRuntimeRequest", "internalPatchRuntime request")
|
||||
assertSchemaRef(t, responseSchemaRef(t, op, http.StatusOK), "#/components/schemas/RuntimeRecord", "internalPatchRuntime 200")
|
||||
assertResponseRef(t, op, http.StatusBadRequest, "#/components/responses/InvalidRequestError")
|
||||
assertResponseRef(t, op, http.StatusNotFound, "#/components/responses/NotFoundError")
|
||||
assertResponseRef(t, op, http.StatusConflict, "#/components/responses/ConflictError")
|
||||
assertResponseRef(t, op, http.StatusInternalServerError, "#/components/responses/InternalError")
|
||||
assertResponseRef(t, op, http.StatusServiceUnavailable, "#/components/responses/ServiceUnavailableError")
|
||||
|
||||
req := componentSchemaRef(t, doc, "PatchRuntimeRequest")
|
||||
assertRequiredFields(t, req, "version")
|
||||
}
|
||||
|
||||
// TestInternalSpecBanishRace verifies the engine-side race banish contract
|
||||
// called by Game Lobby after a permanent membership removal.
|
||||
func TestInternalSpecBanishRace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
op := getOperation(t, doc, "/api/v1/internal/games/{game_id}/race/{race_name}/banish", http.MethodPost)
|
||||
|
||||
require.Equal(t, "internalBanishRace", op.OperationID)
|
||||
require.Nil(t, op.RequestBody, "internalBanishRace must have no request body; the race_name is on the path")
|
||||
assertOperationParameterRefs(t, op,
|
||||
"#/components/parameters/GameIDPath",
|
||||
"#/components/parameters/RaceNamePath",
|
||||
)
|
||||
|
||||
assertNoContentResponse(t, op, http.StatusNoContent)
|
||||
assertResponseRef(t, op, http.StatusNotFound, "#/components/responses/NotFoundError")
|
||||
assertResponseRef(t, op, http.StatusBadGateway, "#/components/responses/EngineUnreachableError")
|
||||
assertResponseRef(t, op, http.StatusInternalServerError, "#/components/responses/InternalError")
|
||||
}
|
||||
|
||||
// TestInternalSpecInvalidateMemberships verifies the membership cache hook
|
||||
// called by Game Lobby on every roster mutation.
|
||||
func TestInternalSpecInvalidateMemberships(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
op := getOperation(t, doc, "/api/v1/internal/games/{game_id}/memberships/invalidate", http.MethodPost)
|
||||
|
||||
require.Equal(t, "internalInvalidateMemberships", op.OperationID)
|
||||
require.Nil(t, op.RequestBody)
|
||||
assertOperationParameterRefs(t, op, "#/components/parameters/GameIDPath")
|
||||
|
||||
assertNoContentResponse(t, op, http.StatusNoContent)
|
||||
assertResponseRef(t, op, http.StatusNotFound, "#/components/responses/NotFoundError")
|
||||
assertResponseRef(t, op, http.StatusInternalServerError, "#/components/responses/InternalError")
|
||||
}
|
||||
|
||||
// TestInternalSpecGameLiveness verifies the liveness reply used by Lobby's
|
||||
// resume flow.
|
||||
func TestInternalSpecGameLiveness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
op := getOperation(t, doc, "/api/v1/internal/games/{game_id}/liveness", http.MethodGet)
|
||||
|
||||
require.Equal(t, "internalGameLiveness", op.OperationID)
|
||||
assertOperationParameterRefs(t, op, "#/components/parameters/GameIDPath")
|
||||
assertSchemaRef(t, responseSchemaRef(t, op, http.StatusOK), "#/components/schemas/LivenessResponse", "internalGameLiveness 200")
|
||||
assertResponseRef(t, op, http.StatusInternalServerError, "#/components/responses/InternalError")
|
||||
|
||||
resp := componentSchemaRef(t, doc, "LivenessResponse")
|
||||
assertRequiredFields(t, resp, "ready", "status")
|
||||
status := resp.Value.Properties["status"]
|
||||
require.NotNil(t, status)
|
||||
require.Equal(t, "#/components/schemas/RuntimeStatus", status.Ref,
|
||||
"LivenessResponse.status must reference RuntimeStatus")
|
||||
}
|
||||
|
||||
// TestInternalSpecEngineVersionsCRUD verifies all six engine version
|
||||
// registry operations: list, create, get, update, deprecate, resolve.
|
||||
func TestInternalSpecEngineVersionsCRUD(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
|
||||
listOp := getOperation(t, doc, "/api/v1/internal/engine-versions", http.MethodGet)
|
||||
require.Equal(t, "internalListEngineVersions", listOp.OperationID)
|
||||
assertOperationParameterRefs(t, listOp, "#/components/parameters/EngineVersionStatusQuery")
|
||||
assertSchemaRef(t, responseSchemaRef(t, listOp, http.StatusOK), "#/components/schemas/EngineVersionListResponse", "internalListEngineVersions 200")
|
||||
|
||||
createOp := getOperation(t, doc, "/api/v1/internal/engine-versions", http.MethodPost)
|
||||
require.Equal(t, "internalCreateEngineVersion", createOp.OperationID)
|
||||
assertSchemaRef(t, requestSchemaRef(t, createOp), "#/components/schemas/CreateEngineVersionRequest", "create request")
|
||||
assertSchemaRef(t, responseSchemaRef(t, createOp, http.StatusCreated), "#/components/schemas/EngineVersion", "internalCreateEngineVersion 201")
|
||||
assertResponseRef(t, createOp, http.StatusConflict, "#/components/responses/ConflictError")
|
||||
|
||||
getOp := getOperation(t, doc, "/api/v1/internal/engine-versions/{version}", http.MethodGet)
|
||||
require.Equal(t, "internalGetEngineVersion", getOp.OperationID)
|
||||
assertOperationParameterRefs(t, getOp, "#/components/parameters/VersionPath")
|
||||
assertSchemaRef(t, responseSchemaRef(t, getOp, http.StatusOK), "#/components/schemas/EngineVersion", "internalGetEngineVersion 200")
|
||||
assertResponseRef(t, getOp, http.StatusNotFound, "#/components/responses/NotFoundError")
|
||||
|
||||
updateOp := getOperation(t, doc, "/api/v1/internal/engine-versions/{version}", http.MethodPatch)
|
||||
require.Equal(t, "internalUpdateEngineVersion", updateOp.OperationID)
|
||||
assertOperationParameterRefs(t, updateOp, "#/components/parameters/VersionPath")
|
||||
assertSchemaRef(t, requestSchemaRef(t, updateOp), "#/components/schemas/UpdateEngineVersionRequest", "update request")
|
||||
assertSchemaRef(t, responseSchemaRef(t, updateOp, http.StatusOK), "#/components/schemas/EngineVersion", "internalUpdateEngineVersion 200")
|
||||
|
||||
deprecateOp := getOperation(t, doc, "/api/v1/internal/engine-versions/{version}", http.MethodDelete)
|
||||
require.Equal(t, "internalDeprecateEngineVersion", deprecateOp.OperationID)
|
||||
assertNoContentResponse(t, deprecateOp, http.StatusNoContent)
|
||||
assertResponseRef(t, deprecateOp, http.StatusConflict, "#/components/responses/EngineVersionInUseError")
|
||||
|
||||
resolveOp := getOperation(t, doc, "/api/v1/internal/engine-versions/{version}/image-ref", http.MethodGet)
|
||||
require.Equal(t, "internalResolveEngineVersionImageRef", resolveOp.OperationID)
|
||||
assertOperationParameterRefs(t, resolveOp, "#/components/parameters/VersionPath")
|
||||
assertSchemaRef(t, responseSchemaRef(t, resolveOp, http.StatusOK), "#/components/schemas/ImageRefResponse", "internalResolveEngineVersionImageRef 200")
|
||||
assertResponseRef(t, resolveOp, http.StatusNotFound, "#/components/responses/EngineVersionNotFoundError")
|
||||
|
||||
createReq := componentSchemaRef(t, doc, "CreateEngineVersionRequest")
|
||||
assertRequiredFields(t, createReq, "version", "image_ref")
|
||||
}
|
||||
|
||||
// TestInternalSpecHotPathContracts verifies the three Edge Gateway hot-path
|
||||
// operations and their pass-through schema treatment.
|
||||
func TestInternalSpecHotPathContracts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
|
||||
cmdOp := getOperation(t, doc, "/api/v1/internal/games/{game_id}/commands", http.MethodPost)
|
||||
require.Equal(t, "internalExecuteCommands", cmdOp.OperationID)
|
||||
assertOperationParameterRefs(t, cmdOp,
|
||||
"#/components/parameters/GameIDPath",
|
||||
"#/components/parameters/XUserIDHeader",
|
||||
)
|
||||
assertSchemaRef(t, requestSchemaRef(t, cmdOp), "#/components/schemas/ExecuteCommandsRequest", "internalExecuteCommands request")
|
||||
assertSchemaRef(t, responseSchemaRef(t, cmdOp, http.StatusOK), "#/components/schemas/ExecuteCommandsResponse", "internalExecuteCommands 200")
|
||||
assertResponseRef(t, cmdOp, http.StatusForbidden, "#/components/responses/ForbiddenError")
|
||||
assertResponseRef(t, cmdOp, http.StatusBadGateway, "#/components/responses/EngineUnreachableError")
|
||||
|
||||
orderOp := getOperation(t, doc, "/api/v1/internal/games/{game_id}/orders", http.MethodPost)
|
||||
require.Equal(t, "internalPutOrders", orderOp.OperationID)
|
||||
assertOperationParameterRefs(t, orderOp,
|
||||
"#/components/parameters/GameIDPath",
|
||||
"#/components/parameters/XUserIDHeader",
|
||||
)
|
||||
assertSchemaRef(t, requestSchemaRef(t, orderOp), "#/components/schemas/PutOrdersRequest", "internalPutOrders request")
|
||||
assertSchemaRef(t, responseSchemaRef(t, orderOp, http.StatusOK), "#/components/schemas/PutOrdersResponse", "internalPutOrders 200")
|
||||
|
||||
reportOp := getOperation(t, doc, "/api/v1/internal/games/{game_id}/reports/{turn}", http.MethodGet)
|
||||
require.Equal(t, "internalGetReport", reportOp.OperationID)
|
||||
assertOperationParameterRefs(t, reportOp,
|
||||
"#/components/parameters/GameIDPath",
|
||||
"#/components/parameters/TurnPath",
|
||||
"#/components/parameters/XUserIDHeader",
|
||||
)
|
||||
require.Nil(t, reportOp.RequestBody, "internalGetReport must have no request body")
|
||||
assertSchemaRef(t, responseSchemaRef(t, reportOp, http.StatusOK), "#/components/schemas/ReportResponse", "internalGetReport 200")
|
||||
assertResponseRef(t, reportOp, http.StatusForbidden, "#/components/responses/ForbiddenError")
|
||||
assertResponseRef(t, reportOp, http.StatusBadGateway, "#/components/responses/EngineUnreachableError")
|
||||
}
|
||||
|
||||
// TestInternalSpecProbes verifies the two probe operations.
|
||||
func TestInternalSpecProbes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
|
||||
for _, path := range []string{"/healthz", "/readyz"} {
|
||||
op := getOperation(t, doc, path, http.MethodGet)
|
||||
assertSchemaRef(t, responseSchemaRef(t, op, http.StatusOK), "#/components/schemas/ProbeResponse", op.OperationID+" 200")
|
||||
assertResponseRef(t, op, http.StatusServiceUnavailable, "#/components/responses/ServiceUnavailableError")
|
||||
}
|
||||
|
||||
healthz := getOperation(t, doc, "/healthz", http.MethodGet)
|
||||
require.Equal(t, "internalHealthz", healthz.OperationID)
|
||||
readyz := getOperation(t, doc, "/readyz", http.MethodGet)
|
||||
require.Equal(t, "internalReadyz", readyz.OperationID)
|
||||
}
|
||||
|
||||
// TestInternalSpecRuntimeRecordSchema verifies that RuntimeRecord declares
|
||||
// the required field set documented in gamemaster/README.md §Persistence
|
||||
// Layout, with the optional lifecycle timestamps present in properties.
|
||||
func TestInternalSpecRuntimeRecordSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
schema := componentSchemaRef(t, doc, "RuntimeRecord")
|
||||
|
||||
assertRequiredFields(t, schema,
|
||||
"game_id",
|
||||
"runtime_status",
|
||||
"engine_endpoint",
|
||||
"current_image_ref",
|
||||
"current_engine_version",
|
||||
"turn_schedule",
|
||||
"current_turn",
|
||||
"next_generation_at",
|
||||
"skip_next_tick",
|
||||
"engine_health_summary",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
for _, optional := range []string{"started_at", "stopped_at", "finished_at"} {
|
||||
require.Contains(t, schema.Value.Properties, optional,
|
||||
"RuntimeRecord.%s must be present in properties", optional)
|
||||
}
|
||||
|
||||
runtimeStatus := schema.Value.Properties["runtime_status"]
|
||||
require.NotNil(t, runtimeStatus)
|
||||
require.Equal(t, "#/components/schemas/RuntimeStatus", runtimeStatus.Ref,
|
||||
"RuntimeRecord.runtime_status must reference RuntimeStatus")
|
||||
}
|
||||
|
||||
// TestInternalSpecEngineVersionSchema verifies the EngineVersion schema's
|
||||
// required field set and the deliberate `additionalProperties: true` on
|
||||
// the free-form `options` field.
|
||||
func TestInternalSpecEngineVersionSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
schema := componentSchemaRef(t, doc, "EngineVersion")
|
||||
|
||||
assertRequiredFields(t, schema,
|
||||
"version", "image_ref", "options", "status", "created_at", "updated_at")
|
||||
|
||||
options := schema.Value.Properties["options"]
|
||||
require.NotNil(t, options)
|
||||
require.NotNil(t, options.Value.AdditionalProperties.Has,
|
||||
"EngineVersion.options must declare additionalProperties explicitly")
|
||||
require.True(t, *options.Value.AdditionalProperties.Has,
|
||||
"EngineVersion.options is free-form jsonb and must keep additionalProperties: true")
|
||||
|
||||
status := schema.Value.Properties["status"]
|
||||
require.NotNil(t, status)
|
||||
require.Equal(t, "#/components/schemas/EngineVersionStatus", status.Ref,
|
||||
"EngineVersion.status must reference EngineVersionStatus")
|
||||
}
|
||||
|
||||
// TestInternalSpecRuntimeStatusEnum verifies the seven-value RuntimeStatus
|
||||
// enum from gamemaster/README.md §Scope.
|
||||
func TestInternalSpecRuntimeStatusEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
schema := componentSchemaRef(t, doc, "RuntimeStatus")
|
||||
|
||||
got := stringEnumValues(t, schema)
|
||||
require.ElementsMatch(t,
|
||||
[]string{
|
||||
"starting",
|
||||
"running",
|
||||
"generation_in_progress",
|
||||
"generation_failed",
|
||||
"stopped",
|
||||
"engine_unreachable",
|
||||
"finished",
|
||||
},
|
||||
got)
|
||||
}
|
||||
|
||||
// TestInternalSpecEngineVersionStatusEnum verifies the EngineVersionStatus
|
||||
// enum from gamemaster/README.md §Engine Version Registry.
|
||||
func TestInternalSpecEngineVersionStatusEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
schema := componentSchemaRef(t, doc, "EngineVersionStatus")
|
||||
|
||||
got := stringEnumValues(t, schema)
|
||||
require.ElementsMatch(t, []string{"active", "deprecated"}, got)
|
||||
}
|
||||
|
||||
// TestInternalSpecStopReasonEnum verifies the StopReason enum from
|
||||
// gamemaster/README.md §Lifecycles -> Stop.
|
||||
func TestInternalSpecStopReasonEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
schema := componentSchemaRef(t, doc, "StopReason")
|
||||
|
||||
got := stringEnumValues(t, schema)
|
||||
require.ElementsMatch(t, []string{"admin_request", "finished", "timeout"}, got)
|
||||
}
|
||||
|
||||
// TestInternalSpecErrorEnvelope verifies the error envelope shape, which
|
||||
// must be identical to the Lobby and Runtime Manager envelopes.
|
||||
func TestInternalSpecErrorEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
|
||||
envelope := componentSchemaRef(t, doc, "ErrorResponse")
|
||||
assertRequiredFields(t, envelope, "error")
|
||||
assertAdditionalPropertiesFalse(t, envelope, "ErrorResponse")
|
||||
errRef := envelope.Value.Properties["error"]
|
||||
require.NotNil(t, errRef)
|
||||
require.Equal(t, "#/components/schemas/ErrorBody", errRef.Ref,
|
||||
"ErrorResponse.error must reference ErrorBody")
|
||||
|
||||
body := componentSchemaRef(t, doc, "ErrorBody")
|
||||
assertRequiredFields(t, body, "code", "message")
|
||||
assertAdditionalPropertiesFalse(t, body, "ErrorBody")
|
||||
}
|
||||
|
||||
// TestInternalSpecGMOwnedSchemasAreClosed verifies that every schema for
|
||||
// which Game Master owns the wire shape rejects unknown fields.
|
||||
func TestInternalSpecGMOwnedSchemasAreClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
|
||||
for _, name := range gmOwnedClosedSchemas {
|
||||
name := name
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := componentSchemaRef(t, doc, name)
|
||||
assertAdditionalPropertiesFalse(t, schema, name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInternalSpecHotPathSchemasArePassthrough verifies that every engine
|
||||
// pass-through schema deliberately keeps `additionalProperties: true`.
|
||||
// The matching test guards against a refactor that closes these by mistake.
|
||||
func TestInternalSpecHotPathSchemasArePassthrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadInternalSpec(t)
|
||||
|
||||
for _, name := range engineOwnedPassthroughSchemas {
|
||||
name := name
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := componentSchemaRef(t, doc, name)
|
||||
require.NotNil(t, schema.Value.AdditionalProperties.Has,
|
||||
"%s must declare additionalProperties explicitly", name)
|
||||
require.True(t, *schema.Value.AdditionalProperties.Has,
|
||||
"%s must keep additionalProperties: true (engine pass-through)", name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// loadInternalSpec loads and validates gamemaster/api/internal-openapi.yaml
|
||||
// relative to this test file.
|
||||
func loadInternalSpec(t *testing.T) *openapi3.T {
|
||||
t.Helper()
|
||||
return loadSpec(t, filepath.Join("api", "internal-openapi.yaml"))
|
||||
}
|
||||
|
||||
func loadSpec(t *testing.T, rel string) *openapi3.T {
|
||||
t.Helper()
|
||||
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
require.FailNow(t, "runtime.Caller failed")
|
||||
}
|
||||
|
||||
specPath := filepath.Join(filepath.Dir(thisFile), rel)
|
||||
loader := openapi3.NewLoader()
|
||||
doc, err := loader.LoadFromFile(specPath)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "load spec %s: %v", specPath, err)
|
||||
}
|
||||
if doc == nil {
|
||||
require.Failf(t, "test failed", "load spec %s: returned nil document", specPath)
|
||||
}
|
||||
if err := doc.Validate(context.Background()); err != nil {
|
||||
require.Failf(t, "test failed", "validate spec %s: %v", specPath, err)
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
func getOperation(t *testing.T, doc *openapi3.T, path, method string) *openapi3.Operation {
|
||||
t.Helper()
|
||||
|
||||
if doc.Paths == nil {
|
||||
require.FailNow(t, "spec is missing paths")
|
||||
}
|
||||
pathItem := doc.Paths.Value(path)
|
||||
if pathItem == nil {
|
||||
require.Failf(t, "test failed", "spec is missing path %s", path)
|
||||
}
|
||||
op := pathItem.GetOperation(method)
|
||||
if op == nil {
|
||||
require.Failf(t, "test failed", "spec is missing %s operation for path %s", method, path)
|
||||
}
|
||||
|
||||
return op
|
||||
}
|
||||
|
||||
func requestSchemaRef(t *testing.T, op *openapi3.Operation) *openapi3.SchemaRef {
|
||||
t.Helper()
|
||||
|
||||
if op.RequestBody == nil || op.RequestBody.Value == nil {
|
||||
require.FailNow(t, "operation is missing request body")
|
||||
}
|
||||
mt := op.RequestBody.Value.Content.Get("application/json")
|
||||
if mt == nil || mt.Schema == nil {
|
||||
require.FailNow(t, "operation is missing application/json request schema")
|
||||
}
|
||||
|
||||
return mt.Schema
|
||||
}
|
||||
|
||||
func responseSchemaRef(t *testing.T, op *openapi3.Operation, status int) *openapi3.SchemaRef {
|
||||
t.Helper()
|
||||
|
||||
ref := op.Responses.Status(status)
|
||||
if ref == nil || ref.Value == nil {
|
||||
require.Failf(t, "test failed", "operation is missing %d response", status)
|
||||
}
|
||||
mt := ref.Value.Content.Get("application/json")
|
||||
if mt == nil || mt.Schema == nil {
|
||||
require.Failf(t, "test failed", "operation is missing application/json schema for %d response", status)
|
||||
}
|
||||
|
||||
return mt.Schema
|
||||
}
|
||||
|
||||
func componentSchemaRef(t *testing.T, doc *openapi3.T, name string) *openapi3.SchemaRef {
|
||||
t.Helper()
|
||||
|
||||
if doc.Components.Schemas == nil {
|
||||
require.FailNow(t, "spec is missing component schemas")
|
||||
}
|
||||
ref := doc.Components.Schemas[name]
|
||||
if ref == nil {
|
||||
require.Failf(t, "test failed", "spec is missing component schema %s", name)
|
||||
}
|
||||
|
||||
return ref
|
||||
}
|
||||
|
||||
func componentParameterRef(t *testing.T, doc *openapi3.T, name string) *openapi3.ParameterRef {
|
||||
t.Helper()
|
||||
|
||||
if doc.Components.Parameters == nil {
|
||||
require.FailNow(t, "spec is missing component parameters")
|
||||
}
|
||||
ref := doc.Components.Parameters[name]
|
||||
if ref == nil {
|
||||
require.Failf(t, "test failed", "spec is missing component parameter %s", name)
|
||||
}
|
||||
|
||||
return ref
|
||||
}
|
||||
|
||||
func assertSchemaRef(t *testing.T, schemaRef *openapi3.SchemaRef, want, name string) {
|
||||
t.Helper()
|
||||
require.NotNil(t, schemaRef, "%s schema ref", name)
|
||||
require.Equal(t, want, schemaRef.Ref, "%s schema ref", name)
|
||||
}
|
||||
|
||||
func assertRequiredFields(t *testing.T, schemaRef *openapi3.SchemaRef, fields ...string) {
|
||||
t.Helper()
|
||||
require.NotNil(t, schemaRef)
|
||||
require.ElementsMatch(t, fields, schemaRef.Value.Required)
|
||||
}
|
||||
|
||||
func assertOperationParameterRefs(t *testing.T, op *openapi3.Operation, refs ...string) {
|
||||
t.Helper()
|
||||
|
||||
got := make([]string, 0, len(op.Parameters))
|
||||
for _, p := range op.Parameters {
|
||||
got = append(got, p.Ref)
|
||||
}
|
||||
|
||||
require.ElementsMatch(t, refs, got)
|
||||
}
|
||||
|
||||
func assertResponseRef(t *testing.T, op *openapi3.Operation, status int, want string) {
|
||||
t.Helper()
|
||||
|
||||
ref := op.Responses.Status(status)
|
||||
if ref == nil {
|
||||
require.Failf(t, "test failed", "operation %s is missing %d response", op.OperationID, status)
|
||||
}
|
||||
require.Equal(t, want, ref.Ref,
|
||||
"operation %s response %d must reference %s", op.OperationID, status, want)
|
||||
}
|
||||
|
||||
func assertNoContentResponse(t *testing.T, op *openapi3.Operation, status int) {
|
||||
t.Helper()
|
||||
|
||||
ref := op.Responses.Status(status)
|
||||
if ref == nil || ref.Value == nil {
|
||||
require.Failf(t, "test failed", "operation %s is missing %d response", op.OperationID, status)
|
||||
}
|
||||
require.Empty(t, ref.Value.Content,
|
||||
"operation %s response %d must have no content body", op.OperationID, status)
|
||||
}
|
||||
|
||||
func assertAdditionalPropertiesFalse(t *testing.T, schemaRef *openapi3.SchemaRef, name string) {
|
||||
t.Helper()
|
||||
require.NotNil(t, schemaRef.Value.AdditionalProperties.Has,
|
||||
"%s must declare additionalProperties explicitly", name)
|
||||
require.False(t, *schemaRef.Value.AdditionalProperties.Has,
|
||||
"%s must reject unknown fields (additionalProperties: false)", name)
|
||||
}
|
||||
|
||||
func stringEnumValues(t *testing.T, schemaRef *openapi3.SchemaRef) []string {
|
||||
t.Helper()
|
||||
|
||||
require.NotNil(t, schemaRef)
|
||||
got := make([]string, 0, len(schemaRef.Value.Enum))
|
||||
for _, value := range schemaRef.Value.Enum {
|
||||
s, ok := value.(string)
|
||||
require.True(t, ok, "enum value %v is not a string", value)
|
||||
got = append(got, s)
|
||||
}
|
||||
return got
|
||||
}
|
||||
Reference in New Issue
Block a user