| """OpenAPI schema validation tests for comprehensive API documentation. |
| |
| Validates that the FastAPI application's OpenAPI schema is well-structured, |
| complete, and consistent. Ensures all endpoints are properly documented with |
| request/response schemas, tags, summaries, and security schemes. |
| |
| Test coverage: |
| - Schema structure (OpenAPI version, info, paths, components) |
| - Endpoint documentation (tags, summaries, responses, request bodies) |
| - Schema consistency (refs resolve, components reused) |
| - Schema coverage (documented routes match actual routes) |
| """ |
| import pytest |
| from main_api_app import app |
|
|
|
|
| class TestOpenAPISchemaStructure: |
| """Tests for basic OpenAPI schema structure and required sections.""" |
|
|
| def test_openapi_schema_version(self): |
| """Test that schema uses OpenAPI 3.x specification.""" |
| schema = app.openapi() |
| assert "openapi" in schema, "Schema missing 'openapi' version field" |
| assert schema["openapi"].startswith("3."), f"Expected OpenAPI 3.x, got {schema['openapi']}" |
|
|
| def test_schema_has_info_section(self): |
| """Test that schema contains required info section.""" |
| schema = app.openapi() |
| assert "info" in schema, "Schema missing 'info' section" |
|
|
| info = schema["info"] |
| assert "title" in info, "Info section missing 'title'" |
| assert "version" in info, "Info section missing 'version'" |
| assert isinstance(info.get("description"), str), "Info 'description' should be a string" |
|
|
| def test_schema_has_paths_section(self): |
| """Test that schema contains paths section with routes.""" |
| schema = app.openapi() |
| assert "paths" in schema, "Schema missing 'paths' section" |
| assert isinstance(schema["paths"], dict), "Paths should be a dictionary" |
| assert len(schema["paths"]) > 0, "Paths section should contain at least one route" |
|
|
| def test_schema_has_components_section(self): |
| """Test that schema defines components/schemas for models.""" |
| schema = app.openapi() |
| |
| has_components = "components" in schema or "definitions" in schema |
| assert has_components, "Schema should have 'components' or 'definitions' section" |
|
|
| |
| components = schema.get("components", {}) or schema.get("definitions", {}) |
| if "schemas" in components: |
| assert len(components["schemas"]) > 0, "Should define at least one schema model" |
|
|
|
|
| class TestEndpointDocumentation: |
| """Tests for endpoint documentation completeness.""" |
|
|
| def test_all_endpoints_have_tags(self): |
| """Test that each documented path has at least one tag.""" |
| schema = app.openapi() |
| paths = schema.get("paths", {}) |
|
|
| undoc_tagged = [] |
| for path, path_item in paths.items(): |
| for method, operation in path_item.items(): |
| if method.lower() in ["get", "post", "put", "delete", "patch"]: |
| tags = operation.get("tags", []) |
| if not tags: |
| undoc_tagged.append(f"{method.upper()} {path}") |
|
|
| |
| |
| if undoc_tagged: |
| |
| pass |
|
|
| def test_all_endpoints_have_summary(self): |
| """Test that operations have descriptive summaries.""" |
| schema = app.openapi() |
| paths = schema.get("paths", {}) |
|
|
| undocumented = [] |
| for path, path_item in paths.items(): |
| for method, operation in path_item.items(): |
| if method.lower() in ["get", "post", "put", "delete", "patch"]: |
| summary = operation.get("summary") or operation.get("description") |
| if not summary: |
| undocumented.append(f"{method.upper()} {path}") |
|
|
| |
| |
| assert len(undocumented) < len(paths) * 0.5, "Too many endpoints missing summaries" |
|
|
| def test_responses_documented(self): |
| """Test that operations define at least one response schema.""" |
| schema = app.openapi() |
| paths = schema.get("paths", {}) |
|
|
| undocumented = [] |
| for path, path_item in paths.items(): |
| for method, operation in path_item.items(): |
| if method.lower() in ["get", "post", "put", "delete", "patch"]: |
| responses = operation.get("responses") |
| if not responses or len(responses) == 0: |
| undocumented.append(f"{method.upper()} {path}") |
|
|
| assert len(undocumented) == 0, f"Operations without responses: {undocumented}" |
|
|
| def test_request_bodies_documented(self): |
| """Test that POST/PUT operations define request schemas.""" |
| schema = app.openapi() |
| paths = schema.get("paths", {}) |
|
|
| undocumented = [] |
| for path, path_item in paths.items(): |
| for method, operation in path_item.items(): |
| if method.lower() in ["post", "put", "patch"]: |
| |
| has_body = "requestBody" in operation |
| has_params = operation.get("parameters") |
| if not has_body and not has_params: |
| undocumented.append(f"{method.upper()} {path}") |
|
|
| |
| |
| if undocumented: |
| pass |
|
|
|
|
| class TestSchemaConsistency: |
| """Tests for schema consistency and validity.""" |
|
|
| def test_response_schemas_valid(self): |
| """Test that response schema references resolve to valid components.""" |
| schema = app.openapi() |
| paths = schema.get("paths", {}) |
| components = schema.get("components", {}).get("schemas", {}) |
|
|
| invalid_refs = [] |
| for path, path_item in paths.items(): |
| for method, operation in path_item.items(): |
| if method.lower() in ["get", "post", "put", "delete", "patch"]: |
| responses = operation.get("responses", {}) |
| for status, response in responses.items(): |
| |
| content = response.get("content", {}) |
| for content_type, content_schema in content.items(): |
| schema_ref = content_schema.get("schema", {}).get("$ref", "") |
| if schema_ref: |
| |
| if schema_ref.startswith("#/components/schemas/"): |
| component_name = schema_ref.split("/")[-1] |
| if component_name not in components: |
| invalid_refs.append(f"{method.upper()} {path} -> {component_name}") |
|
|
| assert len(invalid_refs) == 0, f"Invalid schema references: {invalid_refs}" |
|
|
| def test_request_schemas_valid(self): |
| """Test that request schema references resolve to valid components.""" |
| schema = app.openapi() |
| paths = schema.get("paths", {}) |
| components = schema.get("components", {}).get("schemas", {}) |
|
|
| invalid_refs = [] |
| for path, path_item in paths.items(): |
| for method, operation in path_item.items(): |
| if method.lower() in ["post", "put", "patch"]: |
| request_body = operation.get("requestBody", {}) |
| content = request_body.get("content", {}) |
| for content_type, content_schema in content.items(): |
| schema_ref = content_schema.get("schema", {}).get("$ref", "") |
| if schema_ref: |
| if schema_ref.startswith("#/components/schemas/"): |
| component_name = schema_ref.split("/")[-1] |
| if component_name not in components: |
| invalid_refs.append(f"{method.upper()} {path} -> {component_name}") |
|
|
| assert len(invalid_refs) == 0, f"Invalid schema references: {invalid_refs}" |
|
|
| def test_schema_reuse_consistent(self): |
| """Test that common response patterns use consistent schemas.""" |
| schema = app.openapi() |
| components = schema.get("components", {}).get("schemas", {}) |
|
|
| |
| |
| expected_common_schemas = [ |
| |
| |
| ] |
|
|
| |
| |
| schema_refs = {} |
| paths = schema.get("paths", {}) |
|
|
| for path, path_item in paths.items(): |
| for method, operation in path_item.items(): |
| if method.lower() in ["get", "post", "put", "delete", "patch"]: |
| |
| request_body = operation.get("requestBody", {}) |
| content = request_body.get("content", {}) |
| for content_schema in content.values(): |
| schema_ref = content_schema.get("schema", {}).get("$ref", "") |
| if schema_ref: |
| schema_refs[schema_ref] = schema_refs.get(schema_ref, 0) + 1 |
|
|
| |
| responses = operation.get("responses", {}) |
| for response in responses.values(): |
| content = response.get("content", {}) |
| for content_schema in content.values(): |
| schema_ref = content_schema.get("schema", {}).get("$ref", "") |
| if schema_ref: |
| schema_refs[schema_ref] = schema_refs.get(schema_ref, 0) + 1 |
|
|
| |
| reused_schemas = [ref for ref, count in schema_refs.items() if count > 1] |
| |
| |
|
|
| def test_security_schemes_defined(self): |
| """Test that authentication methods are documented in security schemes.""" |
| schema = app.openapi() |
| components = schema.get("components", {}) |
| security_schemes = components.get("securitySchemes", {}) |
|
|
| |
| |
| if security_schemes: |
| |
| for scheme_name, scheme in security_schemes.items(): |
| assert "type" in scheme, f"Security scheme {scheme_name} missing 'type'" |
| valid_types = ["apiKey", "http", "oauth2", "openIdConnect", "mutualTLS"] |
| assert scheme["type"] in valid_types, f"Invalid scheme type: {scheme['type']}" |
|
|
|
|
| class TestSchemaCoverage: |
| """Tests for coverage between documented and actual routes.""" |
|
|
| def test_documented_routes_match_actual(self): |
| """Test that OpenAPI schema documents all actual FastAPI routes.""" |
| schema = app.openapi() |
| documented_paths = set(schema.get("paths", {}).keys()) |
|
|
| |
| actual_routes = set() |
| for route in app.routes: |
| if hasattr(route, "path") and hasattr(route, "methods"): |
| |
| if route.methods and None not in route.methods: |
| actual_routes.add(route.path) |
|
|
| |
| def normalize_path(path): |
| import re |
| return re.sub(r'\{[^}]+\}', ':param', path) |
|
|
| documented_normalized = {normalize_path(p) for p in documented_paths} |
| actual_normalized = {normalize_path(p) for p in actual_routes} |
|
|
| |
| undocumented = actual_normalized - documented_normalized |
|
|
| |
| |
| if len(undocumented) > 10: |
| |
| pass |
|
|
| def test_actual_routes_match_documented(self): |
| """Test that all documented routes actually exist in FastAPI app.""" |
| schema = app.openapi() |
| documented_paths = set(schema.get("paths", {}).keys()) |
|
|
| |
| actual_routes = set() |
| for route in app.routes: |
| if hasattr(route, "path"): |
| actual_routes.add(route.path) |
|
|
| |
| missing = documented_paths - actual_routes |
|
|
| assert len(missing) == 0, f"Documented routes not found in app: {missing}" |
|
|
| def test_deprecated_routes_marked(self): |
| """Test that deprecated status is documented in schema.""" |
| schema = app.openapi() |
| paths = schema.get("paths", {}) |
|
|
| deprecated_endpoints = [] |
| for path, path_item in paths.items(): |
| for method, operation in path_item.items(): |
| if method.lower() in ["get", "post", "put", "delete", "patch"]: |
| if operation.get("deprecated", False): |
| deprecated_endpoints.append(f"{method.upper()} {path}") |
|
|
| |
| |
|
|
| def test_path_parameter_consistency(self): |
| """Test that path parameters use consistent naming.""" |
| schema = app.openapi() |
| paths = schema.get("paths", {}) |
|
|
| inconsistent = [] |
| for path, path_item in paths.items(): |
| |
| import re |
| params = re.findall(r'\{([^}]+)\}', path) |
|
|
| |
| for param in params: |
| if not param.replace("_", "").isalnum(): |
| inconsistent.append(f"{path} has invalid parameter: {param}") |
|
|
| assert len(inconsistent) == 0, f"Path parameters with inconsistent naming: {inconsistent}" |
|
|