Files
galaxy-game/gamemaster/contract_openapi_test.go
T
2026-05-03 07:59:03 +02:00

719 lines
28 KiB
Go

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
}