clarke / tests /test_fhir_tools_queries.py
yashvshetty's picture
Clarke: NHS clinical documentation system
9636a02
"""Tests for FHIR async client, tool wrappers, and deterministic query aggregation."""
from __future__ import annotations
import asyncio
from typing import Any
import httpx
import pytest
from backend.errors import FHIRClientError
from backend.fhir import mock_api
from backend.fhir.client import FHIRClient
from backend.fhir import tools, queries
@pytest.fixture
def asgi_client_patch(monkeypatch: pytest.MonkeyPatch) -> None:
"""Patch httpx.AsyncClient to target mock FHIR FastAPI app via ASGI transport.
Args:
monkeypatch (pytest.MonkeyPatch): Pytest monkeypatch fixture.
Returns:
None: Applies monkeypatch side effects for test duration.
"""
transport = httpx.ASGITransport(app=mock_api.app)
original_async_client = httpx.AsyncClient
def build_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
kwargs["transport"] = transport
return original_async_client(*args, **kwargs)
monkeypatch.setattr("backend.fhir.client.httpx.AsyncClient", build_client)
def test_fhir_client_reads_core_endpoints(asgi_client_patch: None) -> None:
"""Verify FHIRClient returns JSON payloads for patient-scoped endpoint queries.
Args:
asgi_client_patch (None): Applied fixture to route requests to ASGI app.
Returns:
None: Assertions verify successful responses.
"""
client = FHIRClient(fhir_server_url="http://testserver/fhir", timeout_s=5)
patient = asyncio.run(client.get_patient("pt-001"))
observations = asyncio.run(client.get_observations("pt-001"))
assert patient["resourceType"] == "Patient"
assert observations["resourceType"] == "Bundle"
assert observations["total"] >= 1
def test_fhir_client_404_returns_empty_dict(asgi_client_patch: None) -> None:
"""Verify unknown patient requests return empty dictionaries for 404s.
Args:
asgi_client_patch (None): Applied fixture to route requests to ASGI app.
Returns:
None: Assertions verify 404-empty behaviour.
"""
client = FHIRClient(fhir_server_url="http://testserver/fhir", timeout_s=5)
missing = asyncio.run(client.get_patient("pt-999"))
assert missing == {}
def test_fhir_client_retries_once_on_5xx(monkeypatch: pytest.MonkeyPatch) -> None:
"""Verify a single retry occurs after initial server-side error responses.
Args:
monkeypatch (pytest.MonkeyPatch): Pytest monkeypatch fixture.
Returns:
None: Assertions validate retry semantics.
"""
attempts = {"count": 0}
def handler(request: httpx.Request) -> httpx.Response:
attempts["count"] += 1
if attempts["count"] == 1:
return httpx.Response(status_code=500, json={"detail": "temporary"})
return httpx.Response(status_code=200, json={"resourceType": "Bundle", "entry": [], "total": 0})
transport = httpx.MockTransport(handler)
original_async_client = httpx.AsyncClient
def build_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
kwargs["transport"] = transport
return original_async_client(*args, **kwargs)
monkeypatch.setattr("backend.fhir.client.httpx.AsyncClient", build_client)
client = FHIRClient(fhir_server_url="http://retry/fhir", timeout_s=5)
response = asyncio.run(client.get_conditions("pt-001"))
assert response["resourceType"] == "Bundle"
assert attempts["count"] == 2
def test_fhir_client_timeout_raises_contextual_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""Verify timeout exceptions are raised with contextual FHIR endpoint details.
Args:
monkeypatch (pytest.MonkeyPatch): Pytest monkeypatch fixture.
Returns:
None: Assertion verifies timeout failure semantics.
"""
class TimeoutClient:
"""Minimal async context manager that always raises timeout on GET.
Args:
None: Created by monkeypatch in tests.
Returns:
None: Helper class for timeout simulation.
"""
async def __aenter__(self) -> "TimeoutClient":
"""Enter async context manager.
Args:
None: No parameters.
Returns:
TimeoutClient: Context-managed instance.
"""
return self
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
"""Exit async context manager.
Args:
exc_type (Any): Exception type when raised.
exc (Any): Exception instance when raised.
tb (Any): Traceback when raised.
Returns:
None: No exception suppression.
"""
return None
async def get(self, url: str, params: dict[str, Any] | None = None) -> httpx.Response:
"""Raise timeout for every request.
Args:
url (str): Requested URL string.
params (dict[str, Any] | None): Request query parameters.
Returns:
httpx.Response: Never returns because timeout is raised.
"""
raise httpx.TimeoutException("simulated timeout")
monkeypatch.setattr("backend.fhir.client.httpx.AsyncClient", lambda *a, **k: TimeoutClient())
client = FHIRClient(fhir_server_url="http://timeout/fhir", timeout_s=1)
with pytest.raises(FHIRClientError, match="timeout"):
asyncio.run(client.get_observations("pt-001"))
def test_tools_and_queries_aggregate_expected_lists(asgi_client_patch: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""Verify tool wrappers and query aggregation return resource lists for pt-001.
Args:
asgi_client_patch (None): Applied fixture to route requests to ASGI app.
monkeypatch (pytest.MonkeyPatch): Fixture for replacing default tool client.
Returns:
None: Assertions validate list extraction and aggregation keys.
"""
patched_client = FHIRClient(fhir_server_url="http://testserver/fhir", timeout_s=5)
monkeypatch.setattr(tools, "DEFAULT_FHIR_CLIENT", patched_client)
patients = asyncio.run(tools.search_patients("Thompson"))
conditions = asyncio.run(tools.get_conditions("pt-001"))
context = asyncio.run(queries.get_full_patient_context("pt-001"))
assert patients
assert conditions
assert context["medications"]
assert context["observations"]
assert context["allergies"]
assert "encounters" in context