"""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() # OpenAPI 3.0 uses 'components', 3.1 uses 'components' (backward compatible) has_components = "components" in schema or "definitions" in schema assert has_components, "Schema should have 'components' or 'definitions' section" # Check for schemas 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}") # Allow some endpoints to not have tags (e.g., health checks) # But most should have them for organization if undoc_tagged: # Log but don't fail - tags are recommended but not required 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}") # Allow some endpoints without summaries # But most should have them for API documentation 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"]: # Check for request body or parameters has_body = "requestBody" in operation has_params = operation.get("parameters") if not has_body and not has_params: undocumented.append(f"{method.upper()} {path}") # Allow some POST/PUT without body (e.g., query params only) # But flag for review if undocumented: pass # Log for review 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(): # Check for schema references content = response.get("content", {}) for content_type, content_schema in content.items(): schema_ref = content_schema.get("schema", {}).get("$ref", "") if schema_ref: # Extract component name (e.g., "#/components/schemas/User") 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", {}) # Check for common schema patterns # These should be defined as reusable components expected_common_schemas = [ # "SuccessResponse", # Common success wrapper # "ErrorResponse", # Common error wrapper ] # This is a soft check - we want schema reuse but don't require specific schemas # Just verify that schemas are being reused (multiple refs to same component) 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"]: # Count request schema refs 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 # Count response schema refs 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 # Check that at least some schemas are reused (referenced multiple times) reused_schemas = [ref for ref, count in schema_refs.items() if count > 1] # Don't fail if no reuse, just log for improvement # assert len(reused_schemas) > 0, "No schemas are being reused across endpoints" 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", {}) # Check if security schemes are defined # Not required, but recommended for APIs with authentication if security_schemes: # Validate scheme structure 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()) # Get actual routes from FastAPI app actual_routes = set() for route in app.routes: if hasattr(route, "path") and hasattr(route, "methods"): # Include only HTTP routes (exclude websocket, etc.) if route.methods and None not in route.methods: actual_routes.add(route.path) # Normalize paths (remove path parameters for comparison) 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} # Find actual routes not in docs undocumented = actual_normalized - documented_normalized # Allow some undocumented routes (internal endpoints, etc.) # But flag significant gaps if len(undocumented) > 10: # Too many undocumented routes pass # Log for review 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()) # Get actual routes from FastAPI app actual_routes = set() for route in app.routes: if hasattr(route, "path"): actual_routes.add(route.path) # Find documented routes not in actual 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}") # Don't fail - just document deprecated endpoints # assert len(deprecated_endpoints) == 0 or all_marked, "Deprecated endpoints should be marked" 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(): # Extract path parameters import re params = re.findall(r'\{([^}]+)\}', path) # Check parameter naming (should be snake_case) 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}"