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

351 lines
10 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: "healthz probe",
path: "/healthz",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/HealthzResponse",
},
}
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 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 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)
}
}