Files
galaxy-game/user/openapi_contract_test.go
T
2026-04-25 23:20:55 +02:00

360 lines
12 KiB
Go

package user
import (
"context"
"encoding/json"
"net/http"
"path/filepath"
"runtime"
"slices"
"testing"
"github.com/getkin/kin-openapi/openapi3"
"github.com/stretchr/testify/require"
)
func TestInternalOpenAPISpecValidates(t *testing.T) {
t.Parallel()
loadOpenAPISpec(t)
}
func TestInternalOpenAPISpecFreezesEnsureByEmailRegistrationContext(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
operation := getOpenAPIOperation(t, doc, "/api/v1/internal/users/ensure-by-email", http.MethodPost)
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/EnsureByEmailRequest", "ensure-by-email request schema")
requestSchema := componentSchemaRef(t, doc, "EnsureByEmailRequest")
assertRequiredFields(t, requestSchema, "email", "registration_context")
assertSchemaRef(t, requestSchema.Value.Properties["email"], "#/components/schemas/Email", "ensure-by-email email property")
assertSchemaRef(t, requestSchema.Value.Properties["registration_context"], "#/components/schemas/RegistrationContext", "ensure-by-email registration_context property")
require.Contains(t, marshalOpenAPIJSON(t, requestSchema.Value), `"additionalProperties":false`)
registrationContext := componentSchemaRef(t, doc, "RegistrationContext")
assertRequiredFields(t, registrationContext, "preferred_language", "time_zone")
assertSchemaRef(t, registrationContext.Value.Properties["preferred_language"], "#/components/schemas/LanguageTag", "registration_context preferred_language property")
assertSchemaRef(t, registrationContext.Value.Properties["time_zone"], "#/components/schemas/TimeZoneName", "registration_context time_zone property")
require.Contains(t, marshalOpenAPIJSON(t, registrationContext.Value), `"additionalProperties":false`)
}
func TestInternalOpenAPISpecFreezesSharedResponseSchemas(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
tests := []struct {
name string
path string
method string
status int
wantRef string
}{
{
name: "get my account",
path: "/api/v1/internal/users/{user_id}/account",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/GetMyAccountResponse",
},
{
name: "update my profile",
path: "/api/v1/internal/users/{user_id}/profile",
method: http.MethodPost,
status: http.StatusOK,
wantRef: "#/components/schemas/GetMyAccountResponse",
},
{
name: "update my settings",
path: "/api/v1/internal/users/{user_id}/settings",
method: http.MethodPost,
status: http.StatusOK,
wantRef: "#/components/schemas/GetMyAccountResponse",
},
{
name: "get user eligibility",
path: "/api/v1/internal/users/{user_id}/eligibility",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/UserEligibilityResponse",
},
{
name: "sync declared country",
path: "/api/v1/internal/users/{user_id}/declared-country/sync",
method: http.MethodPost,
status: http.StatusOK,
wantRef: "#/components/schemas/DeclaredCountrySyncResponse",
},
{
name: "get user by id",
path: "/api/v1/internal/users/{user_id}",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/UserLookupResponse",
},
{
name: "get user by email",
path: "/api/v1/internal/user-lookups/by-email",
method: http.MethodPost,
status: http.StatusOK,
wantRef: "#/components/schemas/UserLookupResponse",
},
{
name: "get user by user name",
path: "/api/v1/internal/user-lookups/by-user-name",
method: http.MethodPost,
status: http.StatusOK,
wantRef: "#/components/schemas/UserLookupResponse",
},
{
name: "list users",
path: "/api/v1/internal/users",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/UserListResponse",
},
{
name: "delete user",
path: "/api/v1/internal/users/{user_id}/delete",
method: http.MethodPost,
status: http.StatusOK,
wantRef: "#/components/schemas/DeleteUserResponse",
},
}
for _, tt := range tests {
tt := tt
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 TestInternalOpenAPISpecFreezesDeleteUserRequest(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
operation := getOpenAPIOperation(t, doc, "/api/v1/internal/users/{user_id}/delete", http.MethodPost)
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/DeleteUserRequest", "delete user request schema")
requestSchema := componentSchemaRef(t, doc, "DeleteUserRequest")
assertRequiredFields(t, requestSchema, "reason_code", "actor")
assertSchemaRef(t, requestSchema.Value.Properties["actor"], "#/components/schemas/ActorRef", "delete user request actor property")
require.Contains(t, marshalOpenAPIJSON(t, requestSchema.Value), `"additionalProperties":false`)
responseSchema := componentSchemaRef(t, doc, "DeleteUserResponse")
assertRequiredFields(t, responseSchema, "user_id", "deleted_at")
require.Contains(t, marshalOpenAPIJSON(t, responseSchema.Value), `"additionalProperties":false`)
}
func TestInternalOpenAPISpecSanctionCodeEnumIncludesPermanentBlock(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
schema := componentSchemaRef(t, doc, "SanctionCode")
enumValues := make([]string, 0, len(schema.Value.Enum))
for _, value := range schema.Value.Enum {
stringValue, ok := value.(string)
require.True(t, ok, "SanctionCode enum entry must be a string")
enumValues = append(enumValues, stringValue)
}
require.ElementsMatch(t, []string{
"login_block",
"private_game_create_block",
"private_game_manage_block",
"game_join_block",
"profile_update_block",
"permanent_block",
}, enumValues)
}
func TestInternalOpenAPISpecErrorEnvelopeRemainsStable(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
errorResponse := componentSchemaRef(t, doc, "ErrorResponse")
assertRequiredFields(t, errorResponse, "error")
require.Contains(t, marshalOpenAPIJSON(t, errorResponse.Value), `"additionalProperties":false`)
assertSchemaRef(t, errorResponse.Value.Properties["error"], "#/components/schemas/ErrorBody", "ErrorResponse error property")
errorBody := componentSchemaRef(t, doc, "ErrorBody")
assertRequiredFields(t, errorBody, "code", "message")
require.Contains(t, marshalOpenAPIJSON(t, errorBody.Value), `"additionalProperties":false`)
require.JSONEq(
t,
`{"error":{"code":"invalid_request","message":"request is invalid"}}`,
string(mustMarshalJSON(t, responseExampleValue(t, doc, "InvalidRequestError", "invalidRequest"))),
)
require.JSONEq(
t,
`{"error":{"code":"subject_not_found","message":"subject not found"}}`,
string(mustMarshalJSON(t, responseExampleValue(t, doc, "SubjectNotFoundError", "subjectNotFound"))),
)
}
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 responseExampleValue(t *testing.T, doc *openapi3.T, responseName string, exampleName string) any {
t.Helper()
if doc.Components == nil {
require.Failf(t, "test failed", "spec is missing components while looking up response %s", responseName)
}
response := doc.Components.Responses[responseName]
if response == nil || response.Value == nil {
require.Failf(t, "test failed", "spec is missing response %s", responseName)
}
mediaType := response.Value.Content.Get("application/json")
if mediaType == nil {
require.Failf(t, "test failed", "response %s is missing application/json content", responseName)
}
example := mediaType.Examples[exampleName]
if example == nil || example.Value == nil {
require.Failf(t, "test failed", "response %s is missing example %s", responseName, exampleName)
}
return example.Value.Value
}
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)
}
}
func mustMarshalJSON(t *testing.T, value any) []byte {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
require.Failf(t, "test failed", "marshal JSON: %v", err)
}
return data
}
func marshalOpenAPIJSON(t *testing.T, value any) string {
t.Helper()
return string(mustMarshalJSON(t, value))
}