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, "races") 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") } 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) } }