From 0ca6395d7b5ca276f5638780c6f12ce9d6be19f4 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:12:58 +0000 Subject: [PATCH 1/7] Add contract tests for SDS stub --- .../contract/stub/test_sds_stub_contract.py | 1008 +++++++++++++++++ 1 file changed, 1008 insertions(+) create mode 100644 gateway-api/tests/contract/stub/test_sds_stub_contract.py diff --git a/gateway-api/tests/contract/stub/test_sds_stub_contract.py b/gateway-api/tests/contract/stub/test_sds_stub_contract.py new file mode 100644 index 00000000..39574948 --- /dev/null +++ b/gateway-api/tests/contract/stub/test_sds_stub_contract.py @@ -0,0 +1,1008 @@ +"""Contract tests for the SDS FHIR API stub. + +These tests verify that the :class:`~stubs.sds.stub.SdsFhirApiStub` honours the +``GET /Device`` and ``GET /Endpoint`` contracts described in the SDS OpenAPI +specification: + + https://github.com/NHSDigital/spine-directory-service-api + +The stub does not expose an HTTP server, so the tests call its methods directly +and validate the returned :class:`requests.Response` objects against the +contract requirements. +""" + +from __future__ import annotations + +import pytest +from fhir.constants import FHIRSystem +from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID +from stubs.sds.stub import SdsFhirApiStub + +# --------------------------------------------------------------------------- +# Helpers / constants +# --------------------------------------------------------------------------- + +# FHIR-formatted query parameter values used across all tests +_ORG_PROVIDER = f"{FHIRSystem.ODS_CODE}|PROVIDER" +_ORG_CONSUMER = f"{FHIRSystem.ODS_CODE}|CONSUMER" +_ORG_UNKNOWN = f"{FHIRSystem.ODS_CODE}|UNKNOWN_ORG_XYZ" + +_INTERACTION_ID_PARAM = ( + f"{FHIRSystem.NHS_SERVICE_INTERACTION_ID}|{ACCESS_RECORD_STRUCTURED_INTERACTION_ID}" +) +_PARTY_KEY_PROVIDER = f"{FHIRSystem.NHS_MHS_PARTY_KEY}|PROVIDER-0000806" +_PARTY_KEY_CONSUMER = f"{FHIRSystem.NHS_MHS_PARTY_KEY}|CONSUMER-0000807" + +_VALID_CORRELATION_ID = "test-correlation-id-12345" + +_BASE_DEVICE_URL = "https://sandbox.api.service.nhs.uk/spine-directory/FHIR/R4/Device" +_BASE_ENDPOINT_URL = ( + "https://sandbox.api.service.nhs.uk/spine-directory/FHIR/R4/Endpoint" +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def stub() -> SdsFhirApiStub: + """Return a fresh stub instance pre-seeded with default data.""" + return SdsFhirApiStub() + + +# --------------------------------------------------------------------------- +# GET /Device – 200 success +# --------------------------------------------------------------------------- + + +class TestGetDeviceBundleSuccess: + """Contract tests for the happy-path GET /Device → 200 response.""" + + def test_status_code_is_200(self, stub: SdsFhirApiStub) -> None: + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + assert response.status_code == 200 + + def test_content_type_is_fhir_json(self, stub: SdsFhirApiStub) -> None: + """The spec mandates ``application/fhir+json`` on every response.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + assert "application/fhir+json" in response.headers["Content-Type"] + + def test_response_body_resource_type_is_bundle(self, stub: SdsFhirApiStub) -> None: + """The response body must be a FHIR Bundle resource.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert body["resourceType"] == "Bundle" + + def test_response_body_bundle_type_is_searchset(self, stub: SdsFhirApiStub) -> None: + """The SDS spec requires Bundle.type to be ``searchset``.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert body["type"] == "searchset" + + def test_response_bundle_total_matches_entry_count( + self, stub: SdsFhirApiStub + ) -> None: + """Bundle.total must match the number of entries returned.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert body["total"] == len(body["entry"]) + + def test_response_bundle_entry_has_full_url(self, stub: SdsFhirApiStub) -> None: + """Each entry in the Bundle must have a ``fullUrl`` field.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + for entry in body["entry"]: + assert "fullUrl" in entry + assert entry["fullUrl"] # non-empty + + def test_response_bundle_entry_full_url_contains_device_id( + self, stub: SdsFhirApiStub + ) -> None: + """The ``fullUrl`` must contain the Device resource ID.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + for entry in body["entry"]: + device_id = entry["resource"]["id"] + assert device_id in entry["fullUrl"] + + def test_response_bundle_entry_has_search_mode_match( + self, stub: SdsFhirApiStub + ) -> None: + """Each entry must have ``search.mode`` set to ``match``.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + for entry in body["entry"]: + assert entry["search"]["mode"] == "match" + + def test_x_correlation_id_echoed_back_when_provided( + self, stub: SdsFhirApiStub + ) -> None: + """The spec states ``X-Correlation-Id`` is mirrored back in the response.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key", "X-Correlation-Id": _VALID_CORRELATION_ID}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + assert response.headers.get("X-Correlation-Id") == _VALID_CORRELATION_ID + + def test_x_correlation_id_absent_when_not_provided( + self, stub: SdsFhirApiStub + ) -> None: + """``X-Correlation-Id`` must not appear in the response when not supplied.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + assert "X-Correlation-Id" not in response.headers + + def test_empty_bundle_returned_for_unknown_org(self, stub: SdsFhirApiStub) -> None: + """An unknown organisation must return a 200 with an empty Bundle.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={ + "organization": _ORG_UNKNOWN, + "identifier": _INTERACTION_ID_PARAM, + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["resourceType"] == "Bundle" + assert body["total"] == 0 + assert body["entry"] == [] + + def test_query_with_party_key_returns_matching_device( + self, stub: SdsFhirApiStub + ) -> None: + """Including a party key identifier should still return a match.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={ + "organization": _ORG_PROVIDER, + "identifier": [_INTERACTION_ID_PARAM, _PARTY_KEY_PROVIDER], + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["total"] >= 1 + + +# --------------------------------------------------------------------------- +# GET /Device – Device resource structure +# --------------------------------------------------------------------------- + + +class TestGetDeviceResourceStructure: + """Contract tests verifying the shape of returned Device resources.""" + + def test_device_resource_type_is_device(self, stub: SdsFhirApiStub) -> None: + """Each resource inside the Bundle must have ``resourceType: "Device"``.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + for entry in body["entry"]: + assert entry["resource"]["resourceType"] == "Device" + + def test_device_has_id(self, stub: SdsFhirApiStub) -> None: + """Each Device resource must have an ``id`` field.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + for entry in body["entry"]: + assert "id" in entry["resource"] + assert entry["resource"]["id"] # non-empty + + def test_device_has_identifier_list(self, stub: SdsFhirApiStub) -> None: + """Each Device resource must have an ``identifier`` list.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + for entry in body["entry"]: + assert isinstance(entry["resource"]["identifier"], list) + assert len(entry["resource"]["identifier"]) >= 1 + + def test_device_identifier_contains_asid(self, stub: SdsFhirApiStub) -> None: + """Device identifier must include an ASID entry.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + for entry in body["entry"]: + identifiers = entry["resource"]["identifier"] + asid_entries = [ + i for i in identifiers if i.get("system") == FHIRSystem.NHS_SPINE_ASID + ] + assert len(asid_entries) >= 1 + + def test_device_identifier_contains_party_key(self, stub: SdsFhirApiStub) -> None: + """Device identifier must include a party key entry.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + for entry in body["entry"]: + identifiers = entry["resource"]["identifier"] + party_key_entries = [ + i + for i in identifiers + if i.get("system") == FHIRSystem.NHS_MHS_PARTY_KEY + ] + assert len(party_key_entries) >= 1 + + def test_device_has_owner(self, stub: SdsFhirApiStub) -> None: + """Each Device resource must have an ``owner`` field.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + for entry in body["entry"]: + assert "owner" in entry["resource"] + + def test_device_owner_identifier_uses_ods_system( + self, stub: SdsFhirApiStub + ) -> None: + """Device.owner.identifier.system must be the ODS code system.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + for entry in body["entry"]: + owner = entry["resource"]["owner"] + assert owner["identifier"]["system"] == FHIRSystem.ODS_CODE + + +# --------------------------------------------------------------------------- +# GET /Device – 400 validation errors +# --------------------------------------------------------------------------- + + +class TestGetDeviceBundleValidationErrors: + """Contract tests for GET /Device → 400 when required inputs are missing.""" + + def test_missing_apikey_returns_400(self, stub: SdsFhirApiStub) -> None: + """The spec requires the ``apikey`` header; its absence must yield 400.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + assert response.status_code == 400 + + def test_missing_organization_returns_400(self, stub: SdsFhirApiStub) -> None: + """``organization`` is a required query parameter for /Device.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"identifier": _INTERACTION_ID_PARAM}, + ) + assert response.status_code == 400 + + def test_missing_identifier_returns_400(self, stub: SdsFhirApiStub) -> None: + """``identifier`` is a required query parameter for /Device.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER}, + ) + assert response.status_code == 400 + + def test_identifier_without_interaction_id_returns_400( + self, stub: SdsFhirApiStub + ) -> None: + """``identifier`` must include nhsServiceInteractionId for /Device.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={ + "organization": _ORG_PROVIDER, + "identifier": _PARTY_KEY_PROVIDER, # party key only, no interaction ID + }, + ) + assert response.status_code == 400 + + def test_error_response_resource_type_is_operation_outcome( + self, stub: SdsFhirApiStub + ) -> None: + """Every 400 error body must be an ``OperationOutcome``.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={}, # missing apikey + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert body["resourceType"] == "OperationOutcome" + + def test_error_response_has_non_empty_issue_list( + self, stub: SdsFhirApiStub + ) -> None: + """The OperationOutcome must have a non-empty ``issue`` list.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={}, # missing apikey + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert isinstance(body.get("issue"), list) + assert len(body["issue"]) >= 1 + + def test_error_response_issue_has_severity(self, stub: SdsFhirApiStub) -> None: + """Each issue in the OperationOutcome must have a ``severity`` field.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={}, # missing apikey + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert "severity" in body["issue"][0] + + def test_error_response_issue_has_code(self, stub: SdsFhirApiStub) -> None: + """Each issue in the OperationOutcome must have a ``code`` field.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={}, # missing apikey + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert "code" in body["issue"][0] + + def test_error_response_issue_has_diagnostics(self, stub: SdsFhirApiStub) -> None: + """Each issue must have a non-empty ``diagnostics`` string.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={}, # missing apikey + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert "diagnostics" in body["issue"][0] + assert body["issue"][0]["diagnostics"] # non-empty + + def test_missing_apikey_echoes_correlation_id(self, stub: SdsFhirApiStub) -> None: + """``X-Correlation-Id`` must be echoed even in error responses.""" + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"X-Correlation-Id": _VALID_CORRELATION_ID}, # no apikey + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + assert response.status_code == 400 + assert response.headers.get("X-Correlation-Id") == _VALID_CORRELATION_ID + + +# --------------------------------------------------------------------------- +# GET /Endpoint – 200 successful retrieval +# --------------------------------------------------------------------------- + + +class TestGetEndpointBundleSuccess: + """Contract tests for the happy-path GET /Endpoint → 200 response.""" + + def test_status_code_is_200(self, stub: SdsFhirApiStub) -> None: + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _INTERACTION_ID_PARAM}, + ) + assert response.status_code == 200 + + def test_content_type_is_fhir_json(self, stub: SdsFhirApiStub) -> None: + """The spec mandates ``application/fhir+json`` on every response.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _INTERACTION_ID_PARAM}, + ) + assert "application/fhir+json" in response.headers["Content-Type"] + + def test_response_body_resource_type_is_bundle(self, stub: SdsFhirApiStub) -> None: + """The response body must be a FHIR Bundle resource.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert body["resourceType"] == "Bundle" + + def test_response_body_bundle_type_is_searchset(self, stub: SdsFhirApiStub) -> None: + """The SDS spec requires Bundle.type to be ``searchset``.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert body["type"] == "searchset" + + def test_response_bundle_total_matches_entry_count( + self, stub: SdsFhirApiStub + ) -> None: + """Bundle.total must match the number of entries returned.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert body["total"] == len(body["entry"]) + + def test_response_bundle_entry_has_full_url(self, stub: SdsFhirApiStub) -> None: + """Each entry in the Bundle must have a ``fullUrl`` field.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + for entry in body["entry"]: + assert "fullUrl" in entry + assert entry["fullUrl"] # non-empty + + def test_response_bundle_entry_full_url_contains_endpoint_id( + self, stub: SdsFhirApiStub + ) -> None: + """The ``fullUrl`` must contain the Endpoint resource ID.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + for entry in body["entry"]: + endpoint_id = entry["resource"]["id"] + assert endpoint_id in entry["fullUrl"] + + def test_response_bundle_entry_has_search_mode_match( + self, stub: SdsFhirApiStub + ) -> None: + """Each entry must have ``search.mode`` set to ``match``.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + for entry in body["entry"]: + assert entry["search"]["mode"] == "match" + + def test_query_with_party_key_returns_matching_endpoint( + self, stub: SdsFhirApiStub + ) -> None: + """Including a party key identifier should return matching Endpoint entries.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={ + "identifier": [_INTERACTION_ID_PARAM, _PARTY_KEY_PROVIDER], + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["total"] >= 1 + + def test_x_correlation_id_echoed_back_when_provided( + self, stub: SdsFhirApiStub + ) -> None: + """The spec states ``X-Correlation-Id`` is mirrored back in the response.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key", "X-Correlation-Id": _VALID_CORRELATION_ID}, + params={"identifier": _INTERACTION_ID_PARAM}, + ) + assert response.headers.get("X-Correlation-Id") == _VALID_CORRELATION_ID + + def test_x_correlation_id_absent_when_not_provided( + self, stub: SdsFhirApiStub + ) -> None: + """``X-Correlation-Id`` must not appear in the response when not supplied.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _INTERACTION_ID_PARAM}, + ) + assert "X-Correlation-Id" not in response.headers + + def test_empty_bundle_returned_for_unknown_party_key( + self, stub: SdsFhirApiStub + ) -> None: + """A party key not present in the stub must yield an empty Bundle.""" + unknown_party_key = f"{FHIRSystem.NHS_MHS_PARTY_KEY}|UNKNOWN-9999999" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": unknown_party_key}, + ) + assert response.status_code == 200 + body = response.json() + assert body["resourceType"] == "Bundle" + assert body["total"] == 0 + assert body["entry"] == [] + + +# --------------------------------------------------------------------------- +# GET /Endpoint – Endpoint resource structure +# --------------------------------------------------------------------------- + + +class TestGetEndpointResourceStructure: + """Contract tests verifying the shape of returned Endpoint resources.""" + + def test_endpoint_resource_type_is_endpoint(self, stub: SdsFhirApiStub) -> None: + """Each resource inside the Bundle must have ``resourceType: "Endpoint"``.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _PARTY_KEY_PROVIDER}, + ) + body = response.json() + for entry in body["entry"]: + assert entry["resource"]["resourceType"] == "Endpoint" + + def test_endpoint_has_id(self, stub: SdsFhirApiStub) -> None: + """Each Endpoint resource must have an ``id`` field.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _PARTY_KEY_PROVIDER}, + ) + body = response.json() + for entry in body["entry"]: + assert "id" in entry["resource"] + assert entry["resource"]["id"] # non-empty + + def test_endpoint_has_status_active(self, stub: SdsFhirApiStub) -> None: + """The SDS spec requires ``Endpoint.status`` to be ``active``.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _PARTY_KEY_PROVIDER}, + ) + body = response.json() + for entry in body["entry"]: + assert entry["resource"]["status"] == "active" + + def test_endpoint_has_connection_type(self, stub: SdsFhirApiStub) -> None: + """Each Endpoint must have a ``connectionType`` object.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _PARTY_KEY_PROVIDER}, + ) + body = response.json() + for entry in body["entry"]: + assert "connectionType" in entry["resource"] + + def test_endpoint_connection_type_has_system_and_code( + self, stub: SdsFhirApiStub + ) -> None: + """``connectionType`` must have ``system`` and ``code`` fields.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _PARTY_KEY_PROVIDER}, + ) + body = response.json() + for entry in body["entry"]: + ct = entry["resource"]["connectionType"] + assert "system" in ct + assert "code" in ct + + def test_endpoint_has_payload_type(self, stub: SdsFhirApiStub) -> None: + """Each Endpoint must have a ``payloadType`` list.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _PARTY_KEY_PROVIDER}, + ) + body = response.json() + for entry in body["entry"]: + assert isinstance(entry["resource"]["payloadType"], list) + assert len(entry["resource"]["payloadType"]) >= 1 + + def test_endpoint_has_address(self, stub: SdsFhirApiStub) -> None: + """Each Endpoint must have a non-empty ``address`` field.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _PARTY_KEY_PROVIDER}, + ) + body = response.json() + for entry in body["entry"]: + assert "address" in entry["resource"] + assert entry["resource"]["address"] # non-empty + + def test_endpoint_has_managing_organization(self, stub: SdsFhirApiStub) -> None: + """Each Endpoint must have a ``managingOrganization`` field.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _PARTY_KEY_PROVIDER}, + ) + body = response.json() + for entry in body["entry"]: + assert "managingOrganization" in entry["resource"] + + def test_endpoint_managing_organization_uses_ods_system( + self, stub: SdsFhirApiStub + ) -> None: + """Endpoint.managingOrganization.identifier.system must be the ODS code + system.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _PARTY_KEY_PROVIDER}, + ) + body = response.json() + for entry in body["entry"]: + managing_org = entry["resource"]["managingOrganization"] + assert managing_org["identifier"]["system"] == FHIRSystem.ODS_CODE + + def test_endpoint_has_identifier_list(self, stub: SdsFhirApiStub) -> None: + """Each Endpoint resource must have an ``identifier`` list.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _PARTY_KEY_PROVIDER}, + ) + body = response.json() + for entry in body["entry"]: + assert isinstance(entry["resource"]["identifier"], list) + assert len(entry["resource"]["identifier"]) >= 1 + + def test_endpoint_identifier_contains_asid(self, stub: SdsFhirApiStub) -> None: + """Endpoint identifier must include an ASID entry.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _PARTY_KEY_PROVIDER}, + ) + body = response.json() + for entry in body["entry"]: + identifiers = entry["resource"]["identifier"] + asid_entries = [ + i for i in identifiers if i.get("system") == FHIRSystem.NHS_SPINE_ASID + ] + assert len(asid_entries) >= 1 + + def test_endpoint_identifier_contains_party_key(self, stub: SdsFhirApiStub) -> None: + """Endpoint identifier must include a party key entry.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _PARTY_KEY_PROVIDER}, + ) + body = response.json() + for entry in body["entry"]: + identifiers = entry["resource"]["identifier"] + party_key_entries = [ + i + for i in identifiers + if i.get("system") == FHIRSystem.NHS_MHS_PARTY_KEY + ] + assert len(party_key_entries) >= 1 + + +# --------------------------------------------------------------------------- +# GET /Endpoint – 400 validation errors +# --------------------------------------------------------------------------- + + +class TestGetEndpointBundleValidationErrors: + """Contract tests for GET /Endpoint → 400 when required inputs are missing.""" + + def test_missing_apikey_returns_400(self, stub: SdsFhirApiStub) -> None: + """The spec requires the ``apikey`` header; its absence must yield 400.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={}, + params={"identifier": _INTERACTION_ID_PARAM}, + ) + assert response.status_code == 400 + + def test_missing_identifier_returns_400(self, stub: SdsFhirApiStub) -> None: + """``identifier`` is a required query parameter for /Endpoint.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={}, + ) + assert response.status_code == 400 + + def test_error_response_resource_type_is_operation_outcome( + self, stub: SdsFhirApiStub + ) -> None: + """Every 400 error body must be an ``OperationOutcome``.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={}, # missing apikey + params={"identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert body["resourceType"] == "OperationOutcome" + + def test_error_response_has_non_empty_issue_list( + self, stub: SdsFhirApiStub + ) -> None: + """The OperationOutcome must have a non-empty ``issue`` list.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={}, # missing apikey + params={"identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert isinstance(body.get("issue"), list) + assert len(body["issue"]) >= 1 + + def test_error_response_issue_has_severity(self, stub: SdsFhirApiStub) -> None: + """Each issue in the OperationOutcome must have a ``severity`` field.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={}, # missing apikey + params={"identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert "severity" in body["issue"][0] + + def test_error_response_issue_has_code(self, stub: SdsFhirApiStub) -> None: + """Each issue in the OperationOutcome must have a ``code`` field.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={}, # missing apikey + params={"identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert "code" in body["issue"][0] + + def test_error_response_issue_has_diagnostics(self, stub: SdsFhirApiStub) -> None: + """Each issue must have a non-empty ``diagnostics`` string.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={}, # missing apikey + params={"identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert "diagnostics" in body["issue"][0] + assert body["issue"][0]["diagnostics"] # non-empty + + def test_missing_apikey_echoes_correlation_id(self, stub: SdsFhirApiStub) -> None: + """``X-Correlation-Id`` must be echoed even in error responses.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"X-Correlation-Id": _VALID_CORRELATION_ID}, # no apikey + params={"identifier": _INTERACTION_ID_PARAM}, + ) + assert response.status_code == 400 + assert response.headers.get("X-Correlation-Id") == _VALID_CORRELATION_ID + + +# --------------------------------------------------------------------------- +# get() convenience wrapper – routes by URL path +# --------------------------------------------------------------------------- + + +class TestGetConvenienceMethod: + """Verify the ``get()`` wrapper routes correctly based on the URL path.""" + + def test_device_url_returns_device_bundle(self, stub: SdsFhirApiStub) -> None: + """A URL containing ``/Device`` must be routed to get_device_bundle.""" + response = stub.get( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + assert response.status_code == 200 + body = response.json() + assert body["resourceType"] == "Bundle" + # Verify Device resources were returned + for entry in body["entry"]: + assert entry["resource"]["resourceType"] == "Device" + + def test_endpoint_url_returns_endpoint_bundle(self, stub: SdsFhirApiStub) -> None: + """A URL containing ``/Endpoint`` must be routed to get_endpoint_bundle.""" + response = stub.get( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _PARTY_KEY_PROVIDER}, + ) + assert response.status_code == 200 + body = response.json() + assert body["resourceType"] == "Bundle" + # Verify Endpoint resources returned + for entry in body["entry"]: + assert entry["resource"]["resourceType"] == "Endpoint" + + def test_get_records_last_url(self, stub: SdsFhirApiStub) -> None: + """The stub must record the last URL it was called with.""" + stub.get( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + assert stub.get_url == _BASE_DEVICE_URL + + def test_get_records_last_headers(self, stub: SdsFhirApiStub) -> None: + """The stub must record the last headers it was called with.""" + headers = {"apikey": "test-key", "X-Correlation-Id": _VALID_CORRELATION_ID} + stub.get( + url=_BASE_DEVICE_URL, + headers=headers, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + assert stub.get_headers == headers + + def test_get_records_last_params(self, stub: SdsFhirApiStub) -> None: + """The stub must record the last query params it was called with.""" + params = {"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM} + stub.get( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params=params, + ) + assert stub.get_params == params + + def test_get_records_last_timeout(self, stub: SdsFhirApiStub) -> None: + """The stub must record the last timeout value it was called with.""" + stub.get( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + timeout=30, + ) + assert stub.get_timeout == 30 + + +# --------------------------------------------------------------------------- +# upsert_device / upsert_endpoint – dynamic data management +# --------------------------------------------------------------------------- + + +class TestUpsertOperations: + """Verify that devices and endpoints can be dynamically added to the stub.""" + + def test_upsert_device_is_returned_by_get_device_bundle( + self, stub: SdsFhirApiStub + ) -> None: + """A device added via upsert_device must be returned in subsequent queries.""" + stub.clear_devices() + new_device: dict[str, object] = { + "resourceType": "Device", + "id": "new-device-123", + "identifier": [], + "owner": {"identifier": {"system": FHIRSystem.ODS_CODE, "value": "NEWORG"}}, + } + stub.upsert_device( + organization_ods="NEWORG", + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, + party_key=None, + device=new_device, + ) + + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={ + "organization": f"{FHIRSystem.ODS_CODE}|NEWORG", + "identifier": _INTERACTION_ID_PARAM, + }, + ) + body = response.json() + assert body["total"] == 1 + assert body["entry"][0]["resource"]["id"] == "new-device-123" + + def test_clear_devices_removes_all_devices(self, stub: SdsFhirApiStub) -> None: + """After clear_devices, all Device queries must return an empty Bundle.""" + stub.clear_devices() + + response = stub.get_device_bundle( + url=_BASE_DEVICE_URL, + headers={"apikey": "test-key"}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert body["total"] == 0 + + def test_upsert_endpoint_is_returned_by_get_endpoint_bundle( + self, stub: SdsFhirApiStub + ) -> None: + """An endpoint added via upsert_endpoint must be returned in subsequent + queries.""" + stub.clear_endpoints() + new_party_key = "NEWORG-0000999" + new_endpoint: dict[str, object] = { + "resourceType": "Endpoint", + "id": "new-endpoint-456", + "status": "active", + "connectionType": { + "system": SdsFhirApiStub.CONNECTION_SYSTEM, + "code": "hl7-fhir-rest", + "display": SdsFhirApiStub.CONNECTION_DISPLAY, + }, + "payloadType": [{"coding": [{"system": SdsFhirApiStub.CODING_SYSTEM}]}], + "address": "https://new.example.com/fhir", + "managingOrganization": { + "identifier": {"system": FHIRSystem.ODS_CODE, "value": "NEWORG"} + }, + "identifier": [ + {"system": FHIRSystem.NHS_MHS_PARTY_KEY, "value": new_party_key} + ], + } + stub.upsert_endpoint( + organization_ods="NEWORG", + service_interaction_id=ACCESS_RECORD_STRUCTURED_INTERACTION_ID, + party_key=new_party_key, + endpoint=new_endpoint, + ) + + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={ + "identifier": f"{FHIRSystem.NHS_MHS_PARTY_KEY}|{new_party_key}", + }, + ) + body = response.json() + assert body["total"] == 1 + assert body["entry"][0]["resource"]["id"] == "new-endpoint-456" + + def test_clear_endpoints_removes_all_endpoints(self, stub: SdsFhirApiStub) -> None: + """After clear_endpoints, all Endpoint queries must return an empty Bundle.""" + stub.clear_endpoints() + + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _PARTY_KEY_PROVIDER}, + ) + body = response.json() + assert body["total"] == 0 From 278626150e042f116061c3f6ab2f6bc79a399a5b Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:29:27 +0000 Subject: [PATCH 2/7] Fix copilot review issues --- .../tests/contract/stub/test_sds_stub_contract.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/gateway-api/tests/contract/stub/test_sds_stub_contract.py b/gateway-api/tests/contract/stub/test_sds_stub_contract.py index 39574948..3caa0999 100644 --- a/gateway-api/tests/contract/stub/test_sds_stub_contract.py +++ b/gateway-api/tests/contract/stub/test_sds_stub_contract.py @@ -24,14 +24,12 @@ # FHIR-formatted query parameter values used across all tests _ORG_PROVIDER = f"{FHIRSystem.ODS_CODE}|PROVIDER" -_ORG_CONSUMER = f"{FHIRSystem.ODS_CODE}|CONSUMER" _ORG_UNKNOWN = f"{FHIRSystem.ODS_CODE}|UNKNOWN_ORG_XYZ" _INTERACTION_ID_PARAM = ( f"{FHIRSystem.NHS_SERVICE_INTERACTION_ID}|{ACCESS_RECORD_STRUCTURED_INTERACTION_ID}" ) _PARTY_KEY_PROVIDER = f"{FHIRSystem.NHS_MHS_PARTY_KEY}|PROVIDER-0000806" -_PARTY_KEY_CONSUMER = f"{FHIRSystem.NHS_MHS_PARTY_KEY}|CONSUMER-0000807" _VALID_CORRELATION_ID = "test-correlation-id-12345" @@ -117,6 +115,7 @@ def test_response_bundle_entry_has_full_url(self, stub: SdsFhirApiStub) -> None: params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, ) body = response.json() + assert len(body["entry"]) >= 1 # sanity check for non-empty entries for entry in body["entry"]: assert "fullUrl" in entry assert entry["fullUrl"] # non-empty @@ -356,7 +355,7 @@ def test_identifier_without_interaction_id_returns_400( def test_error_response_resource_type_is_operation_outcome( self, stub: SdsFhirApiStub ) -> None: - """Every 400 error body must be an ``OperationOutcome``.""" + """Every error body must be an ``OperationOutcome``.""" response = stub.get_device_bundle( url=_BASE_DEVICE_URL, headers={}, # missing apikey @@ -485,6 +484,7 @@ def test_response_bundle_entry_has_full_url(self, stub: SdsFhirApiStub) -> None: params={"identifier": _INTERACTION_ID_PARAM}, ) body = response.json() + assert len(body["entry"]) >= 1 for entry in body["entry"]: assert "fullUrl" in entry assert entry["fullUrl"] # non-empty @@ -762,7 +762,7 @@ def test_missing_identifier_returns_400(self, stub: SdsFhirApiStub) -> None: def test_error_response_resource_type_is_operation_outcome( self, stub: SdsFhirApiStub ) -> None: - """Every 400 error body must be an ``OperationOutcome``.""" + """Every error body must be an ``OperationOutcome``.""" response = stub.get_endpoint_bundle( url=_BASE_ENDPOINT_URL, headers={}, # missing apikey @@ -845,6 +845,7 @@ def test_device_url_returns_device_bundle(self, stub: SdsFhirApiStub) -> None: body = response.json() assert body["resourceType"] == "Bundle" # Verify Device resources were returned + assert len(body["entry"]) >= 1 for entry in body["entry"]: assert entry["resource"]["resourceType"] == "Device" From 12b7fbd4dff640067b5f230f9212f2045b63939d Mon Sep 17 00:00:00 2001 From: Ian Robinson <267046044+ian-robinson-35@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:42:44 +0000 Subject: [PATCH 3/7] Combine tests and use more specific assertions --- .../contract/stub/test_sds_stub_contract.py | 580 ++++-------------- 1 file changed, 109 insertions(+), 471 deletions(-) diff --git a/gateway-api/tests/contract/stub/test_sds_stub_contract.py b/gateway-api/tests/contract/stub/test_sds_stub_contract.py index 3caa0999..99f55595 100644 --- a/gateway-api/tests/contract/stub/test_sds_stub_contract.py +++ b/gateway-api/tests/contract/stub/test_sds_stub_contract.py @@ -13,6 +13,8 @@ from __future__ import annotations +from typing import Any + import pytest from fhir.constants import FHIRSystem from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID @@ -58,94 +60,29 @@ def stub() -> SdsFhirApiStub: class TestGetDeviceBundleSuccess: """Contract tests for the happy-path GET /Device → 200 response.""" - def test_status_code_is_200(self, stub: SdsFhirApiStub) -> None: + def test_response_matches_expected(self, stub: SdsFhirApiStub) -> None: response = stub.get_device_bundle( url=_BASE_DEVICE_URL, headers={"apikey": "test-key"}, params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, ) assert response.status_code == 200 - - def test_content_type_is_fhir_json(self, stub: SdsFhirApiStub) -> None: - """The spec mandates ``application/fhir+json`` on every response.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={"apikey": "test-key"}, - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) assert "application/fhir+json" in response.headers["Content-Type"] - def test_response_body_resource_type_is_bundle(self, stub: SdsFhirApiStub) -> None: - """The response body must be a FHIR Bundle resource.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={"apikey": "test-key"}, - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) body = response.json() assert body["resourceType"] == "Bundle" - - def test_response_body_bundle_type_is_searchset(self, stub: SdsFhirApiStub) -> None: - """The SDS spec requires Bundle.type to be ``searchset``.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={"apikey": "test-key"}, - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() assert body["type"] == "searchset" - def test_response_bundle_total_matches_entry_count( - self, stub: SdsFhirApiStub - ) -> None: - """Bundle.total must match the number of entries returned.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={"apikey": "test-key"}, - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - assert body["total"] == len(body["entry"]) - - def test_response_bundle_entry_has_full_url(self, stub: SdsFhirApiStub) -> None: - """Each entry in the Bundle must have a ``fullUrl`` field.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={"apikey": "test-key"}, - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - assert len(body["entry"]) >= 1 # sanity check for non-empty entries - for entry in body["entry"]: - assert "fullUrl" in entry - assert entry["fullUrl"] # non-empty - - def test_response_bundle_entry_full_url_contains_device_id( - self, stub: SdsFhirApiStub - ) -> None: - """The ``fullUrl`` must contain the Device resource ID.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={"apikey": "test-key"}, - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - for entry in body["entry"]: - device_id = entry["resource"]["id"] - assert device_id in entry["fullUrl"] + assert body["total"] == 1 + assert len(body["entry"]) == 1 - def test_response_bundle_entry_has_search_mode_match( - self, stub: SdsFhirApiStub - ) -> None: - """Each entry must have ``search.mode`` set to ``match``.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={"apikey": "test-key"}, - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + entry = body["entry"][0] + assert entry["resource"]["id"] == "F0F0E921-92CA-4A88-A550-2DBB36F703AF" + assert ( + entry["fullUrl"] + == "https://sandbox.api.service.nhs.uk/spine-directory/FHIR/R4/Device/F0F0E921-92CA-4A88-A550-2DBB36F703AF" ) - body = response.json() - for entry in body["entry"]: - assert entry["search"]["mode"] == "match" + assert entry["search"]["mode"] == "match" def test_x_correlation_id_echoed_back_when_provided( self, stub: SdsFhirApiStub @@ -218,89 +155,20 @@ def test_device_resource_type_is_device(self, stub: SdsFhirApiStub) -> None: params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, ) body = response.json() - for entry in body["entry"]: - assert entry["resource"]["resourceType"] == "Device" + assert len(body["entry"]) == 1 - def test_device_has_id(self, stub: SdsFhirApiStub) -> None: - """Each Device resource must have an ``id`` field.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={"apikey": "test-key"}, - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - for entry in body["entry"]: - assert "id" in entry["resource"] - assert entry["resource"]["id"] # non-empty - - def test_device_has_identifier_list(self, stub: SdsFhirApiStub) -> None: - """Each Device resource must have an ``identifier`` list.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={"apikey": "test-key"}, - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - for entry in body["entry"]: - assert isinstance(entry["resource"]["identifier"], list) - assert len(entry["resource"]["identifier"]) >= 1 + entry = body["entry"][0] + resource = entry["resource"] - def test_device_identifier_contains_asid(self, stub: SdsFhirApiStub) -> None: - """Device identifier must include an ASID entry.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={"apikey": "test-key"}, - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - for entry in body["entry"]: - identifiers = entry["resource"]["identifier"] - asid_entries = [ - i for i in identifiers if i.get("system") == FHIRSystem.NHS_SPINE_ASID - ] - assert len(asid_entries) >= 1 - - def test_device_identifier_contains_party_key(self, stub: SdsFhirApiStub) -> None: - """Device identifier must include a party key entry.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={"apikey": "test-key"}, - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - for entry in body["entry"]: - identifiers = entry["resource"]["identifier"] - party_key_entries = [ - i - for i in identifiers - if i.get("system") == FHIRSystem.NHS_MHS_PARTY_KEY - ] - assert len(party_key_entries) >= 1 - - def test_device_has_owner(self, stub: SdsFhirApiStub) -> None: - """Each Device resource must have an ``owner`` field.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={"apikey": "test-key"}, - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - for entry in body["entry"]: - assert "owner" in entry["resource"] + assert resource["resourceType"] == "Device" + assert resource["id"] == "F0F0E921-92CA-4A88-A550-2DBB36F703AF" + assert resource["owner"]["identifier"]["system"] == FHIRSystem.ODS_CODE - def test_device_owner_identifier_uses_ods_system( - self, stub: SdsFhirApiStub - ) -> None: - """Device.owner.identifier.system must be the ODS code system.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={"apikey": "test-key"}, - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - for entry in body["entry"]: - owner = entry["resource"]["owner"] - assert owner["identifier"]["system"] == FHIRSystem.ODS_CODE + assert len(resource["identifier"]) == 2 + identifiers = resource["identifier"] + assert identifiers[0]["system"] == FHIRSystem.NHS_SPINE_ASID + assert identifiers[0]["value"] == "asid_PROV" + assert identifiers[1]["system"] == FHIRSystem.NHS_MHS_PARTY_KEY # --------------------------------------------------------------------------- @@ -319,6 +187,7 @@ def test_missing_apikey_returns_400(self, stub: SdsFhirApiStub) -> None: params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, ) assert response.status_code == 400 + self.verify_error_response_body(response, "Missing required header: apikey") def test_missing_organization_returns_400(self, stub: SdsFhirApiStub) -> None: """``organization`` is a required query parameter for /Device.""" @@ -328,6 +197,9 @@ def test_missing_organization_returns_400(self, stub: SdsFhirApiStub) -> None: params={"identifier": _INTERACTION_ID_PARAM}, ) assert response.status_code == 400 + self.verify_error_response_body( + response, "Missing required query parameter: organization" + ) def test_missing_identifier_returns_400(self, stub: SdsFhirApiStub) -> None: """``identifier`` is a required query parameter for /Device.""" @@ -337,6 +209,9 @@ def test_missing_identifier_returns_400(self, stub: SdsFhirApiStub) -> None: params={"organization": _ORG_PROVIDER}, ) assert response.status_code == 400 + self.verify_error_response_body( + response, "Missing required query parameter: identifier" + ) def test_identifier_without_interaction_id_returns_400( self, stub: SdsFhirApiStub @@ -351,72 +226,39 @@ def test_identifier_without_interaction_id_returns_400( }, ) assert response.status_code == 400 + self.verify_error_response_body( + response, + "identifier must include nhsServiceInteractionId", + ) - def test_error_response_resource_type_is_operation_outcome( - self, stub: SdsFhirApiStub - ) -> None: - """Every error body must be an ``OperationOutcome``.""" + def test_missing_apikey_echoes_correlation_id(self, stub: SdsFhirApiStub) -> None: + """``X-Correlation-Id`` must be echoed even in error responses.""" response = stub.get_device_bundle( url=_BASE_DEVICE_URL, - headers={}, # missing apikey + headers={"X-Correlation-Id": _VALID_CORRELATION_ID}, # no apikey params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, ) - body = response.json() - assert body["resourceType"] == "OperationOutcome" + assert response.status_code == 400 + assert response.headers.get("X-Correlation-Id") == _VALID_CORRELATION_ID - def test_error_response_has_non_empty_issue_list( - self, stub: SdsFhirApiStub + self.verify_error_response_body(response, "Missing required header: apikey") + + def verify_error_response_body( + self, response: Any, expected_diagnostics: str ) -> None: - """The OperationOutcome must have a non-empty ``issue`` list.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={}, # missing apikey - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) body = response.json() + assert body["resourceType"] == "OperationOutcome" + assert isinstance(body.get("issue"), list) - assert len(body["issue"]) >= 1 + assert len(body["issue"]) == 1 - def test_error_response_issue_has_severity(self, stub: SdsFhirApiStub) -> None: - """Each issue in the OperationOutcome must have a ``severity`` field.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={}, # missing apikey - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - assert "severity" in body["issue"][0] + issue = body["issue"][0] + assert issue["severity"] == "error" - def test_error_response_issue_has_code(self, stub: SdsFhirApiStub) -> None: - """Each issue in the OperationOutcome must have a ``code`` field.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={}, # missing apikey - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - assert "code" in body["issue"][0] + assert issue["code"] == "invalid" - def test_error_response_issue_has_diagnostics(self, stub: SdsFhirApiStub) -> None: - """Each issue must have a non-empty ``diagnostics`` string.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={}, # missing apikey - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() assert "diagnostics" in body["issue"][0] - assert body["issue"][0]["diagnostics"] # non-empty - - def test_missing_apikey_echoes_correlation_id(self, stub: SdsFhirApiStub) -> None: - """``X-Correlation-Id`` must be echoed even in error responses.""" - response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, - headers={"X-Correlation-Id": _VALID_CORRELATION_ID}, # no apikey - params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, - ) - assert response.status_code == 400 - assert response.headers.get("X-Correlation-Id") == _VALID_CORRELATION_ID + assert body["issue"][0]["diagnostics"] == expected_diagnostics # --------------------------------------------------------------------------- @@ -427,93 +269,38 @@ def test_missing_apikey_echoes_correlation_id(self, stub: SdsFhirApiStub) -> Non class TestGetEndpointBundleSuccess: """Contract tests for the happy-path GET /Endpoint → 200 response.""" - def test_status_code_is_200(self, stub: SdsFhirApiStub) -> None: + def test_endpoint_bundle_matches_expected_response( + self, stub: SdsFhirApiStub + ) -> None: response = stub.get_endpoint_bundle( url=_BASE_ENDPOINT_URL, headers={"apikey": "test-key"}, params={"identifier": _INTERACTION_ID_PARAM}, ) assert response.status_code == 200 - - def test_content_type_is_fhir_json(self, stub: SdsFhirApiStub) -> None: - """The spec mandates ``application/fhir+json`` on every response.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _INTERACTION_ID_PARAM}, - ) assert "application/fhir+json" in response.headers["Content-Type"] - def test_response_body_resource_type_is_bundle(self, stub: SdsFhirApiStub) -> None: - """The response body must be a FHIR Bundle resource.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _INTERACTION_ID_PARAM}, - ) body = response.json() assert body["resourceType"] == "Bundle" - - def test_response_body_bundle_type_is_searchset(self, stub: SdsFhirApiStub) -> None: - """The SDS spec requires Bundle.type to be ``searchset``.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() assert body["type"] == "searchset" - - def test_response_bundle_total_matches_entry_count( - self, stub: SdsFhirApiStub - ) -> None: - """Bundle.total must match the number of entries returned.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() assert body["total"] == len(body["entry"]) - def test_response_bundle_entry_has_full_url(self, stub: SdsFhirApiStub) -> None: - """Each entry in the Bundle must have a ``fullUrl`` field.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - assert len(body["entry"]) >= 1 - for entry in body["entry"]: - assert "fullUrl" in entry - assert entry["fullUrl"] # non-empty - - def test_response_bundle_entry_full_url_contains_endpoint_id( - self, stub: SdsFhirApiStub - ) -> None: - """The ``fullUrl`` must contain the Endpoint resource ID.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - for entry in body["entry"]: - endpoint_id = entry["resource"]["id"] - assert endpoint_id in entry["fullUrl"] - - def test_response_bundle_entry_has_search_mode_match( - self, stub: SdsFhirApiStub - ) -> None: - """Each entry must have ``search.mode`` set to ``match``.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - for entry in body["entry"]: + assert len(body["entry"]) == 4 + endpoint_ids = [ + "E0E0E921-92CA-4A88-A550-2DBB36F703AF", + "E1E1E921-92CA-4A88-A550-2DBB36F703AF", + "E2E2E921-92CA-4A88-A550-2DBB36F703AF", + "E3E3E921-92CA-4A88-A550-2DBB36F703AF", + ] + for i in range(0, 4): + entry = body["entry"][i] + + endpoint_id = endpoint_ids[i] + assert ( + entry["fullUrl"] + == f"https://sandbox.api.service.nhs.uk/spine-directory/FHIR/R4/Endpoint/{endpoint_id}" + ) + assert entry["resource"]["id"] == endpoint_id assert entry["search"]["mode"] == "match" def test_query_with_party_key_returns_matching_endpoint( @@ -586,151 +373,38 @@ def test_endpoint_resource_type_is_endpoint(self, stub: SdsFhirApiStub) -> None: params={"identifier": _PARTY_KEY_PROVIDER}, ) body = response.json() - for entry in body["entry"]: - assert entry["resource"]["resourceType"] == "Endpoint" + assert len(body["entry"]) == 1 - def test_endpoint_has_id(self, stub: SdsFhirApiStub) -> None: - """Each Endpoint resource must have an ``id`` field.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _PARTY_KEY_PROVIDER}, - ) - body = response.json() - for entry in body["entry"]: - assert "id" in entry["resource"] - assert entry["resource"]["id"] # non-empty + entry = body["entry"][0] + resource = entry["resource"] + assert resource["resourceType"] == "Endpoint" - def test_endpoint_has_status_active(self, stub: SdsFhirApiStub) -> None: - """The SDS spec requires ``Endpoint.status`` to be ``active``.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _PARTY_KEY_PROVIDER}, - ) - body = response.json() - for entry in body["entry"]: - assert entry["resource"]["status"] == "active" - - def test_endpoint_has_connection_type(self, stub: SdsFhirApiStub) -> None: - """Each Endpoint must have a ``connectionType`` object.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _PARTY_KEY_PROVIDER}, - ) - body = response.json() - for entry in body["entry"]: - assert "connectionType" in entry["resource"] + assert resource["id"] == "E0E0E921-92CA-4A88-A550-2DBB36F703AF" + assert resource["status"] == "active" - def test_endpoint_connection_type_has_system_and_code( - self, stub: SdsFhirApiStub - ) -> None: - """``connectionType`` must have ``system`` and ``code`` fields.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _PARTY_KEY_PROVIDER}, - ) - body = response.json() - for entry in body["entry"]: - ct = entry["resource"]["connectionType"] - assert "system" in ct - assert "code" in ct - - def test_endpoint_has_payload_type(self, stub: SdsFhirApiStub) -> None: - """Each Endpoint must have a ``payloadType`` list.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _PARTY_KEY_PROVIDER}, - ) - body = response.json() - for entry in body["entry"]: - assert isinstance(entry["resource"]["payloadType"], list) - assert len(entry["resource"]["payloadType"]) >= 1 - - def test_endpoint_has_address(self, stub: SdsFhirApiStub) -> None: - """Each Endpoint must have a non-empty ``address`` field.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _PARTY_KEY_PROVIDER}, + ct = resource["connectionType"] + assert ( + ct["system"] + == "https://terminology.hl7.org/CodeSystem/endpoint-connection-type" ) - body = response.json() - for entry in body["entry"]: - assert "address" in entry["resource"] - assert entry["resource"]["address"] # non-empty + assert ct["code"] == "hl7-fhir-rest" - def test_endpoint_has_managing_organization(self, stub: SdsFhirApiStub) -> None: - """Each Endpoint must have a ``managingOrganization`` field.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _PARTY_KEY_PROVIDER}, + assert len(resource["payloadType"]) == 1 + assert resource["address"] == "https://provider.example.com/fhir" + assert ( + resource["managingOrganization"]["identifier"]["system"] + == FHIRSystem.ODS_CODE ) - body = response.json() - for entry in body["entry"]: - assert "managingOrganization" in entry["resource"] - def test_endpoint_managing_organization_uses_ods_system( - self, stub: SdsFhirApiStub - ) -> None: - """Endpoint.managingOrganization.identifier.system must be the ODS code - system.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _PARTY_KEY_PROVIDER}, - ) - body = response.json() - for entry in body["entry"]: - managing_org = entry["resource"]["managingOrganization"] - assert managing_org["identifier"]["system"] == FHIRSystem.ODS_CODE + managing_org = resource["managingOrganization"] + assert managing_org["identifier"]["system"] == FHIRSystem.ODS_CODE - def test_endpoint_has_identifier_list(self, stub: SdsFhirApiStub) -> None: - """Each Endpoint resource must have an ``identifier`` list.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _PARTY_KEY_PROVIDER}, - ) - body = response.json() - for entry in body["entry"]: - assert isinstance(entry["resource"]["identifier"], list) - assert len(entry["resource"]["identifier"]) >= 1 + assert isinstance(resource["identifier"], list) + assert len(resource["identifier"]) == 2 - def test_endpoint_identifier_contains_asid(self, stub: SdsFhirApiStub) -> None: - """Endpoint identifier must include an ASID entry.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _PARTY_KEY_PROVIDER}, - ) - body = response.json() - for entry in body["entry"]: - identifiers = entry["resource"]["identifier"] - asid_entries = [ - i for i in identifiers if i.get("system") == FHIRSystem.NHS_SPINE_ASID - ] - assert len(asid_entries) >= 1 - - def test_endpoint_identifier_contains_party_key(self, stub: SdsFhirApiStub) -> None: - """Endpoint identifier must include a party key entry.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"apikey": "test-key"}, - params={"identifier": _PARTY_KEY_PROVIDER}, - ) - body = response.json() - for entry in body["entry"]: - identifiers = entry["resource"]["identifier"] - party_key_entries = [ - i - for i in identifiers - if i.get("system") == FHIRSystem.NHS_MHS_PARTY_KEY - ] - assert len(party_key_entries) >= 1 + identifiers = resource["identifier"] + assert identifiers[0]["system"] == FHIRSystem.NHS_SPINE_ASID + assert identifiers[1]["system"] == FHIRSystem.NHS_MHS_PARTY_KEY # --------------------------------------------------------------------------- @@ -749,6 +423,7 @@ def test_missing_apikey_returns_400(self, stub: SdsFhirApiStub) -> None: params={"identifier": _INTERACTION_ID_PARAM}, ) assert response.status_code == 400 + self.verify_error_response_body(response, "Missing required header: apikey") def test_missing_identifier_returns_400(self, stub: SdsFhirApiStub) -> None: """``identifier`` is a required query parameter for /Endpoint.""" @@ -758,72 +433,35 @@ def test_missing_identifier_returns_400(self, stub: SdsFhirApiStub) -> None: params={}, ) assert response.status_code == 400 + self.verify_error_response_body( + response, "Missing required query parameter: identifier" + ) - def test_error_response_resource_type_is_operation_outcome( - self, stub: SdsFhirApiStub - ) -> None: - """Every error body must be an ``OperationOutcome``.""" + def test_missing_apikey_echoes_correlation_id(self, stub: SdsFhirApiStub) -> None: + """``X-Correlation-Id`` must be echoed even in error responses.""" response = stub.get_endpoint_bundle( url=_BASE_ENDPOINT_URL, - headers={}, # missing apikey + headers={"X-Correlation-Id": _VALID_CORRELATION_ID}, # no apikey params={"identifier": _INTERACTION_ID_PARAM}, ) - body = response.json() - assert body["resourceType"] == "OperationOutcome" + assert response.status_code == 400 + assert response.headers.get("X-Correlation-Id") == _VALID_CORRELATION_ID + self.verify_error_response_body(response, "Missing required header: apikey") - def test_error_response_has_non_empty_issue_list( - self, stub: SdsFhirApiStub + def verify_error_response_body( + self, response: Any, expected_diagnostics: str ) -> None: - """The OperationOutcome must have a non-empty ``issue`` list.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={}, # missing apikey - params={"identifier": _INTERACTION_ID_PARAM}, - ) body = response.json() + assert body["resourceType"] == "OperationOutcome" + assert isinstance(body.get("issue"), list) - assert len(body["issue"]) >= 1 + assert len(body["issue"]) == 1 - def test_error_response_issue_has_severity(self, stub: SdsFhirApiStub) -> None: - """Each issue in the OperationOutcome must have a ``severity`` field.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={}, # missing apikey - params={"identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - assert "severity" in body["issue"][0] + assert body["issue"][0]["severity"] == "error" + assert body["issue"][0]["code"] == "invalid" - def test_error_response_issue_has_code(self, stub: SdsFhirApiStub) -> None: - """Each issue in the OperationOutcome must have a ``code`` field.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={}, # missing apikey - params={"identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() - assert "code" in body["issue"][0] - - def test_error_response_issue_has_diagnostics(self, stub: SdsFhirApiStub) -> None: - """Each issue must have a non-empty ``diagnostics`` string.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={}, # missing apikey - params={"identifier": _INTERACTION_ID_PARAM}, - ) - body = response.json() assert "diagnostics" in body["issue"][0] - assert body["issue"][0]["diagnostics"] # non-empty - - def test_missing_apikey_echoes_correlation_id(self, stub: SdsFhirApiStub) -> None: - """``X-Correlation-Id`` must be echoed even in error responses.""" - response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, - headers={"X-Correlation-Id": _VALID_CORRELATION_ID}, # no apikey - params={"identifier": _INTERACTION_ID_PARAM}, - ) - assert response.status_code == 400 - assert response.headers.get("X-Correlation-Id") == _VALID_CORRELATION_ID + assert body["issue"][0]["diagnostics"] == expected_diagnostics # --------------------------------------------------------------------------- From f85bac7dd97e17e8b2cac6f37e7715b7c2962931 Mon Sep 17 00:00:00 2001 From: Ian Robinson <267046044+ian-robinson-35@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:47:36 +0000 Subject: [PATCH 4/7] Try making docker build happy --- gateway-api/stubs/stubs/sds/stub.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gateway-api/stubs/stubs/sds/stub.py b/gateway-api/stubs/stubs/sds/stub.py index 17078d57..1482c386 100644 --- a/gateway-api/stubs/stubs/sds/stub.py +++ b/gateway-api/stubs/stubs/sds/stub.py @@ -166,6 +166,7 @@ def get_device_bundle( * ``200`` with Bundle JSON (may be empty) * ``400`` with error details for missing/invalid parameters """ + print(f"get_device_bundle stub from url {url}") headers = headers or {} params = params or {} From fd84d9d5ea08c6e99f7b4fd2e61149e650f38e69 Mon Sep 17 00:00:00 2001 From: Ian Robinson <267046044+ian-robinson-35@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:49:33 +0000 Subject: [PATCH 5/7] Undo, fixed on main --- gateway-api/stubs/stubs/sds/stub.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gateway-api/stubs/stubs/sds/stub.py b/gateway-api/stubs/stubs/sds/stub.py index 1482c386..17078d57 100644 --- a/gateway-api/stubs/stubs/sds/stub.py +++ b/gateway-api/stubs/stubs/sds/stub.py @@ -166,7 +166,6 @@ def get_device_bundle( * ``200`` with Bundle JSON (may be empty) * ``400`` with error details for missing/invalid parameters """ - print(f"get_device_bundle stub from url {url}") headers = headers or {} params = params or {} From 93552599c995aa8f337a10a3c29132d5091abde0 Mon Sep 17 00:00:00 2001 From: Ian Robinson <267046044+ian-robinson-35@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:54:14 +0000 Subject: [PATCH 6/7] Fix merge conflicts --- .../contract/stub/test_sds_stub_contract.py | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/gateway-api/tests/contract/stub/test_sds_stub_contract.py b/gateway-api/tests/contract/stub/test_sds_stub_contract.py index 99f55595..1847ba2e 100644 --- a/gateway-api/tests/contract/stub/test_sds_stub_contract.py +++ b/gateway-api/tests/contract/stub/test_sds_stub_contract.py @@ -62,7 +62,6 @@ class TestGetDeviceBundleSuccess: def test_response_matches_expected(self, stub: SdsFhirApiStub) -> None: response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, headers={"apikey": "test-key"}, params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, ) @@ -89,7 +88,6 @@ def test_x_correlation_id_echoed_back_when_provided( ) -> None: """The spec states ``X-Correlation-Id`` is mirrored back in the response.""" response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, headers={"apikey": "test-key", "X-Correlation-Id": _VALID_CORRELATION_ID}, params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, ) @@ -100,7 +98,6 @@ def test_x_correlation_id_absent_when_not_provided( ) -> None: """``X-Correlation-Id`` must not appear in the response when not supplied.""" response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, headers={"apikey": "test-key"}, params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, ) @@ -109,7 +106,6 @@ def test_x_correlation_id_absent_when_not_provided( def test_empty_bundle_returned_for_unknown_org(self, stub: SdsFhirApiStub) -> None: """An unknown organisation must return a 200 with an empty Bundle.""" response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, headers={"apikey": "test-key"}, params={ "organization": _ORG_UNKNOWN, @@ -127,7 +123,6 @@ def test_query_with_party_key_returns_matching_device( ) -> None: """Including a party key identifier should still return a match.""" response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, headers={"apikey": "test-key"}, params={ "organization": _ORG_PROVIDER, @@ -150,7 +145,6 @@ class TestGetDeviceResourceStructure: def test_device_resource_type_is_device(self, stub: SdsFhirApiStub) -> None: """Each resource inside the Bundle must have ``resourceType: "Device"``.""" response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, headers={"apikey": "test-key"}, params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, ) @@ -182,7 +176,6 @@ class TestGetDeviceBundleValidationErrors: def test_missing_apikey_returns_400(self, stub: SdsFhirApiStub) -> None: """The spec requires the ``apikey`` header; its absence must yield 400.""" response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, headers={}, params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, ) @@ -192,7 +185,6 @@ def test_missing_apikey_returns_400(self, stub: SdsFhirApiStub) -> None: def test_missing_organization_returns_400(self, stub: SdsFhirApiStub) -> None: """``organization`` is a required query parameter for /Device.""" response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, headers={"apikey": "test-key"}, params={"identifier": _INTERACTION_ID_PARAM}, ) @@ -204,7 +196,6 @@ def test_missing_organization_returns_400(self, stub: SdsFhirApiStub) -> None: def test_missing_identifier_returns_400(self, stub: SdsFhirApiStub) -> None: """``identifier`` is a required query parameter for /Device.""" response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, headers={"apikey": "test-key"}, params={"organization": _ORG_PROVIDER}, ) @@ -218,7 +209,6 @@ def test_identifier_without_interaction_id_returns_400( ) -> None: """``identifier`` must include nhsServiceInteractionId for /Device.""" response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, headers={"apikey": "test-key"}, params={ "organization": _ORG_PROVIDER, @@ -234,7 +224,6 @@ def test_identifier_without_interaction_id_returns_400( def test_missing_apikey_echoes_correlation_id(self, stub: SdsFhirApiStub) -> None: """``X-Correlation-Id`` must be echoed even in error responses.""" response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, headers={"X-Correlation-Id": _VALID_CORRELATION_ID}, # no apikey params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, ) @@ -273,7 +262,6 @@ def test_endpoint_bundle_matches_expected_response( self, stub: SdsFhirApiStub ) -> None: response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, headers={"apikey": "test-key"}, params={"identifier": _INTERACTION_ID_PARAM}, ) @@ -308,7 +296,6 @@ def test_query_with_party_key_returns_matching_endpoint( ) -> None: """Including a party key identifier should return matching Endpoint entries.""" response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, headers={"apikey": "test-key"}, params={ "identifier": [_INTERACTION_ID_PARAM, _PARTY_KEY_PROVIDER], @@ -323,7 +310,6 @@ def test_x_correlation_id_echoed_back_when_provided( ) -> None: """The spec states ``X-Correlation-Id`` is mirrored back in the response.""" response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, headers={"apikey": "test-key", "X-Correlation-Id": _VALID_CORRELATION_ID}, params={"identifier": _INTERACTION_ID_PARAM}, ) @@ -334,7 +320,6 @@ def test_x_correlation_id_absent_when_not_provided( ) -> None: """``X-Correlation-Id`` must not appear in the response when not supplied.""" response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, headers={"apikey": "test-key"}, params={"identifier": _INTERACTION_ID_PARAM}, ) @@ -346,7 +331,6 @@ def test_empty_bundle_returned_for_unknown_party_key( """A party key not present in the stub must yield an empty Bundle.""" unknown_party_key = f"{FHIRSystem.NHS_MHS_PARTY_KEY}|UNKNOWN-9999999" response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, headers={"apikey": "test-key"}, params={"identifier": unknown_party_key}, ) @@ -368,7 +352,6 @@ class TestGetEndpointResourceStructure: def test_endpoint_resource_type_is_endpoint(self, stub: SdsFhirApiStub) -> None: """Each resource inside the Bundle must have ``resourceType: "Endpoint"``.""" response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, headers={"apikey": "test-key"}, params={"identifier": _PARTY_KEY_PROVIDER}, ) @@ -418,7 +401,6 @@ class TestGetEndpointBundleValidationErrors: def test_missing_apikey_returns_400(self, stub: SdsFhirApiStub) -> None: """The spec requires the ``apikey`` header; its absence must yield 400.""" response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, headers={}, params={"identifier": _INTERACTION_ID_PARAM}, ) @@ -428,7 +410,6 @@ def test_missing_apikey_returns_400(self, stub: SdsFhirApiStub) -> None: def test_missing_identifier_returns_400(self, stub: SdsFhirApiStub) -> None: """``identifier`` is a required query parameter for /Endpoint.""" response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, headers={"apikey": "test-key"}, params={}, ) @@ -440,7 +421,6 @@ def test_missing_identifier_returns_400(self, stub: SdsFhirApiStub) -> None: def test_missing_apikey_echoes_correlation_id(self, stub: SdsFhirApiStub) -> None: """``X-Correlation-Id`` must be echoed even in error responses.""" response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, headers={"X-Correlation-Id": _VALID_CORRELATION_ID}, # no apikey params={"identifier": _INTERACTION_ID_PARAM}, ) @@ -568,7 +548,6 @@ def test_upsert_device_is_returned_by_get_device_bundle( ) response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, headers={"apikey": "test-key"}, params={ "organization": f"{FHIRSystem.ODS_CODE}|NEWORG", @@ -584,7 +563,6 @@ def test_clear_devices_removes_all_devices(self, stub: SdsFhirApiStub) -> None: stub.clear_devices() response = stub.get_device_bundle( - url=_BASE_DEVICE_URL, headers={"apikey": "test-key"}, params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, ) @@ -624,7 +602,6 @@ def test_upsert_endpoint_is_returned_by_get_endpoint_bundle( ) response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, headers={"apikey": "test-key"}, params={ "identifier": f"{FHIRSystem.NHS_MHS_PARTY_KEY}|{new_party_key}", @@ -639,7 +616,6 @@ def test_clear_endpoints_removes_all_endpoints(self, stub: SdsFhirApiStub) -> No stub.clear_endpoints() response = stub.get_endpoint_bundle( - url=_BASE_ENDPOINT_URL, headers={"apikey": "test-key"}, params={"identifier": _PARTY_KEY_PROVIDER}, ) From 65d765d0eeb93c869fe960f6f5e6d8bff1e76750 Mon Sep 17 00:00:00 2001 From: Ian Robinson <267046044+ian-robinson-35@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:08:59 +0000 Subject: [PATCH 7/7] Remove remaining >= assertions --- .../tests/contract/stub/test_sds_stub_contract.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/gateway-api/tests/contract/stub/test_sds_stub_contract.py b/gateway-api/tests/contract/stub/test_sds_stub_contract.py index 1847ba2e..e8a7f577 100644 --- a/gateway-api/tests/contract/stub/test_sds_stub_contract.py +++ b/gateway-api/tests/contract/stub/test_sds_stub_contract.py @@ -131,7 +131,7 @@ def test_query_with_party_key_returns_matching_device( ) assert response.status_code == 200 body = response.json() - assert body["total"] >= 1 + assert body["total"] == 1 # --------------------------------------------------------------------------- @@ -280,7 +280,7 @@ def test_endpoint_bundle_matches_expected_response( "E2E2E921-92CA-4A88-A550-2DBB36F703AF", "E3E3E921-92CA-4A88-A550-2DBB36F703AF", ] - for i in range(0, 4): + for i in range(len(endpoint_ids)): entry = body["entry"][i] endpoint_id = endpoint_ids[i] @@ -303,7 +303,7 @@ def test_query_with_party_key_returns_matching_endpoint( ) assert response.status_code == 200 body = response.json() - assert body["total"] >= 1 + assert body["total"] == 1 def test_x_correlation_id_echoed_back_when_provided( self, stub: SdsFhirApiStub @@ -463,9 +463,8 @@ def test_device_url_returns_device_bundle(self, stub: SdsFhirApiStub) -> None: body = response.json() assert body["resourceType"] == "Bundle" # Verify Device resources were returned - assert len(body["entry"]) >= 1 - for entry in body["entry"]: - assert entry["resource"]["resourceType"] == "Device" + assert len(body["entry"]) == 1 + assert body["entry"][0]["resource"]["resourceType"] == "Device" def test_endpoint_url_returns_endpoint_bundle(self, stub: SdsFhirApiStub) -> None: """A URL containing ``/Endpoint`` must be routed to get_endpoint_bundle."""