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)) }