Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |