Files
galaxy-game/game/openapi_contract_test.go
T
Ilia Denisov 15d35f6f1f
Tests · Go / test (push) Successful in 1m57s
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m0s
feat(game): canonical gameId in POST /api/v1/admin/init
Engine no longer mints its own game UUID. The orchestrator (backend)
generates the game UUID at game-create time and passes it in the
admin/init request body as the required `gameId` field, so the value
that names the engine container and host bind-mount directory also
ends up inside the engine's state.json.

The engine rejects the zero UUID with 400 and any init that conflicts
with an existing state.json with 409 (a second init on the same gameId
is also a conflict; full idempotency is not part of the contract).

Updates rest.InitRequest, openapi.yaml (schema + 409 response),
controller.GenerateGame/NewGame/buildGameOnMap signatures, the engine
HTTP handler/executor, the backend runtime worker, and the relevant
unit and contract tests. Documentation in game/README.md,
docs/ARCHITECTURE.md, backend/README.md, and backend/docs/{runtime,flows}.md
is updated in the same patch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 13:13:31 +02:00

529 lines
17 KiB
Go

package game
import (
"context"
"net/http"
"path/filepath"
"runtime"
"slices"
"testing"
"github.com/getkin/kin-openapi/openapi3"
"github.com/stretchr/testify/require"
)
func TestGameOpenAPISpecValidates(t *testing.T) {
t.Parallel()
loadOpenAPISpec(t)
}
func TestGameOpenAPISpecFreezesResponseSchemas(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
tests := []struct {
name string
path string
method string
status int
wantRef string
}{
{
name: "admin get game status",
path: "/api/v1/admin/status",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/StateResponse",
},
{
name: "admin init game",
path: "/api/v1/admin/init",
method: http.MethodPost,
status: http.StatusCreated,
wantRef: "#/components/schemas/StateResponse",
},
{
name: "get report",
path: "/api/v1/report",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/Report",
},
{
name: "admin generate turn",
path: "/api/v1/admin/turn",
method: http.MethodPut,
status: http.StatusOK,
wantRef: "#/components/schemas/StateResponse",
},
{
name: "put order",
path: "/api/v1/order",
method: http.MethodPut,
status: http.StatusAccepted,
wantRef: "#/components/schemas/UserGamesOrder",
},
{
name: "get order",
path: "/api/v1/order",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/UserGamesOrder",
},
{
name: "healthz probe",
path: "/healthz",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/HealthzResponse",
},
{
name: "get battle",
path: "/api/v1/battle/{turn}/{uuid}",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/BattleReport",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
operation := getOpenAPIOperation(t, doc, tt.path, tt.method)
assertSchemaRef(t, responseSchemaRef(t, operation, tt.status), tt.wantRef, tt.name+" response schema")
})
}
}
func TestGameOpenAPISpecFreezesEmptyResponses(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
tests := []struct {
name string
path string
method string
status int
}{
{
name: "command accepted",
path: "/api/v1/command",
method: http.MethodPut,
status: http.StatusAccepted,
},
{
name: "get order no content",
path: "/api/v1/order",
method: http.MethodGet,
status: http.StatusNoContent,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
operation := getOpenAPIOperation(t, doc, tt.path, tt.method)
require.NotNil(t, operation.Responses, "operation must declare responses")
response := operation.Responses.Status(tt.status)
require.NotNil(t, response, "operation must declare %d response", tt.status)
require.NotNil(t, response.Value, "%d response must have a value", tt.status)
require.Empty(t, response.Value.Content, "%d response must carry no body", tt.status)
})
}
}
func TestGameOpenAPISpecFreezesUserGamesOrder(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
schema := componentSchemaRef(t, doc, "UserGamesOrder")
assertRequiredFields(t, schema, "game_id", "updatedAt", "cmd")
gameIDSchema := schema.Value.Properties["game_id"]
require.NotNil(t, gameIDSchema, "UserGamesOrder.game_id schema must exist")
require.Equal(t, "uuid", gameIDSchema.Value.Format, "UserGamesOrder.game_id format must be uuid")
updatedAtSchema := schema.Value.Properties["updatedAt"]
require.NotNil(t, updatedAtSchema, "UserGamesOrder.updatedAt schema must exist")
require.True(t, updatedAtSchema.Value.Type.Is("integer"), "UserGamesOrder.updatedAt must be integer")
require.Equal(t, "int64", updatedAtSchema.Value.Format, "UserGamesOrder.updatedAt format must be int64")
cmdSchema := schema.Value.Properties["cmd"]
require.NotNil(t, cmdSchema, "UserGamesOrder.cmd schema must exist")
require.True(t, cmdSchema.Value.Type.Is("array"), "UserGamesOrder.cmd must be array")
require.NotNil(t, cmdSchema.Value.Items, "UserGamesOrder.cmd items must be defined")
assertSchemaRef(t, cmdSchema.Value.Items, "#/components/schemas/Command", "UserGamesOrder.cmd items schema")
}
func TestGameOpenAPISpecFreezesGetOrderOperation(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
operation := getOpenAPIOperation(t, doc, "/api/v1/order", http.MethodGet)
require.Equal(t, "getOrder", operation.OperationID, "GET /api/v1/order operation id")
paramRefs := make(map[string]bool)
for _, p := range operation.Parameters {
require.NotNil(t, p.Value, "parameter must have value")
paramRefs[p.Ref] = true
}
require.True(t, paramRefs["#/components/parameters/PlayerParam"], "GET /api/v1/order must reference PlayerParam")
require.True(t, paramRefs["#/components/parameters/TurnParam"], "GET /api/v1/order must reference TurnParam")
}
func TestGameOpenAPISpecFreezesInitRequest(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
operation := getOpenAPIOperation(t, doc, "/api/v1/admin/init", http.MethodPost)
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/InitRequest", "init request schema")
schema := componentSchemaRef(t, doc, "InitRequest")
assertRequiredFields(t, schema, "gameId", "races")
gameIDSchema := schema.Value.Properties["gameId"]
require.NotNil(t, gameIDSchema, "InitRequest.gameId schema must exist")
require.True(t, gameIDSchema.Value.Type.Is("string"), "InitRequest.gameId must be string")
require.Equal(t, "uuid", gameIDSchema.Value.Format, "InitRequest.gameId format must be uuid")
racesSchema := schema.Value.Properties["races"]
require.NotNil(t, racesSchema, "InitRequest.races schema must exist")
require.Equal(t, uint64(10), racesSchema.Value.MinItems, "InitRequest.races minItems must be 10")
if operation.Responses == nil {
require.FailNow(t, "init operation is missing responses")
}
conflict := operation.Responses.Status(http.StatusConflict)
require.NotNil(t, conflict, "init operation must declare 409 response")
require.NotNil(t, conflict.Value, "init 409 response must have a value")
}
func TestGameOpenAPISpecFreezesAdminOperationIDs(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
tests := []struct {
path string
method string
opID string
}{
{"/api/v1/admin/init", http.MethodPost, "adminInitGame"},
{"/api/v1/admin/status", http.MethodGet, "adminGetGameStatus"},
{"/api/v1/admin/turn", http.MethodPut, "adminGenerateTurn"},
{"/api/v1/admin/race/banish", http.MethodPost, "adminBanishRace"},
}
for _, tt := range tests {
t.Run(tt.opID, func(t *testing.T) {
t.Parallel()
operation := getOpenAPIOperation(t, doc, tt.path, tt.method)
require.Equal(t, tt.opID, operation.OperationID, "operation id for %s %s", tt.method, tt.path)
})
}
}
func TestGameOpenAPISpecFreezesBanishRequest(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
operation := getOpenAPIOperation(t, doc, "/api/v1/admin/race/banish", http.MethodPost)
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/BanishRequest", "banish request schema")
if operation.Responses == nil {
require.FailNow(t, "banish operation is missing responses")
}
noContent := operation.Responses.Status(http.StatusNoContent)
require.NotNil(t, noContent, "banish operation must declare 204 response")
require.NotNil(t, noContent.Value, "banish 204 response must have a value")
schema := componentSchemaRef(t, doc, "BanishRequest")
assertRequiredFields(t, schema, "race_name")
raceNameSchema := schema.Value.Properties["race_name"]
require.NotNil(t, raceNameSchema, "BanishRequest.race_name schema must exist")
require.Equal(t, uint64(1), raceNameSchema.Value.MinLength, "BanishRequest.race_name minLength must be 1")
}
func TestGameOpenAPISpecFreezesStateResponseFinished(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
schema := componentSchemaRef(t, doc, "StateResponse")
assertRequiredFields(t, schema, "id", "turn", "stage", "player", "finished")
finishedSchema := schema.Value.Properties["finished"]
require.NotNil(t, finishedSchema, "StateResponse.finished schema must exist")
require.True(t, finishedSchema.Value.Type.Is("boolean"), "StateResponse.finished must be boolean")
}
func TestGameOpenAPISpecFreezesCommandRequest(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
for _, path := range []string{"/api/v1/command", "/api/v1/order"} {
t.Run(path, func(t *testing.T) {
t.Parallel()
operation := getOpenAPIOperation(t, doc, path, http.MethodPut)
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/CommandRequest", path+" command request schema")
})
}
schema := componentSchemaRef(t, doc, "CommandRequest")
assertRequiredFields(t, schema, "actor", "cmd")
cmdSchema := schema.Value.Properties["cmd"]
require.NotNil(t, cmdSchema, "CommandRequest.cmd schema must exist")
require.Equal(t, uint64(1), cmdSchema.Value.MinItems, "CommandRequest.cmd minItems must be 1")
}
func TestGameOpenAPISpecFreezesGetBattleOperation(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
operation := getOpenAPIOperation(t, doc, "/api/v1/battle/{turn}/{uuid}", http.MethodGet)
require.Equal(t, "getBattle", operation.OperationID, "GET /api/v1/battle/{turn}/{uuid} operation id")
paramRefs := make(map[string]bool)
for _, p := range operation.Parameters {
require.NotNil(t, p.Value, "parameter must have value")
paramRefs[p.Ref] = true
}
require.True(t, paramRefs["#/components/parameters/BattleTurnParam"], "GET /api/v1/battle/{turn}/{uuid} must reference BattleTurnParam")
require.True(t, paramRefs["#/components/parameters/BattleIDParam"], "GET /api/v1/battle/{turn}/{uuid} must reference BattleIDParam")
require.NotNil(t, operation.Responses, "operation must declare responses")
notFound := operation.Responses.Status(http.StatusNotFound)
require.NotNil(t, notFound, "operation must declare 404 response")
require.NotNil(t, notFound.Value, "404 response must have a value")
}
func TestGameOpenAPISpecFreezesBattleReport(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
reportSchema := componentSchemaRef(t, doc, "BattleReport")
assertRequiredFields(t, reportSchema, "id", "planet", "planetName", "races", "ships", "protocol")
groupSchema := componentSchemaRef(t, doc, "BattleReportGroup")
assertRequiredFields(t, groupSchema, "race", "className", "tech", "num", "numLeft", "loadType", "loadQuantity", "inBattle")
actionSchema := componentSchemaRef(t, doc, "BattleActionReport")
assertRequiredFields(t, actionSchema, "a", "sa", "d", "sd", "x")
protocolSchema := reportSchema.Value.Properties["protocol"]
require.NotNil(t, protocolSchema, "BattleReport.protocol schema must exist")
require.True(t, protocolSchema.Value.Type.Is("array"), "BattleReport.protocol must be array")
require.NotNil(t, protocolSchema.Value.Items, "BattleReport.protocol items must be defined")
assertSchemaRef(t, protocolSchema.Value.Items, "#/components/schemas/BattleActionReport", "BattleReport.protocol items schema")
shipsSchema := reportSchema.Value.Properties["ships"]
require.NotNil(t, shipsSchema, "BattleReport.ships schema must exist")
require.True(t, shipsSchema.Value.Type.Is("object"), "BattleReport.ships must be object")
require.NotNil(t, shipsSchema.Value.AdditionalProperties.Schema, "BattleReport.ships additionalProperties must be a schema")
assertSchemaRef(t, shipsSchema.Value.AdditionalProperties.Schema, "#/components/schemas/BattleReportGroup", "BattleReport.ships additionalProperties schema")
}
func TestGameOpenAPISpecFreezesBattleSummary(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
summary := componentSchemaRef(t, doc, "BattleSummary")
assertRequiredFields(t, summary, "id", "planet", "shots")
report := componentSchemaRef(t, doc, "Report")
battle := report.Value.Properties["battle"]
require.NotNil(t, battle, "Report.battle schema must exist")
require.True(t, battle.Value.Type.Is("array"), "Report.battle must be array")
require.NotNil(t, battle.Value.Items, "Report.battle items must be defined")
assertSchemaRef(t, battle.Value.Items, "#/components/schemas/BattleSummary", "Report.battle items schema")
}
func TestGameOpenAPISpecHealthzStatusEnum(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
schema := componentSchemaRef(t, doc, "HealthzResponse")
assertRequiredFields(t, schema, "status")
statusSchema := schema.Value.Properties["status"]
require.NotNil(t, statusSchema, "HealthzResponse.status schema must exist")
require.Equal(t, []any{"ok"}, statusSchema.Value.Enum, "HealthzResponse.status enum must be [\"ok\"]")
}
func TestGameOpenAPISpecCommandTypeEnumIsComplete(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
schema := componentSchemaRef(t, doc, "CommandType")
enumValues := make([]string, 0, len(schema.Value.Enum))
for _, v := range schema.Value.Enum {
s, ok := v.(string)
require.True(t, ok, "CommandType enum entry must be a string")
enumValues = append(enumValues, s)
}
require.ElementsMatch(t, []string{
"raceQuit",
"raceVote",
"raceRelation",
"shipClassCreate",
"shipClassMerge",
"shipClassRemove",
"shipGroupBreak",
"shipGroupLoad",
"shipGroupUnload",
"shipGroupSend",
"shipGroupUpgrade",
"shipGroupMerge",
"shipGroupDismantle",
"shipGroupTransfer",
"shipGroupJoinFleet",
"fleetMerge",
"fleetSend",
"scienceCreate",
"scienceRemove",
"planetRename",
"planetProduce",
"planetRouteSet",
"planetRouteRemove",
}, enumValues)
}
// helpers (modelled after user/openapi_contract_test.go)
func loadOpenAPISpec(t *testing.T) *openapi3.T {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
require.FailNow(t, "runtime.Caller failed")
}
specPath := filepath.Join(filepath.Dir(thisFile), "openapi.yaml")
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 doc.Info == nil {
require.Failf(t, "test failed", "load spec %s: missing info section", specPath)
}
if doc.Info.Version != "v1" {
require.Failf(t, "test failed", "spec %s version = %q, want v1", specPath, doc.Info.Version)
}
if err := doc.Validate(context.Background()); err != nil {
require.Failf(t, "test failed", "validate spec %s: %v", specPath, err)
}
return doc
}
func getOpenAPIOperation(t *testing.T, doc *openapi3.T, path string, method string) *openapi3.Operation {
t.Helper()
if doc.Paths == nil {
require.Failf(t, "test failed", "spec is missing paths while looking up %s %s", method, path)
}
pathItem := doc.Paths.Value(path)
if pathItem == nil {
require.Failf(t, "test failed", "spec is missing path %s", path)
}
operation := pathItem.GetOperation(method)
if operation == nil {
require.Failf(t, "test failed", "spec is missing %s operation for path %s", method, path)
}
return operation
}
func requestSchemaRef(t *testing.T, operation *openapi3.Operation) *openapi3.SchemaRef {
t.Helper()
if operation.RequestBody == nil || operation.RequestBody.Value == nil {
require.FailNow(t, "operation is missing request body")
}
mediaType := operation.RequestBody.Value.Content.Get("application/json")
if mediaType == nil || mediaType.Schema == nil {
require.FailNow(t, "operation is missing application/json request schema")
}
return mediaType.Schema
}
func responseSchemaRef(t *testing.T, operation *openapi3.Operation, status int) *openapi3.SchemaRef {
t.Helper()
if operation.Responses == nil {
require.Failf(t, "test failed", "operation is missing responses for status %d", status)
}
response := operation.Responses.Status(status)
if response == nil || response.Value == nil {
require.Failf(t, "test failed", "operation is missing response for status %d", status)
}
mediaType := response.Value.Content.Get("application/json")
if mediaType == nil || mediaType.Schema == nil {
require.Failf(t, "test failed", "operation response %d is missing application/json schema", status)
}
return mediaType.Schema
}
func componentSchemaRef(t *testing.T, doc *openapi3.T, name string) *openapi3.SchemaRef {
t.Helper()
if doc.Components == nil {
require.Failf(t, "test failed", "spec is missing components while looking up schema %s", name)
}
schema := doc.Components.Schemas[name]
if schema == nil || schema.Value == nil {
require.Failf(t, "test failed", "spec is missing schema %s", name)
}
return schema
}
func assertSchemaRef(t *testing.T, schemaRef *openapi3.SchemaRef, want string, name string) {
t.Helper()
if schemaRef == nil {
require.Failf(t, "test failed", "%s schema ref is nil", name)
}
if schemaRef.Ref != want {
require.Failf(t, "test failed", "%s ref = %q, want %q", name, schemaRef.Ref, want)
}
}
func assertRequiredFields(t *testing.T, schemaRef *openapi3.SchemaRef, fields ...string) {
t.Helper()
required := append([]string(nil), schemaRef.Value.Required...)
slices.Sort(required)
want := append([]string(nil), fields...)
slices.Sort(want)
if !slices.Equal(required, want) {
require.Failf(t, "test failed", "schema required fields = %v, want %v", required, want)
}
}