Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions tests/unit/test_api_specification_static.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""
Static validation tests for API endpoint definitions to ensure they have proper FastAPI decorator parameters.
This tests the code structure without running the full application.
"""
import os
import ast
import sys
from typing import Dict, List, Set

# Add the project root to the path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))

def get_fastapi_decorators_from_file(file_path: str) -> List[Dict]:
"""Extract FastAPI decorators and their parameters from a Python file."""
with open(file_path, 'r') as f:
tree = ast.parse(f.read())

decorators = []

for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
for decorator in node.decorator_list:
if isinstance(decorator, ast.Call):
# Check if it's a router method call (e.g., @router.get, @router.post, etc.)
if (isinstance(decorator.func, ast.Attribute) and
isinstance(decorator.func.value, ast.Name) and
decorator.func.value.id == 'router'):

method = decorator.func.attr
path = None
params = {}

# Get the path (first positional argument)
if decorator.args:
if isinstance(decorator.args[0], ast.Constant):
path = decorator.args[0].value

# Get keyword arguments
for keyword in decorator.keywords:
if isinstance(keyword.value, ast.Constant):
params[keyword.arg] = keyword.value.value
elif isinstance(keyword.value, ast.Name):
params[keyword.arg] = keyword.value.id
elif isinstance(keyword.value, ast.List):
# Handle list values like tags=["Jobs"]
list_items = []
for item in keyword.value.elts:
if isinstance(item, ast.Constant):
list_items.append(item.value)
params[keyword.arg] = list_items
elif isinstance(keyword.value, ast.Dict):
# Handle dict values like responses={}
params[keyword.arg] = "dict_value"

decorators.append({
'method': method,
'path': path,
'function_name': node.name,
'params': params
})

elif isinstance(decorator, ast.Attribute):
# Handle decorators like @workerfacing_app.get
if (isinstance(decorator.value, ast.Name) and
decorator.value.id == 'workerfacing_app'):
# This is likely from main.py root endpoint
decorators.append({
'method': decorator.attr,
'path': '/', # Root path
'function_name': node.name,
'params': {}
})

return decorators


def test_endpoints_have_required_fields():
"""Test that all endpoint decorators have the required FastAPI fields."""
endpoint_files = [
'workerfacing_api/endpoints/access.py',
'workerfacing_api/endpoints/files.py',
'workerfacing_api/endpoints/jobs.py',
'workerfacing_api/endpoints/jobs_post.py',
'workerfacing_api/main.py'
]

all_endpoints = []
for file_path in endpoint_files:
if os.path.exists(file_path):
decorators = get_fastapi_decorators_from_file(file_path)
all_endpoints.extend(decorators)

print(f"Found {len(all_endpoints)} endpoints to validate")

# Required fields for all endpoints
required_fields = ['description']

# Fields that should be present (either explicitly or through defaults)
expected_fields = ['response_model', 'status_code', 'responses']

issues = []

for endpoint in all_endpoints:
method = endpoint['method']
path = endpoint['path']
params = endpoint['params']
func_name = endpoint['function_name']

endpoint_id = f"{method.upper()} {path} ({func_name})"

# Check required fields
for field in required_fields:
if field not in params:
issues.append(f"{endpoint_id} missing {field}")

# Check for responses field
if 'responses' not in params:
issues.append(f"{endpoint_id} missing responses parameter")

# Check status_code for non-GET methods or specific cases
if method in ['post', 'put', 'delete'] and 'status_code' not in params:
issues.append(f"{endpoint_id} missing explicit status_code")

# Print all found endpoints for debugging
print("\nFound endpoints:")
for endpoint in all_endpoints:
print(f" {endpoint['method'].upper()} {endpoint['path']} - {endpoint['function_name']}")
print(f" Params: {list(endpoint['params'].keys())}")

if issues:
print(f"\nFound {len(issues)} issues:")
for issue in issues:
print(f" - {issue}")
assert False, f"Found {len(issues)} endpoint specification issues"
else:
print(f"\nAll {len(all_endpoints)} endpoints have proper specifications!")


def test_pydantic_models_have_examples():
"""Test that Pydantic models have Field examples for OpenAPI documentation."""
schema_files = [
'workerfacing_api/schemas/files.py',
'workerfacing_api/schemas/queue_jobs.py',
'workerfacing_api/schemas/responses.py'
]

models_with_examples = []
models_without_examples = []

for file_path in schema_files:
if os.path.exists(file_path):
with open(file_path, 'r') as f:
content = f.read()

# Check for Field with example parameter
if 'Field(' in content and 'example=' in content:
models_with_examples.append(file_path)
else:
# Check if there are any BaseModel classes that should have examples
tree = ast.parse(content)
has_models = any(
isinstance(node, ast.ClassDef) and
any(isinstance(base, ast.Name) and base.id == 'BaseModel' for base in node.bases)
for node in ast.walk(tree)
)
if has_models and file_path not in models_with_examples:
models_without_examples.append(file_path)

print(f"Schema files with examples: {models_with_examples}")
print(f"Schema files that may need examples: {models_without_examples}")

# For this test, we'll check that at least some models have examples
assert len(models_with_examples) > 0, "No Pydantic models found with Field examples"


if __name__ == "__main__":
test_endpoints_have_required_fields()
test_pydantic_models_have_examples()
print("All static validation tests passed!")
188 changes: 188 additions & 0 deletions tests/unit/test_openapi_spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""
Tests to validate that all API endpoints have proper OpenAPI specification including:
- response_model
- status_code
- responses
- description
- examples
"""
import json
import sys
import os

# Add the project root to the path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))

import pytest
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient

# Mock all the dependencies that require external services
mock_modules = [
'workerfacing_api.dependencies',
'workerfacing_api.settings',
'workerfacing_api.core.filesystem',
'workerfacing_api.core.queue',
'workerfacing_api.crud',
'fastapi_utils.tasks',
'sqlalchemy',
]

for module in mock_modules:
sys.modules[module] = MagicMock()

# Mock repeat_every decorator
def mock_repeat_every(*args, **kwargs):
def decorator(func):
return func
return decorator

sys.modules['fastapi_utils.tasks'].repeat_every = mock_repeat_every

# Now import the app
from workerfacing_api.main import workerfacing_app


def test_openapi_schema_structure():
"""Test that the OpenAPI schema contains expected fields for all endpoints."""
# Mock environment variables
with patch.dict(os.environ, {
'COGNITO_USER_POOL_ID': 'test-pool',
'COGNITO_CLIENT_ID': 'test-client',
'COGNITO_REGION': 'us-east-1'
}):
with TestClient(workerfacing_app) as client:
response = client.get("/openapi.json")
assert response.status_code == 200

openapi_spec = response.json()
paths = openapi_spec["paths"]

# Expected paths from the endpoints
expected_paths = [
"/",
"/access_info",
"/files/{file_id}/download",
"/files/{file_id}/url",
"/jobs",
"/jobs/{job_id}/status",
"/jobs/{job_id}/files/upload",
"/jobs/{job_id}/files/url",
"/_jobs"
]

for path in expected_paths:
assert path in paths, f"Path {path} not found in OpenAPI spec"


def test_endpoints_have_descriptions():
"""Test that all endpoints have descriptions."""
with patch.dict(os.environ, {
'COGNITO_USER_POOL_ID': 'test-pool',
'COGNITO_CLIENT_ID': 'test-client',
'COGNITO_REGION': 'us-east-1'
}):
with TestClient(workerfacing_app) as client:
response = client.get("/openapi.json")
openapi_spec = response.json()
paths = openapi_spec["paths"]

for path, methods in paths.items():
for method, spec in methods.items():
assert "description" in spec, f"Endpoint {method.upper()} {path} missing description"
assert spec["description"], f"Endpoint {method.upper()} {path} has empty description"


def test_endpoints_have_responses():
"""Test that all endpoints have responses defined."""
with patch.dict(os.environ, {
'COGNITO_USER_POOL_ID': 'test-pool',
'COGNITO_CLIENT_ID': 'test-client',
'COGNITO_REGION': 'us-east-1'
}):
with TestClient(workerfacing_app) as client:
response = client.get("/openapi.json")
openapi_spec = response.json()
paths = openapi_spec["paths"]

for path, methods in paths.items():
for method, spec in methods.items():
assert "responses" in spec, f"Endpoint {method.upper()} {path} missing responses"
responses = spec["responses"]
assert len(responses) > 0, f"Endpoint {method.upper()} {path} has no response codes defined"

# Check for success status codes
success_codes = [code for code in responses.keys() if code.startswith("2")]
assert len(success_codes) > 0, f"Endpoint {method.upper()} {path} has no success response codes"


def test_schemas_have_examples():
"""Test that key schemas have examples for OpenAPI documentation."""
with patch.dict(os.environ, {
'COGNITO_USER_POOL_ID': 'test-pool',
'COGNITO_CLIENT_ID': 'test-client',
'COGNITO_REGION': 'us-east-1'
}):
with TestClient(workerfacing_app) as client:
response = client.get("/openapi.json")
openapi_spec = response.json()
components = openapi_spec.get("components", {})
schemas = components.get("schemas", {})

# Key schemas that should have examples
schema_examples_to_check = [
"FileHTTPRequest",
"HardwareSpecs",
"MetaSpecs",
"AppSpecs",
"HandlerSpecs",
"PathsUploadSpecs",
"SubmittedJob",
"WelcomeMessage"
]

for schema_name in schema_examples_to_check:
if schema_name in schemas:
schema = schemas[schema_name]
properties = schema.get("properties", {})

# Check if any properties have examples
has_examples = any("example" in prop for prop in properties.values())
assert has_examples, f"Schema {schema_name} should have examples in its properties"


def test_root_endpoint_specification():
"""Test that the root endpoint has proper API specification."""
with patch.dict(os.environ, {
'COGNITO_USER_POOL_ID': 'test-pool',
'COGNITO_CLIENT_ID': 'test-client',
'COGNITO_REGION': 'us-east-1'
}):
with TestClient(workerfacing_app) as client:
response = client.get("/openapi.json")
openapi_spec = response.json()

root_spec = openapi_spec["paths"]["/"]["get"]

# Check all required fields are present
assert "description" in root_spec
assert "responses" in root_spec
assert "200" in root_spec["responses"]
assert "tags" in root_spec

# Test the actual endpoint response
root_response = client.get("/")
assert root_response.status_code == 200
data = root_response.json()
assert "message" in data
assert data["message"] == "Welcome to the DECODE OpenCloud Worker-facing API"


if __name__ == "__main__":
# Can run directly for quick testing
test_openapi_schema_structure()
test_endpoints_have_descriptions()
test_endpoints_have_responses()
test_schemas_have_examples()
test_root_endpoint_specification()
print("All OpenAPI specification tests passed!")
4 changes: 4 additions & 0 deletions workerfacing_api/endpoints/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ class AccessType(enum.Enum):
@router.get(
"/access_info",
response_model=dict[AccessType, dict[str, str | None]],
status_code=200,
responses={
200: {"description": "Access information retrieved successfully"},
},
description="Get information about where API users should authenticate.",
)
def get_access_info() -> dict[AccessType, dict[str, str | None]]:
Expand Down
Loading
Loading