diff --git a/gateway-api/src/gateway_api/common/__init__.py b/gateway-api/src/gateway_api/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/gateway_api/common/common.py b/gateway-api/src/gateway_api/common/common.py new file mode 100644 index 00000000..ab25528f --- /dev/null +++ b/gateway-api/src/gateway_api/common/common.py @@ -0,0 +1,63 @@ +""" +Shared lightweight types and helpers used across the gateway API. +""" + +import re +from dataclasses import dataclass + +# This project uses JSON request/response bodies as strings in the controller layer. +# The alias is used to make intent clearer in function signatures. +type json_str = str + + +@dataclass +class FlaskResponse: + """ + Lightweight response container returned by controller entry points. + + This mirrors the minimal set of fields used by the surrounding web framework. + + :param status_code: HTTP status code for the response (e.g., 200, 400, 404). + :param data: Response body as text, if any. + :param headers: Response headers, if any. + """ + + status_code: int + data: str | None = None + headers: dict[str, str] | None = None + + +def validate_nhs_number(value: str | int) -> bool: + """ + Validate an NHS number using the NHS modulus-11 check digit algorithm. + + The input may be a string or integer. Any non-digit separators in string + inputs (spaces, hyphens, etc.) are ignored. + + :param value: NHS number as a string or integer. Non-digit characters + are ignored when a string is provided. + :returns: ``True`` if the number is a valid NHS number, otherwise ``False``. + """ + str_value = str(value) # Just in case they passed an integer + digits = re.sub(r"\D", "", str_value or "") + + if len(digits) != 10: + return False + if not digits.isdigit(): + return False + + first_nine = [int(ch) for ch in digits[:9]] + provided_check_digit = int(digits[9]) + + weights = list(range(10, 1, -1)) + total = sum(d * w for d, w in zip(first_nine, weights, strict=True)) + + remainder = total % 11 + check = 11 - remainder + + if check == 11: + check = 0 + if check == 10: + return False # invalid NHS number + + return check == provided_check_digit diff --git a/gateway-api/src/gateway_api/common/py.typed b/gateway-api/src/gateway_api/common/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/gateway_api/common/test_common.py b/gateway-api/src/gateway_api/common/test_common.py new file mode 100644 index 00000000..ee19aa8b --- /dev/null +++ b/gateway-api/src/gateway_api/common/test_common.py @@ -0,0 +1,60 @@ +""" +Unit tests for :mod:`gateway_api.common.common`. +""" + +from gateway_api.common import common + + +def test_validate_nhs_number_accepts_valid_number_with_separators() -> None: + """ + Validate that separators (spaces, hyphens) are ignored and valid numbers pass. + """ + assert common.validate_nhs_number("943 476 5919") is True + assert common.validate_nhs_number("943-476-5919") is True + assert common.validate_nhs_number(9434765919) is True + + +def test_validate_nhs_number_rejects_wrong_length_and_bad_check_digit() -> None: + """Validate that incorrect lengths and invalid check digits are rejected.""" + assert common.validate_nhs_number("") is False + assert common.validate_nhs_number("943476591") is False # 9 digits + assert common.validate_nhs_number("94347659190") is False # 11 digits + assert common.validate_nhs_number("9434765918") is False # wrong check digit + + +def test_validate_nhs_number_returns_false_for_non_ten_digits_and_non_numeric() -> None: + """ + validate_nhs_number should return False when: + - The number of digits is not exactly 10. + - The input is not numeric. + + Notes: + - The implementation strips non-digit characters before validation, so a fully + non-numeric input becomes an empty digit string and is rejected. + """ + # Not ten digits after stripping -> False + assert common.validate_nhs_number("123456789") is False + assert common.validate_nhs_number("12345678901") is False + + # Not numeric -> False (becomes 0 digits after stripping) + assert common.validate_nhs_number("NOT_A_NUMBER") is False + + +def test_validate_nhs_number_check_edge_cases_10_and_11() -> None: + """ + validate_nhs_number should behave correctly when the computed ``check`` value + is 10 or 11. + + - If ``check`` computes to 11, it should be treated as 0, so a number with check + digit 0 should validate successfully. + - If ``check`` computes to 10, the number is invalid and validation should return + False. + """ + # All zeros => weighted sum 0 => remainder 0 => check 11 => mapped to 0 => valid + # with check digit 0 + assert common.validate_nhs_number("0000000000") is True + + # First nine digits produce remainder 1 => check 10 => invalid regardless of + # final digit + # Choose d9=6 and others 0: total = 6*2 = 12 => 12 % 11 = 1 => check = 10 + assert common.validate_nhs_number("0000000060") is False diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py new file mode 100644 index 00000000..09a6be4c --- /dev/null +++ b/gateway-api/src/gateway_api/controller.py @@ -0,0 +1,448 @@ +""" +Controller layer for orchestrating calls to external services +""" + +from __future__ import annotations + +import json + +__all__ = ["json"] # Make mypy happy in tests + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + import requests + +from gateway_api.common.common import FlaskResponse, json_str, validate_nhs_number +from gateway_api.pds_search import PdsClient, PdsSearchResults + + +@dataclass +class RequestError(Exception): + """ + Raised (and handled) when there is a problem with the incoming request. + + Instances of this exception are caught by controller entry points and converted + into an appropriate :class:`FlaskResponse`. + + :param status_code: HTTP status code that should be returned. + :param message: Human-readable error message. + """ + + status_code: int + message: str + + def __str__(self) -> str: + """ + Coercing this exception to a string returns the error message. + + :returns: The error message. + """ + return self.message + + +@dataclass +class SdsSearchResults: + """ + Stub SDS search results dataclass. + + Replace this with the real one once it's implemented. + + :param asid: Accredited System ID. + :param endpoint: Endpoint URL associated with the organisation, if applicable. + """ + + asid: str + endpoint: str | None + + +class SdsClient: + """ + Stub SDS client for obtaining ASID from ODS code. + + Replace this with the real one once it's implemented. + """ + + SANDBOX_URL = "https://example.invalid/sds" + + def __init__( + self, + auth_token: str, + base_url: str = SANDBOX_URL, + timeout: int = 10, + ) -> None: + """ + Create an SDS client. + + :param auth_token: Authentication token to present to SDS. + :param base_url: Base URL for SDS. + :param timeout: Timeout in seconds for SDS calls. + """ + self.auth_token = auth_token + self.base_url = base_url + self.timeout = timeout + + def get_org_details(self, ods_code: str) -> SdsSearchResults | None: + """ + Retrieve SDS org details for a given ODS code. + + This is a placeholder implementation that always returns an ASID and endpoint. + + :param ods_code: ODS code to look up. + :returns: SDS search results or ``None`` if not found. + """ + # Placeholder implementation + return SdsSearchResults( + asid=f"asid_{ods_code}", endpoint="https://example-provider.org/endpoint" + ) + + +class GpProviderClient: + """ + Stub GP provider client for obtaining patient records. + + Replace this with the real one once it's implemented. + """ + + SANDBOX_URL = "https://example.invalid/gpprovider" + + def __init__( + self, + provider_endpoint: str, # Obtain from ODS + provider_asid: str, + consumer_asid: str, + ) -> None: + """ + Create a GP provider client. + + :param provider_endpoint: Provider endpoint obtained from SDS. + :param provider_asid: Provider ASID obtained from SDS. + :param consumer_asid: Consumer ASID obtained from SDS. + """ + self.provider_endpoint = provider_endpoint + self.provider_asid = provider_asid + self.consumer_asid = consumer_asid + + def access_structured_record( + self, + trace_id: str, # NOSONAR S1172 (ignore in stub) + body: json_str, # NOSONAR S1172 (ignore in stub) + nhsnumber: str, # NOSONAR S1172 (ignore in stub) + ) -> requests.Response | None: + """ + Retrieve a patient's structured record from GP provider. + + This stub just returns None, the real thing will be more interesting! + + :param trace_id: Correlation/trace identifier for request tracking. + :param body: Original request body. + :param nhsnumber: NHS number as a string. + :returns: A ``requests.Response`` if the call was made, otherwise ``None``. + """ + # Placeholder implementation + return None + + +class Controller: + """ + Orchestrates calls to PDS -> SDS -> GP provider. + + Entry point: + - ``call_gp_provider(request_body_json, headers, auth_token) -> FlaskResponse`` + """ + + gp_provider_client: GpProviderClient | None + + def __init__( + self, + pds_base_url: str = PdsClient.SANDBOX_URL, + sds_base_url: str = "https://example.invalid/sds", + nhsd_session_urid: str | None = None, + timeout: int = 10, + ) -> None: + """ + Create a controller instance. + + :param pds_base_url: Base URL for PDS client. + :param sds_base_url: Base URL for SDS client. + :param nhsd_session_urid: Session URID for NHS Digital session handling. + :param timeout: Timeout in seconds for downstream calls. + """ + self.pds_base_url = pds_base_url + self.sds_base_url = sds_base_url + self.nhsd_session_urid = nhsd_session_urid + self.timeout = timeout + self.gp_provider_client = None + + def _get_details_from_body(self, request_body: json_str) -> int: + """ + Parse request JSON and extract the NHS number as an integer. + + :param request_body: JSON request body containing an ``"nhs-number"`` field. + :returns: NHS number as an integer. + :raises RequestError: If the request body is invalid, missing fields, or + contains an invalid NHS number. + """ + # Extract NHS number from request body + try: + body: Any = json.loads(request_body) + except (TypeError, json.JSONDecodeError): + raise RequestError( + status_code=400, + message='Request body must be valid JSON with an "nhs-number" field', + ) from None + + # Guard: require "dict-like" semantics without relying on isinstance checks. + if not ( + hasattr(body, "__getitem__") and hasattr(body, "get") + ): # Must be a dict-like object + raise RequestError( + status_code=400, + message='Request body must be a JSON object with an "nhs-number" field', + ) from None + + nhs_number_value = body.get("nhs-number") + if nhs_number_value is None: + raise RequestError( + status_code=400, + message='Missing required field "nhs-number" in JSON request body', + ) from None + + try: + nhs_number_int = _coerce_nhs_number_to_int(nhs_number_value) + except ValueError: + raise RequestError( + status_code=400, + message=f'Could not cast NHS number "{nhs_number_value}" to an integer', + ) from None + + return nhs_number_int + + def _get_pds_details( + self, auth_token: str, consumer_ods: str, nhs_number: int + ) -> str: + """ + Call PDS to find the provider ODS code (GP ODS code) for a patient. + + :param auth_token: Authorization token to use for PDS. + :param consumer_ods: Consumer organisation ODS code (from request headers). + :param nhs_number: NHS number (already coerced to an integer). + :returns: Provider ODS code (GP ODS code). + :raises RequestError: If the patient cannot be found or has no provider ODS code + """ + # PDS: find patient and extract GP ODS code (provider ODS) + pds = PdsClient( + auth_token=auth_token, + end_user_org_ods=consumer_ods, + base_url=self.pds_base_url, + nhsd_session_urid=self.nhsd_session_urid, + timeout=self.timeout, + ) + + pds_result: PdsSearchResults | None = pds.search_patient_by_nhs_number( + nhs_number + ) + + if pds_result is None: + raise RequestError( + status_code=404, + message=f"No PDS patient found for NHS number {nhs_number}", + ) + + if pds_result.gp_ods_code: + provider_ods_code = pds_result.gp_ods_code + else: + raise RequestError( + status_code=404, + message=( + f"PDS patient {nhs_number} did not contain a current " + "provider ODS code" + ), + ) + + return provider_ods_code + + def _get_sds_details( + self, auth_token: str, consumer_ods: str, provider_ods: str + ) -> tuple[str, str, str]: + """ + Call SDS to obtain consumer ASID, provider ASID, and provider endpoint. + + This method performs two SDS lookups: + - provider details (ASID + endpoint) + - consumer details (ASID) + + :param auth_token: Authorization token to use for SDS. + :param consumer_ods: Consumer organisation ODS code (from request headers). + :param provider_ods: Provider organisation ODS code (from PDS). + :returns: Tuple of (consumer_asid, provider_asid, provider_endpoint). + :raises RequestError: If SDS data is missing or incomplete for provider/consumer + """ + # SDS: Get provider details (ASID + endpoint) for provider ODS + sds = SdsClient( + auth_token=auth_token, + base_url=self.sds_base_url, + timeout=self.timeout, + ) + + provider_details: SdsSearchResults | None = sds.get_org_details(provider_ods) + if provider_details is None: + raise RequestError( + status_code=404, + message=f"No SDS org found for provider ODS code {provider_ods}", + ) + + provider_asid = (provider_details.asid or "").strip() + if not provider_asid: + raise RequestError( + status_code=404, + message=( + f"SDS result for provider ODS code {provider_ods} did not contain " + "a current ASID" + ), + ) + + provider_endpoint = (provider_details.endpoint or "").strip() + if not provider_endpoint: + raise RequestError( + status_code=404, + message=( + f"SDS result for provider ODS code {provider_ods} did not contain " + "a current endpoint" + ), + ) + + # SDS: Get consumer details (ASID) for consumer ODS + consumer_details: SdsSearchResults | None = sds.get_org_details(consumer_ods) + if consumer_details is None: + raise RequestError( + status_code=404, + message=f"No SDS org found for consumer ODS code {consumer_ods}", + ) + + consumer_asid = (consumer_details.asid or "").strip() + if not consumer_asid: + raise RequestError( + status_code=404, + message=( + f"SDS result for consumer ODS code {consumer_ods} did not contain " + "a current ASID" + ), + ) + + return consumer_asid, provider_asid, provider_endpoint + + def call_gp_provider( + self, + request_body: json_str, + headers: dict[str, str], + auth_token: str, + ) -> FlaskResponse: + """ + Controller entry point + + Expects a JSON request body containing an ``"nhs-number"`` field. + Also expects HTTP headers (from Flask) and extracts: + - ``Ods-from`` as the consumer organisation ODS code + - ``X-Request-ID`` as the trace/correlation ID + + Orchestration steps: + 1) Call PDS to obtain the patient's GP (provider) ODS code. + 2) Call SDS using provider ODS to obtain provider ASID + provider endpoint. + 3) Call SDS using consumer ODS to obtain consumer ASID. + 4) Call GP provider to obtain patient records. + + :param request_body: Raw JSON request body. + :param headers: HTTP headers from the request. + :param auth_token: Authorization token used for downstream services. + :returns: A :class:`~gateway_api.common.common.FlaskResponse` representing the + outcome. + """ + try: + nhs_number = self._get_details_from_body(request_body) + except RequestError as err: + return FlaskResponse( + status_code=err.status_code, + data=str(err), + ) + + # Extract consumer ODS from headers + consumer_ods = headers.get("Ods-from", "").strip() + if not consumer_ods: + return FlaskResponse( + status_code=400, + data='Missing required header "Ods-from"', + ) + + trace_id = headers.get("X-Request-ID") + if trace_id is None: + return FlaskResponse( + status_code=400, data="Missing required header: X-Request-ID" + ) + + try: + provider_ods = self._get_pds_details(auth_token, consumer_ods, nhs_number) + except RequestError as err: + return FlaskResponse(status_code=err.status_code, data=str(err)) + + try: + consumer_asid, provider_asid, provider_endpoint = self._get_sds_details( + auth_token, consumer_ods, provider_ods + ) + except RequestError as err: + return FlaskResponse(status_code=err.status_code, data=str(err)) + + # Call GP provider with correct parameters + self.gp_provider_client = GpProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + ) + + response = self.gp_provider_client.access_structured_record( + trace_id=trace_id, + body=request_body, + nhsnumber=str(nhs_number), + ) + + # If we get a None from the GP provider, that means that either the service did + # not respond or we didn't make the request to the service in the first place. + # Therefore a None is a 502, any real response just pass straight back. + return FlaskResponse( + status_code=response.status_code if response is not None else 502, + data=response.text if response is not None else "GP provider service error", + headers=dict(response.headers) if response is not None else None, + ) + + +def _coerce_nhs_number_to_int(value: str | int) -> int: + """ + Coerce an NHS number to an integer with basic validation. + + Notes: + - NHS numbers are 10 digits. + - Input may include whitespace (e.g., ``"943 476 5919"``). + + :param value: NHS number value, as a string or integer. + :returns: The coerced NHS number as an integer. + :raises ValueError: If the NHS number is non-numeric, the wrong length, or fails + validation. + """ + try: + stripped = cast("str", value).strip().replace(" ", "") + except AttributeError: + nhs_number_int = cast("int", value) + else: + if not stripped.isdigit(): + raise ValueError("NHS number must be numeric") + nhs_number_int = int(stripped) + + if len(str(nhs_number_int)) != 10: + # If you need to accept test numbers of different length, relax this. + raise ValueError("NHS number must be 10 digits") + + if not validate_nhs_number(nhs_number_int): + raise ValueError("NHS number is invalid") + + return nhs_number_int diff --git a/gateway-api/src/gateway_api/pds_search.py b/gateway-api/src/gateway_api/pds_search.py index cddcc056..4a17cc62 100644 --- a/gateway-api/src/gateway_api/pds_search.py +++ b/gateway-api/src/gateway_api/pds_search.py @@ -44,7 +44,7 @@ class ExternalServiceError(Exception): @dataclass -class SearchResults: +class PdsSearchResults: """ A single extracted patient record. @@ -74,7 +74,7 @@ class PdsClient: * :meth:`search_patient_by_nhs_number` - calls ``GET /Patient/{nhs_number}`` - This method returns a :class:`SearchResults` instance when a patient can be + This method returns a :class:`PdsSearchResults` instance when a patient can be extracted, otherwise ``None``. **Usage example**:: @@ -164,12 +164,12 @@ def search_patient_by_nhs_number( request_id: str | None = None, correlation_id: str | None = None, timeout: int | None = None, - ) -> SearchResults | None: + ) -> PdsSearchResults | None: """ Retrieve a patient by NHS number. Calls ``GET /Patient/{nhs_number}``, which returns a single FHIR Patient - resource on success, then extracts a single :class:`SearchResults`. + resource on success, then extracts a single :class:`PdsSearchResults`. :param nhs_number: NHS number to search for. :param request_id: Optional request ID to reuse for retries; if not supplied a @@ -177,7 +177,7 @@ def search_patient_by_nhs_number( :param correlation_id: Optional correlation ID for tracing. :param timeout: Optional per-call timeout in seconds. If not provided, :attr:`timeout` is used. - :return: A :class:`SearchResults` instance if a patient can be extracted, + :return: A :class:`PdsSearchResults` instance if a patient can be extracted, otherwise ``None``. :raises ExternalServiceError: If the HTTP request returns an error status and ``raise_for_status()`` raises :class:`requests.HTTPError`. @@ -241,9 +241,9 @@ def _get_gp_ods_code(self, general_practitioners: ResultList) -> str | None: def _extract_single_search_result( self, body: ResultStructureDict - ) -> SearchResults | None: + ) -> PdsSearchResults | None: """ - Extract a single :class:`SearchResults` from a Patient response. + Extract a single :class:`PdsSearchResults` from a Patient response. This helper accepts either: * a single FHIR Patient resource (as returned by ``GET /Patient/{id}``), or @@ -253,7 +253,7 @@ def _extract_single_search_result( single match; if multiple entries are present, the first entry is used. :param body: Parsed JSON body containing either a Patient resource or a Bundle whose first entry contains a Patient resource under ``resource``. - :return: A populated :class:`SearchResults` if extraction succeeds, otherwise + :return: A populated :class:`PdsSearchResults` if extraction succeeds, otherwise ``None``. """ # Accept either: @@ -294,7 +294,7 @@ def _extract_single_search_result( gp_list = cast("ResultList", patient.get("generalPractitioner", [])) gp_ods_code = self._get_gp_ods_code(gp_list) - return SearchResults( + return PdsSearchResults( given_names=given_names_str, family_name=family_name, nhs_number=nhs_number, diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py new file mode 100644 index 00000000..9901e99e --- /dev/null +++ b/gateway-api/src/gateway_api/test_controller.py @@ -0,0 +1,748 @@ +""" +Unit tests for :mod:`gateway_api.controller`. +""" + +from __future__ import annotations + +import json as std_json +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any + +import pytest +from requests import Response + +import gateway_api.controller as controller_module +from gateway_api.controller import ( + Controller, + SdsSearchResults, + _coerce_nhs_number_to_int, +) + +if TYPE_CHECKING: + from gateway_api.common.common import json_str + + +# ----------------------------- +# Helpers for request test data +# ----------------------------- +def make_request_body(nhs_number: str = "9434765919") -> json_str: + """ + Create a JSON request body string containing an ``"nhs-number"`` field. + + :param nhs_number: NHS number to embed in the request body. + :returns: JSON string payload suitable for + :meth:`gateway_api.controller.Controller.call_gp_provider`. + """ + # Controller expects a JSON string containing an "nhs-number" field. + return std_json.dumps({"nhs-number": nhs_number}) + + +def make_headers( + ods_from: str = "ORG1", + trace_id: str = "trace-123", +) -> dict[str, str]: + """ + Create the minimum required headers for controller entry points. + + :param ods_from: Value for the ``Ods-from`` header (consumer ODS code). + :param trace_id: Value for the ``X-Request-ID`` header (trace/correlation ID). + :returns: Header dictionary suitable for + :meth:`gateway_api.controller.Controller.call_gp_provider`. + """ + # Controller expects these headers: + # - Ods-from (consumer ODS) + # - X-Request-ID (trace id) + return {"Ods-from": ods_from, "X-Request-ID": trace_id} + + +# ----------------------------- +# Fake downstream dependencies +# ----------------------------- +def _make_pds_result(gp_ods_code: str | None) -> Any: + """ + Construct a minimal PDS-result-like object for tests. + + The controller only relies on the ``gp_ods_code`` attribute. + + :param gp_ods_code: Provider ODS code to expose on the result. + :returns: An object with a ``gp_ods_code`` attribute. + """ + # We only need .gp_ods_code for controller logic. + return SimpleNamespace(gp_ods_code=gp_ods_code) + + +class FakePdsClient: + """ + Test double for :class:`gateway_api.pds_search.PdsClient`. + + The controller instantiates this class and calls ``search_patient_by_nhs_number``. + Tests configure the returned patient details using ``set_patient_details``. + """ + + last_init: dict[str, Any] | None = None + + def __init__(self, **kwargs: Any) -> None: + """ + Capture constructor kwargs for later assertions. + + :param kwargs: Arbitrary keyword arguments passed by the controller. + """ + # Controller constructs PdsClient with kwargs; capture for assertions. + FakePdsClient.last_init = dict(kwargs) + self._patient_details: Any | None = None + + def set_patient_details(self, value: Any) -> None: + """ + Configure the value returned by ``search_patient_by_nhs_number``. + + :param value: Result-like object to return (or ``None`` to simulate not found). + """ + # Keep call sites explicit and "correct": pass a PDS-result-like object. + self._patient_details = value + + def search_patient_by_nhs_number(self, nhs_number: int) -> Any | None: + """ + Return the configured patient details. + + :param nhs_number: NHS number requested (not used by the fake). + :returns: Configured patient details or ``None``. + """ + return self._patient_details + + +class FakeSdsClient: + """ + Test double for :class:`gateway_api.controller.SdsClient`. + + Tests configure per-ODS results using ``set_org_details`` and the controller + retrieves them via ``get_org_details``. + """ + + last_init: dict[str, Any] | None = None + + def __init__( + self, + auth_token: str | None = None, + base_url: str = "test_url", + timeout: int = 10, + ) -> None: + """ + Capture constructor arguments and initialise storage for org details. + + :param auth_token: Auth token passed by the controller. + :param base_url: Base URL passed by the controller. + :param timeout: Timeout passed by the controller. + """ + FakeSdsClient.last_init = { + "auth_token": auth_token, + "base_url": base_url, + "timeout": timeout, + } + self.auth_token = auth_token + self.base_url = base_url + self.timeout = timeout + self._org_details_by_ods: dict[str, SdsSearchResults | None] = {} + + def set_org_details( + self, ods_code: str, org_details: SdsSearchResults | None + ) -> None: + """ + Configure the SDS lookup result for a given ODS code. + + :param ods_code: ODS code key. + :param org_details: SDS details or ``None`` to simulate not found. + """ + self._org_details_by_ods[ods_code] = org_details + + def get_org_details(self, ods_code: str) -> SdsSearchResults | None: + """ + Retrieve configured org details for a given ODS code. + + :param ods_code: ODS code to look up. + :returns: Configured SDS details or ``None``. + """ + return self._org_details_by_ods.get(ods_code) + + +class FakeGpProviderClient: + """ + Test double for :class:`gateway_api.controller.GpProviderClient`. + + The controller instantiates this class and calls ``access_structured_record``. + Tests configure the returned HTTP response using class-level attributes. + """ + + last_init: dict[str, str] | None = None + last_call: dict[str, str] | None = None + + # Configure per-test. + return_none: bool = False + response_status_code: int = 200 + response_body: bytes = b"ok" + response_headers: dict[str, str] = {"Content-Type": "application/fhir+json"} + + def __init__( + self, provider_endpoint: str, provider_asid: str, consumer_asid: str + ) -> None: + """ + Capture constructor arguments for later assertions. + + :param provider_endpoint: Provider endpoint passed by the controller. + :param provider_asid: Provider ASID passed by the controller. + :param consumer_asid: Consumer ASID passed by the controller. + """ + FakeGpProviderClient.last_init = { + "provider_endpoint": provider_endpoint, + "provider_asid": provider_asid, + "consumer_asid": consumer_asid, + } + + def access_structured_record( + self, + trace_id: str, + body: json_str, + nhsnumber: str, + ) -> Response | None: + """ + Return either a configured :class:`requests.Response` or ``None``. + + :param trace_id: Trace identifier from request headers. + :param body: JSON request body. + :param nhsnumber: NHS number as a string. + :returns: A configured :class:`requests.Response`, or ``None`` if + ``return_none`` is set. + """ + FakeGpProviderClient.last_call = { + "trace_id": trace_id, + "body": body, + "nhsnumber": nhsnumber, + } + + if FakeGpProviderClient.return_none: + return None + + resp = Response() + resp.status_code = FakeGpProviderClient.response_status_code + resp._content = FakeGpProviderClient.response_body # noqa: SLF001 + resp.encoding = "utf-8" + resp.headers.update(FakeGpProviderClient.response_headers) + resp.url = "https://example.invalid/fake" + return resp + + +@pytest.fixture +def patched_deps(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Patch controller dependencies to use test fakes. + Pass as a fixture to give any given test a clean set of patched dependencies. + + :param monkeypatch: pytest monkeypatch fixture. + """ + # Patch dependency classes in the *module* namespace that Controller uses. + monkeypatch.setattr(controller_module, "PdsClient", FakePdsClient) + monkeypatch.setattr(controller_module, "SdsClient", FakeSdsClient) + monkeypatch.setattr(controller_module, "GpProviderClient", FakeGpProviderClient) + + +def _make_controller() -> Controller: + """ + Construct a controller instance configured for unit tests. + + :returns: Controller instance. + """ + return Controller( + pds_base_url="https://pds.example", + sds_base_url="https://sds.example", + nhsd_session_urid="session-123", + timeout=3, + ) + + +# ----------------------------- +# Unit tests +# ----------------------------- +def test__coerce_nhs_number_to_int_accepts_spaces_and_validates() -> None: + """ + Validate that whitespace separators are accepted and the number is validated. + """ + # Use real validator logic by default; 9434765919 is algorithmically valid. + assert _coerce_nhs_number_to_int("943 476 5919") == 9434765919 # noqa: SLF001 (testing private member) + + +@pytest.mark.parametrize("value", ["not-a-number", "943476591", "94347659190"]) +def test__coerce_nhs_number_to_int_rejects_bad_inputs(value: Any) -> None: + """ + Validate that non-numeric and incorrect-length values are rejected. + + :param value: Parameterized input value. + """ + with pytest.raises(ValueError): # noqa: PT011 (ValueError is correct here) + _coerce_nhs_number_to_int(value) # noqa: SLF001 (testing private member) + + +def test__coerce_nhs_number_to_int_rejects_when_validator_returns_false( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Validate that a failing NHS number validator causes coercion to fail. + + :param monkeypatch: pytest monkeypatch fixture. + """ + # _coerce_nhs_number_to_int calls validate_nhs_number imported into + # gateway_api.controller + monkeypatch.setattr(controller_module, "validate_nhs_number", lambda _: False) + with pytest.raises(ValueError, match="invalid"): + _coerce_nhs_number_to_int("9434765919") # noqa: SLF001 (testing private member) + + +def test__coerce_nhs_number_to_int_accepts_integer_value() -> None: + """ + Ensure ``_coerce_nhs_number_to_int`` accepts an integer input + and returns it unchanged. + + :returns: None + """ + assert _coerce_nhs_number_to_int(9434765919) == 9434765919 # noqa: SLF001 + + +def test_call_gp_provider_returns_404_when_pds_patient_not_found( + patched_deps: Any, +) -> None: + """ + If PDS returns no patient record, the controller should return 404. + """ + c = _make_controller() + + # PDS returns None by default + body = make_request_body("9434765919") + headers = make_headers() + + r = c.call_gp_provider(body, headers, "token-abc") + + assert r.status_code == 404 + assert "No PDS patient found for NHS number" in (r.data or "") + + +def test_call_gp_provider_returns_404_when_gp_ods_code_missing( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + If PDS returns a patient without a provider (GP) ODS code, return 404. + + :param monkeypatch: pytest monkeypatch fixture. + """ + c = _make_controller() + + def pds_factory(**kwargs: Any) -> FakePdsClient: + inst = FakePdsClient(**kwargs) + # missing gp_ods_code should be a PDS error + inst.set_patient_details(_make_pds_result("")) + return inst + + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + + body = make_request_body("9434765919") + headers = make_headers() + + r = c.call_gp_provider(body, headers, "token-abc") + + assert r.status_code == 404 + assert "did not contain a current provider ODS code" in (r.data or "") + + +def test_call_gp_provider_returns_404_when_sds_returns_none_for_provider( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + If SDS returns no provider org details, the controller should return 404. + + :param monkeypatch: pytest monkeypatch fixture. + """ + c = _make_controller() + + def pds_factory(**kwargs: Any) -> FakePdsClient: + inst = FakePdsClient(**kwargs) + inst.set_patient_details(_make_pds_result("A12345")) + return inst + + def sds_factory(**kwargs: Any) -> FakeSdsClient: + inst = FakeSdsClient(**kwargs) + # Do NOT set provider org details => None + return inst + + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + body = make_request_body("9434765919") + headers = make_headers() + + r = c.call_gp_provider(body, headers, "token-abc") + + assert r.status_code == 404 + assert r.data == "No SDS org found for provider ODS code A12345" + + +def test_call_gp_provider_returns_404_when_sds_provider_asid_blank( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + If provider ASID is blank/whitespace, the controller should return 404. + + :param monkeypatch: pytest monkeypatch fixture. + """ + c = _make_controller() + + def pds_factory(**kwargs: Any) -> FakePdsClient: + inst = FakePdsClient(**kwargs) + inst.set_patient_details(_make_pds_result("A12345")) + return inst + + def sds_factory(**kwargs: Any) -> FakeSdsClient: + inst = FakeSdsClient(**kwargs) + inst.set_org_details( + "A12345", + SdsSearchResults(asid=" ", endpoint="https://provider.example/ep"), + ) + return inst + + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + body = make_request_body("9434765919") + headers = make_headers() + + r = c.call_gp_provider(body, headers, "token-abc") + + assert r.status_code == 404 + assert "did not contain a current ASID" in (r.data or "") + + +def test_call_gp_provider_returns_502_when_gp_provider_returns_none( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + If GP provider returns no response object, the controller should return 502. + + :param monkeypatch: pytest monkeypatch fixture. + """ + c = _make_controller() + + def pds_factory(**kwargs: Any) -> FakePdsClient: + inst = FakePdsClient(**kwargs) + inst.set_patient_details(_make_pds_result("A12345")) + return inst + + def sds_factory(**kwargs: Any) -> FakeSdsClient: + inst = FakeSdsClient(**kwargs) + inst.set_org_details( + "A12345", + SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + inst.set_org_details("ORG1", SdsSearchResults(asid="asid_ORG1", endpoint=None)) + return inst + + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + FakeGpProviderClient.return_none = True + + body = make_request_body("9434765919") + headers = make_headers() + + r = c.call_gp_provider(body, headers, "token-abc") + + assert r.status_code == 502 + assert r.data == "GP provider service error" + assert r.headers is None + + # reset for other tests + FakeGpProviderClient.return_none = False + + +def test_call_gp_provider_constructs_pds_client_with_expected_kwargs( + patched_deps: Any, +) -> None: + """ + Validate that the controller constructs the PDS client with expected kwargs. + """ + c = _make_controller() + + body = make_request_body("9434765919") + headers = make_headers(ods_from="ORG1", trace_id="trace-123") + + _ = c.call_gp_provider(body, headers, "token-abc") # will stop at PDS None => 404 + + assert FakePdsClient.last_init is not None + assert FakePdsClient.last_init["auth_token"] == "token-abc" # noqa: S105 + assert FakePdsClient.last_init["end_user_org_ods"] == "ORG1" + assert FakePdsClient.last_init["base_url"] == "https://pds.example" + assert FakePdsClient.last_init["nhsd_session_urid"] == "session-123" + assert FakePdsClient.last_init["timeout"] == 3 + + +def test_call_gp_provider_returns_400_when_request_body_not_valid_json( + patched_deps: Any, +) -> None: + """ + If the request body is invalid JSON, the controller should return 400. + """ + c = _make_controller() + headers = make_headers() + + r = c.call_gp_provider("{", headers, "token-abc") + + assert r.status_code == 400 + assert r.data == 'Request body must be valid JSON with an "nhs-number" field' + + +def test_call_gp_provider_returns_400_when_request_body_is_not_an_object( + patched_deps: Any, +) -> None: + """ + If the request body JSON is not an expected type of object (e.g., list), return 400. + """ + c = _make_controller() + headers = make_headers() + + r = c.call_gp_provider('["9434765919"]', headers, "token-abc") + + assert r.status_code == 400 + assert r.data == 'Request body must be a JSON object with an "nhs-number" field' + + +def test_call_gp_provider_returns_400_when_request_body_missing_nhs_number( + patched_deps: Any, +) -> None: + """ + If the request body omits ``"nhs-number"``, return 400. + """ + c = _make_controller() + headers = make_headers() + + r = c.call_gp_provider("{}", headers, "token-abc") + + assert r.status_code == 400 + assert r.data == 'Missing required field "nhs-number" in JSON request body' + + +def test_call_gp_provider_returns_400_when_nhs_number_not_coercible( + patched_deps: Any, +) -> None: + """ + If ``"nhs-number"`` cannot be coerced/validated, return 400. + """ + c = _make_controller() + headers = make_headers() + + r = c.call_gp_provider(std_json.dumps({"nhs-number": "ABC"}), headers, "token-abc") + + assert r.status_code == 400 + assert r.data == 'Could not cast NHS number "ABC" to an integer' + + +def test_call_gp_provider_returns_400_when_missing_ods_from_header( + patched_deps: Any, +) -> None: + """ + If the required ``Ods-from`` header is missing, return 400. + """ + c = _make_controller() + body = make_request_body("9434765919") + + r = c.call_gp_provider(body, {"X-Request-ID": "trace-123"}, "token-abc") + + assert r.status_code == 400 + assert r.data == 'Missing required header "Ods-from"' + + +def test_call_gp_provider_returns_400_when_ods_from_is_whitespace( + patched_deps: Any, +) -> None: + """ + If the ``Ods-from`` header is whitespace-only, return 400. + """ + c = _make_controller() + body = make_request_body("9434765919") + + r = c.call_gp_provider( + body, {"Ods-from": " ", "X-Request-ID": "trace-123"}, "token-abc" + ) + + assert r.status_code == 400 + assert r.data == 'Missing required header "Ods-from"' + + +def test_call_gp_provider_returns_400_when_missing_x_request_id( + patched_deps: Any, +) -> None: + """ + If the required ``X-Request-ID`` header is missing, return 400. + """ + c = _make_controller() + body = make_request_body("9434765919") + + r = c.call_gp_provider(body, {"Ods-from": "ORG1"}, "token-abc") + + assert r.status_code == 400 + assert r.data == "Missing required header: X-Request-ID" + + +def test_call_gp_provider_returns_404_when_sds_provider_endpoint_blank( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + If provider endpoint is blank/whitespace, the controller should return 404. + + :param monkeypatch: pytest monkeypatch fixture. + """ + c = _make_controller() + + def pds_factory(**kwargs: Any) -> FakePdsClient: + inst = FakePdsClient(**kwargs) + inst.set_patient_details(_make_pds_result("A12345")) + return inst + + def sds_factory(**kwargs: Any) -> FakeSdsClient: + inst = FakeSdsClient(**kwargs) + inst.set_org_details( + "A12345", SdsSearchResults(asid="asid_A12345", endpoint=" ") + ) + inst.set_org_details("ORG1", SdsSearchResults(asid="asid_ORG1", endpoint=None)) + return inst + + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + r = c.call_gp_provider(make_request_body("9434765919"), make_headers(), "token-abc") + + assert r.status_code == 404 + assert "did not contain a current endpoint" in (r.data or "") + + +def test_call_gp_provider_returns_404_when_sds_returns_none_for_consumer( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + If SDS returns no consumer org details, the controller should return 404. + + :param monkeypatch: pytest monkeypatch fixture. + """ + c = _make_controller() + + def pds_factory(**kwargs: Any) -> FakePdsClient: + inst = FakePdsClient(**kwargs) + inst.set_patient_details(_make_pds_result("A12345")) + return inst + + def sds_factory(**kwargs: Any) -> FakeSdsClient: + inst = FakeSdsClient(**kwargs) + inst.set_org_details( + "A12345", + SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + # No consumer org details + return inst + + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + r = c.call_gp_provider( + make_request_body("9434765919"), make_headers(ods_from="ORG1"), "token-abc" + ) + + assert r.status_code == 404 + assert r.data == "No SDS org found for consumer ODS code ORG1" + + +def test_call_gp_provider_returns_404_when_sds_consumer_asid_blank( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + If consumer ASID is blank/whitespace, the controller should return 404. + + :param monkeypatch: pytest monkeypatch fixture. + """ + c = _make_controller() + + def pds_factory(**kwargs: Any) -> FakePdsClient: + inst = FakePdsClient(**kwargs) + inst.set_patient_details(_make_pds_result("A12345")) + return inst + + def sds_factory(**kwargs: Any) -> FakeSdsClient: + inst = FakeSdsClient(**kwargs) + inst.set_org_details( + "A12345", + SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + inst.set_org_details("ORG1", SdsSearchResults(asid=" ", endpoint=None)) + return inst + + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + r = c.call_gp_provider( + make_request_body("9434765919"), make_headers(ods_from="ORG1"), "token-abc" + ) + + assert r.status_code == 404 + assert "did not contain a current ASID" in (r.data or "") + + +def test_call_gp_provider_passthroughs_non_200_gp_provider_response( + patched_deps: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Validate that non-200 responses from GP provider are passed through. + + :param monkeypatch: pytest monkeypatch fixture. + """ + c = _make_controller() + + def pds_factory(**kwargs: Any) -> FakePdsClient: + inst = FakePdsClient(**kwargs) + inst.set_patient_details(_make_pds_result("A12345")) + return inst + + def sds_factory(**kwargs: Any) -> FakeSdsClient: + inst = FakeSdsClient(**kwargs) + inst.set_org_details( + "A12345", + SdsSearchResults( + asid="asid_A12345", endpoint="https://provider.example/ep" + ), + ) + inst.set_org_details("ORG1", SdsSearchResults(asid="asid_ORG1", endpoint=None)) + return inst + + monkeypatch.setattr(controller_module, "PdsClient", pds_factory) + monkeypatch.setattr(controller_module, "SdsClient", sds_factory) + + FakeGpProviderClient.response_status_code = 404 + FakeGpProviderClient.response_body = b"Not Found" + FakeGpProviderClient.response_headers = { + "Content-Type": "text/plain", + "X-Downstream": "gp-provider", + } + + r = c.call_gp_provider(make_request_body("9434765919"), make_headers(), "token-abc") + + assert r.status_code == 404 + assert r.data == "Not Found" + assert r.headers is not None + assert r.headers.get("Content-Type") == "text/plain" + assert r.headers.get("X-Downstream") == "gp-provider" diff --git a/gateway-api/src/gateway_api/test_pds_search.py b/gateway-api/src/gateway_api/test_pds_search.py index 78ed9e73..a42b73c6 100644 --- a/gateway-api/src/gateway_api/test_pds_search.py +++ b/gateway-api/src/gateway_api/test_pds_search.py @@ -198,7 +198,7 @@ def test_search_patient_by_nhs_number_get_patient_success( Verify ``GET /Patient/{nhs_number}`` returns 200 and demographics are extracted. This test explicitly inserts the patient into the stub and asserts that the client - returns a populated :class:`gateway_api.pds_search.SearchResults`. + returns a populated :class:`gateway_api.pds_search.PdsSearchResults`. :param stub: Stub backend fixture. :param mock_requests_get: Patched ``requests.get`` fixture