AnemiaLens / backend /tests /test_api_integration.py
asnannp's picture
sync: sync backend code, models, schemas, and API routers to Hugging Face Space cleanly
f559cc0
Raw
History Blame Contribute Delete
15.9 kB
"""
Integration tests for API endpoints.
Covers:
- POST /api/analyze (full screening pipeline)
- POST /api/quality-check
- POST /api/guidance/chat
- Root redirect
- Error handling (413, 415, 422)
- Middleware behavior (request ID, CORS, rate limiting)
"""
from __future__ import annotations
import io
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from PIL import Image
def _create_test_image(size: tuple[int, int] = (200, 200), color: tuple = (140, 90, 80)) -> bytes:
"""Create a test image and return its bytes."""
img = Image.new("RGB", size, color=color)
buf = io.BytesIO()
img.save(buf, format="JPEG")
return buf.getvalue()
def _create_test_image_png(size: tuple[int, int] = (200, 200), color: tuple = (140, 90, 80)) -> bytes:
"""Create a test PNG image and return its bytes."""
img = Image.new("RGB", size, color=color)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
# ---------------------------------------------------------------------------
# Root redirect
# ---------------------------------------------------------------------------
class TestRootEndpoint:
def test_root_redirects_to_docs(self) -> None:
from app.main import app
client = TestClient(app, follow_redirects=False)
response = client.get("/")
assert response.status_code == 307
assert "/docs" in response.headers["location"]
# ---------------------------------------------------------------------------
# POST /api/analyze
# ---------------------------------------------------------------------------
class TestAnalyzeEndpoint:
def test_analyze_with_jpeg_image(self) -> None:
from app.main import app
client = TestClient(app)
image_bytes = _create_test_image()
response = client.post(
"/api/analyze",
files={"image": ("test.jpg", image_bytes, "image/jpeg")},
)
# May be 200 (model ready) or some error if model not loaded
assert response.status_code in (200, 500)
if response.status_code == 200:
data = response.json()
assert "blocked" in data
assert "quality" in data
assert "analysis_meta" in data
assert "request_id" in data.get("analysis_meta", {})
def test_analyze_with_png_image(self) -> None:
from app.main import app
client = TestClient(app)
image_bytes = _create_test_image_png()
response = client.post(
"/api/analyze",
files={"image": ("test.png", image_bytes, "image/png")},
)
assert response.status_code in (200, 500)
def test_analyze_with_symptoms(self) -> None:
from app.main import app
client = TestClient(app)
image_bytes = _create_test_image()
response = client.post(
"/api/analyze",
files={"image": ("test.jpg", image_bytes, "image/jpeg")},
data={"symptoms": json.dumps({"fatigue": True, "dizziness": False})},
)
assert response.status_code in (200, 422, 500)
def test_analyze_with_patient_profile(self) -> None:
from app.main import app
client = TestClient(app)
image_bytes = _create_test_image()
profile = json.dumps({"age": 30, "sex": "female"})
response = client.post(
"/api/analyze",
files={"image": ("test.jpg", image_bytes, "image/jpeg")},
data={"patient_profile": profile},
)
assert response.status_code in (200, 422, 500)
def test_analyze_with_language(self) -> None:
from app.main import app
client = TestClient(app)
image_bytes = _create_test_image()
response = client.post(
"/api/analyze",
files={"image": ("test.jpg", image_bytes, "image/jpeg")},
data={"language": "es"},
)
assert response.status_code in (200, 422, 500)
def test_analyze_with_region(self) -> None:
from app.main import app
client = TestClient(app)
image_bytes = _create_test_image()
response = client.post(
"/api/analyze",
files={"image": ("test.jpg", image_bytes, "image/jpeg")},
data={"region": "LATAM"},
)
assert response.status_code in (200, 422, 500)
def test_analyze_response_has_request_id_header(self) -> None:
from app.main import app
client = TestClient(app)
image_bytes = _create_test_image()
response = client.post(
"/api/analyze",
files={"image": ("test.jpg", image_bytes, "image/jpeg")},
)
assert "x-request-id" in response.headers
assert "x-response-time" in response.headers
def test_analyze_with_all_parameters(self) -> None:
from app.main import app
client = TestClient(app)
image_bytes = _create_test_image()
response = client.post(
"/api/analyze",
files={"image": ("test.jpg", image_bytes, "image/jpeg")},
data={
"symptoms": json.dumps({"fatigue": True}),
"patient_profile": json.dumps({"age": 25, "sex": "female"}),
"language": "en",
"region": "US",
},
)
assert response.status_code in (200, 422, 500)
# ---------------------------------------------------------------------------
# POST /api/quality-check
# ---------------------------------------------------------------------------
class TestQualityCheckEndpoint:
def test_quality_check_with_jpeg(self) -> None:
from app.main import app
client = TestClient(app)
image_bytes = _create_test_image()
response = client.post(
"/api/quality-check",
files={"image": ("test.jpg", image_bytes, "image/jpeg")},
)
# May be 200 or error depending on quality service state
assert response.status_code in (200, 415, 500)
if response.status_code == 200:
data = response.json()
assert "quality" in data
def test_quality_check_with_png(self) -> None:
from app.main import app
client = TestClient(app)
image_bytes = _create_test_image_png()
response = client.post(
"/api/quality-check",
files={"image": ("test.png", image_bytes, "image/png")},
)
assert response.status_code in (200, 415, 500)
def test_quality_check_returns_roi_preview(self) -> None:
from app.main import app
client = TestClient(app)
image_bytes = _create_test_image()
response = client.post(
"/api/quality-check",
files={"image": ("test.jpg", image_bytes, "image/jpeg")},
)
if response.status_code == 200:
data = response.json()
assert "roi_preview" in data
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
class TestErrorHandling:
def test_rejects_non_image_file(self) -> None:
from app.main import app
client = TestClient(app)
response = client.post(
"/api/quality-check",
files={"image": ("test.txt", b"not an image", "text/plain")},
)
assert response.status_code in (415, 422, 500)
def test_rejects_invalid_image_data(self) -> None:
from app.main import app
client = TestClient(app)
response = client.post(
"/api/quality-check",
files={"image": ("test.jpg", b"\xff\xd8\xff\xe0invalid_jpeg_data", "image/jpeg")},
)
# Should return 415 (unsupported media) or 422 (unprocessable)
assert response.status_code in (415, 422, 500)
def test_analyze_rejects_non_image_file(self) -> None:
from app.main import app
client = TestClient(app)
response = client.post(
"/api/analyze",
files={"image": ("test.txt", b"not an image", "text/plain")},
)
assert response.status_code in (415, 422, 500)
def test_rejects_oversized_image(self) -> None:
"""Test that very large images are rejected with 413."""
from app.main import app
from app.config import settings
client = TestClient(app)
# Create an image larger than the limit
max_bytes = settings.max_image_bytes
oversized = b"\x00" * (max_bytes + 1024)
response = client.post(
"/api/analyze",
files={"image": ("huge.jpg", oversized, "image/jpeg")},
)
assert response.status_code in (413, 500)
def test_error_response_has_request_id(self) -> None:
from app.main import app
client = TestClient(app)
response = client.post(
"/api/quality-check",
files={"image": ("test.txt", b"not an image", "text/plain")},
)
# Even error responses should have request ID
assert "x-request-id" in response.headers
# ---------------------------------------------------------------------------
# POST /api/guidance/chat
# ---------------------------------------------------------------------------
class TestGuidanceChatEndpoint:
def test_guidance_chat_with_valid_payload(self) -> None:
from app.main import app
from app.schemas import QualityAssessment
client = TestClient(app)
# Create a minimal analysis payload
analysis_payload = {
"blocked": False,
"quality": {
"passed": True,
"blur_score": 150.0,
"brightness_score": 0.3,
"contrast_score": 0.15,
"framing_score": 1.5,
"issues": [],
},
"prediction": {
"anemia_risk": 0.45,
"predicted_hemoglobin": 12.5,
"confidence": 0.7,
"uncertainty": 0.3,
"reliability_flag": "medium",
"screening_label": "uncertain",
"screening_text": "Uncertain result.",
"model_source": "test",
},
"triage": {
"level": "moderate",
"text": "Moderate concern.",
"anemia_risk": 0.45,
"action": "consult_provider",
},
"decision_audit": {
"quality_assessment": "Image quality acceptable.",
"model_analysis": "Model prediction with medium confidence.",
"triage_rationale": "Moderate risk level.",
"guidance_summary": "Consult a provider.",
},
"guidance": {
"summary": "Consult a provider for confirmation.",
"immediate_actions": [],
"monitoring": [],
"prevention": [],
"disclaimer": "This is screening only.",
},
}
payload = {
"analysis": analysis_payload,
"message": "What should I do next?",
"history": [],
}
response = client.post(
"/api/guidance/chat",
json=payload,
)
# May succeed or fail depending on guidance service availability
assert response.status_code in (200, 422, 500)
def test_guidance_chat_without_analysis_returns_422(self) -> None:
from app.main import app
client = TestClient(app)
response = client.post(
"/api/guidance/chat",
json={"message": "Hello"},
)
assert response.status_code in (422, 500)
# ---------------------------------------------------------------------------
# Middleware behavior
# ---------------------------------------------------------------------------
class TestMiddlewareBehavior:
def test_cors_headers_present(self) -> None:
from app.main import app
client = TestClient(app)
response = client.options(
"/health",
headers={"Origin": "http://localhost:5173"},
)
# CORS preflight or the response should have CORS headers
assert "access-control-allow-origin" in response.headers or response.status_code in (200, 405)
def test_request_id_on_health_endpoint(self) -> None:
from app.main import app
client = TestClient(app)
response = client.get("/health")
assert "x-request-id" in response.headers
def test_response_time_header_present(self) -> None:
from app.main import app
client = TestClient(app)
response = client.get("/readyz")
assert "x-response-time" in response.headers
def test_request_id_is_unique(self) -> None:
from app.main import app
client = TestClient(app)
r1 = client.get("/health")
r2 = client.get("/health")
id1 = r1.headers.get("x-request-id")
id2 = r2.headers.get("x-request-id")
# Both should have IDs
assert id1 is not None
assert id2 is not None
# They should be different (probability of collision is negligible)
assert id1 != id2
# ---------------------------------------------------------------------------
# Auth routes (basic smoke tests)
# ---------------------------------------------------------------------------
class TestAuthRoutes:
def test_register_returns_422_without_required_fields(self) -> None:
from app.main import app
client = TestClient(app)
response = client.post("/auth/register", json={})
assert response.status_code in (422, 500)
def test_login_returns_422_without_credentials(self) -> None:
from app.main import app
client = TestClient(app)
response = client.post("/auth/login", json={})
assert response.status_code in (422, 500)
def test_profile_requires_auth(self) -> None:
from app.main import app
client = TestClient(app)
response = client.get("/auth/profile")
# Should be 401 (unauthorized) or 422 if token parsing fails
assert response.status_code in (401, 422, 500)
# ---------------------------------------------------------------------------
# History routes (basic smoke tests)
# ---------------------------------------------------------------------------
class TestHistoryRoutes:
def test_history_requires_auth(self) -> None:
from app.main import app
client = TestClient(app)
response = client.get("/api/history")
assert response.status_code in (401, 422, 500)
# ---------------------------------------------------------------------------
# Admin routes (basic smoke tests)
# ---------------------------------------------------------------------------
class TestAdminRoutes:
def test_admin_requires_auth(self) -> None:
from app.main import app
client = TestClient(app)
response = client.get("/api/admin/stats")
assert response.status_code in (401, 403, 422, 500)
# ---------------------------------------------------------------------------
# API route module imports
# ---------------------------------------------------------------------------
class TestApiModules:
def test_auth_router_imported(self) -> None:
from app.main import app
routes = [r.path for r in app.routes]
# Auth routes should be registered
auth_routes = [r for r in routes if r.startswith("/auth")]
assert len(auth_routes) > 0
def test_history_router_imported(self) -> None:
from app.main import app
routes = [r.path for r in app.routes]
history_routes = [r for r in routes if "/history" in r]
assert len(history_routes) > 0