From 4bac742c7a568ea3a88a5312c0616e6b5332075f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:55:54 +0000 Subject: [PATCH 1/6] Initial plan From 2e2cb2785943fce94f403662bbe9c033516757d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:04:25 +0000 Subject: [PATCH 2/6] Add SDS stub contract tests and documentation Agent-Logs-Url: https://github.com/NHSDigital/clinical-data-gateway-api/sessions/2f2c1323-5612-4a91-8fa4-596ae1a54dc3 Co-authored-by: Vox-Ben <59649826+Vox-Ben@users.noreply.github.com> --- .../contract/stub/sds_stub_contract_tests.md | 461 +++++++ .../contract/stub/test_sds_stub_contract.py | 1106 +++++++++++++++++ 2 files changed, 1567 insertions(+) create mode 100644 gateway-api/tests/contract/stub/sds_stub_contract_tests.md create mode 100644 gateway-api/tests/contract/stub/test_sds_stub_contract.py diff --git a/gateway-api/tests/contract/stub/sds_stub_contract_tests.md b/gateway-api/tests/contract/stub/sds_stub_contract_tests.md new file mode 100644 index 00000000..f8e1b497 --- /dev/null +++ b/gateway-api/tests/contract/stub/sds_stub_contract_tests.md @@ -0,0 +1,461 @@ +# SDS Stub Contract Test Documentation + +## Overview + +The file `test_sds_stub_contract.py` contains contract tests for the +`SdsFhirApiStub` class. This stub is an in-memory implementation of the Spine +Directory Service (SDS) FHIR R4 API used during local and integration testing. +Its purpose is to stand in for the real SDS service without requiring a live +network connection. + +The contract tests verify that the stub faithfully reproduces the HTTP behaviour +described in the SDS OpenAPI specification at +. The tests call the +stub's methods directly (no HTTP server is started) and inspect the returned +`requests.Response` objects. + +--- + +## Module-Level Constants + +At the top of the module, several string constants are defined for use across +all test classes. + +### FHIR-Formatted Query Parameter Constants + +The SDS API uses FHIR-style parameter encoding where both a system URL and a +value are joined with a pipe character (`|`). Four constants capture the most +frequently used combinations: + +- `_ORG_PROVIDER` — The `organization` query parameter value for the seeded + "PROVIDER" organisation. Constructed by combining the ODS code FHIR system + URL (`https://fhir.nhs.uk/Id/ods-organization-code`) with the literal string + `PROVIDER`. + +- `_ORG_CONSUMER` — The same construction for the seeded "CONSUMER" + organisation. + +- `_ORG_UNKNOWN` — A syntactically valid organisation parameter whose ODS code + (`UNKNOWN_ORG_XYZ`) is guaranteed to have no matching records in the stub. + +- `_INTERACTION_ID_PARAM` — The `identifier` query parameter value representing + the GP Connect "Access Record Structured" service interaction. Constructed by + combining the NHS service interaction ID system URL + (`https://fhir.nhs.uk/Id/nhsServiceInteractionId`) with the canonical + interaction identifier + `urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1`. + +- `_PARTY_KEY_PROVIDER` — The `identifier` query parameter value for the + PROVIDER organisation's MHS party key. Constructed by combining the party + key system URL (`https://fhir.nhs.uk/Id/nhsMhsPartyKey`) with + `PROVIDER-0000806`. + +- `_PARTY_KEY_CONSUMER` — The same construction for the CONSUMER organisation + using `CONSUMER-0000807`. + +- `_VALID_CORRELATION_ID` — A fixed string used as a sample `X-Correlation-Id` + header value in tests that verify header echo behaviour. + +### URL Constants + +- `_BASE_DEVICE_URL` — The canonical sandbox URL for the SDS `/Device` + endpoint. + +- `_BASE_ENDPOINT_URL` — The canonical sandbox URL for the SDS `/Endpoint` + endpoint. + +--- + +## Fixtures + +### `stub` + +Creates and returns a new `SdsFhirApiStub` instance. The stub constructor +seeds itself with a set of deterministic Device and Endpoint records, so every +test starts with a known, consistent data state. Because the instance is created +fresh for each test, mutations in one test (such as clearing or adding records) +do not affect other tests. + +--- + +## Test Classes + +### `TestGetDeviceBundleSuccess` + +This class tests the normal ("happy path") behaviour of the `get_device_bundle` +method when all required inputs are present and the queried organisation exists +in the stub's data store. + +Every call in this class supplies the `apikey` header and passes `_ORG_PROVIDER` +as the `organization` parameter together with `_INTERACTION_ID_PARAM` as the +`identifier` parameter. + +**`test_status_code_is_200`** +Calls `get_device_bundle` with valid inputs and asserts that the HTTP status +code of the response is 200. + +**`test_content_type_is_fhir_json`** +Asserts that the `Content-Type` response header contains the string +`application/fhir+json`, which is the media type mandated by the FHIR +specification for all FHIR API responses. + +**`test_response_body_resource_type_is_bundle`** +Parses the response body as JSON and asserts that the `resourceType` field +equals `"Bundle"`. The SDS spec requires that search results are wrapped in a +FHIR Bundle. + +**`test_response_body_bundle_type_is_searchset`** +Asserts that `Bundle.type` equals `"searchset"`. This is the mandatory Bundle +type for FHIR search responses. + +**`test_response_bundle_total_matches_entry_count`** +Asserts that `Bundle.total` (an integer in the response body) equals the number +of items in the `Bundle.entry` array. The FHIR spec requires these two values +to be consistent. + +**`test_response_bundle_has_at_least_one_entry_for_known_org`** +Asserts that `Bundle.total` is at least 1. Because the stub is pre-seeded with +a Device for the PROVIDER organisation, a search for that organisation must +return a non-empty result. + +**`test_response_bundle_entry_has_full_url`** +Iterates over every entry in `Bundle.entry` and asserts that each entry has a +non-empty `fullUrl` field. The FHIR spec requires `fullUrl` to be present in +every Bundle entry. + +**`test_response_bundle_entry_full_url_contains_device_id`** +For every entry, reads `entry.resource.id` and asserts that this value appears +as a substring of `entry.fullUrl`. This verifies that the URL is constructed +from the resource's own identifier. + +**`test_response_bundle_entry_has_resource`** +Asserts that every Bundle entry has a `resource` field. This is the FHIR +container that holds the actual Device or Endpoint object. + +**`test_response_bundle_entry_has_search_mode_match`** +Asserts that `entry.search.mode` equals `"match"` for every entry. This is the +value required by FHIR for entries that directly satisfy the search criteria. + +**`test_x_correlation_id_echoed_back_when_provided`** +Passes `X-Correlation-Id: test-correlation-id-12345` in the request headers and +asserts that the same value appears under `X-Correlation-Id` in the response +headers. The SDS spec states that this header must be mirrored back. + +**`test_x_correlation_id_absent_when_not_provided`** +Does not pass `X-Correlation-Id` and asserts that the header is absent from the +response. This prevents the stub from inventing correlation IDs. + +**`test_empty_bundle_returned_for_unknown_org`** +Passes `_ORG_UNKNOWN` as the organisation parameter and asserts that the +response is still 200 (not 404), that `resourceType` is `"Bundle"`, that +`total` is 0, and that `entry` is an empty list. The SDS spec returns an empty +Bundle rather than a 404 when no records match. + +**`test_query_with_party_key_returns_matching_device`** +Passes both `_INTERACTION_ID_PARAM` and `_PARTY_KEY_PROVIDER` as a list in the +`identifier` parameter and asserts that `Bundle.total` is at least 1. This +verifies that the stub correctly handles multi-value identifier queries. + +--- + +### `TestGetDeviceResourceStructure` + +This class verifies the internal structure of each `Device` resource returned +inside the Bundle. Every test in this class makes a valid query for the PROVIDER +organisation and then inspects each `entry.resource` object. + +**`test_device_resource_type_is_device`** +Asserts that every resource has `resourceType` equal to `"Device"`. + +**`test_device_has_id`** +Asserts that every resource has a non-empty `id` field. + +**`test_device_has_identifier_list`** +Asserts that `Device.identifier` is a list containing at least one element. + +**`test_device_identifier_contains_asid`** +Searches `Device.identifier` for an entry whose `system` equals the NHS Spine +ASID system URL (`https://fhir.nhs.uk/Id/nhsSpineASID`) and asserts that at +least one such entry exists. + +**`test_device_identifier_contains_party_key`** +Searches `Device.identifier` for an entry whose `system` equals the MHS party +key system URL (`https://fhir.nhs.uk/Id/nhsMhsPartyKey`) and asserts that at +least one such entry exists. + +**`test_device_has_owner`** +Asserts that every Device resource has an `owner` field. + +**`test_device_owner_identifier_uses_ods_system`** +Navigates to `Device.owner.identifier.system` and asserts it equals the ODS +code FHIR system URL (`https://fhir.nhs.uk/Id/ods-organization-code`). This +links the device to its owning organisation. + +--- + +### `TestGetDeviceBundleValidationErrors` + +This class tests the stub's input validation for `get_device_bundle`. Each test +deliberately omits or corrupts a required input and asserts that the stub +responds with HTTP 400 and an `OperationOutcome` body. + +**`test_missing_apikey_returns_400`** +Passes an empty headers dictionary (no `apikey`). The SDS API requires this +header; its absence must yield 400. + +**`test_missing_organization_returns_400`** +Omits the `organization` query parameter. This parameter is mandatory for +`/Device` queries. + +**`test_missing_identifier_returns_400`** +Omits the `identifier` query parameter entirely. + +**`test_identifier_without_interaction_id_returns_400`** +Passes only `_PARTY_KEY_PROVIDER` as the `identifier`, omitting the +`nhsServiceInteractionId` component. The stub requires the interaction ID to be +present in the identifier list for Device queries. + +**`test_error_response_resource_type_is_operation_outcome`** +Sends a request with a missing `apikey` header and asserts that the response +body has `resourceType` equal to `"OperationOutcome"`. All FHIR error responses +must use this resource type. + +**`test_error_response_has_non_empty_issue_list`** +Asserts that the `OperationOutcome.issue` field is a list containing at least +one element. + +**`test_error_response_issue_has_severity`** +Asserts that `issue[0]` contains a `severity` field. FHIR requires every issue +to carry a severity. + +**`test_error_response_issue_has_code`** +Asserts that `issue[0]` contains a `code` field. FHIR requires every issue to +carry an issue type code. + +**`test_error_response_issue_has_diagnostics`** +Asserts that `issue[0]` contains a non-empty `diagnostics` field. This free-text +field carries the human-readable error description. + +**`test_missing_apikey_echoes_correlation_id`** +Passes `X-Correlation-Id` without `apikey` and asserts that, even though the +response is 400, the correlation ID is still echoed back in the response +headers. + +--- + +### `TestGetEndpointBundleSuccess` + +This class mirrors `TestGetDeviceBundleSuccess` but for the `get_endpoint_bundle` +method. The key difference is that `organization` is optional for Endpoint +queries; the minimum required parameter is `identifier`. + +**`test_status_code_is_200`** +Calls `get_endpoint_bundle` with the `apikey` header and `_INTERACTION_ID_PARAM` +as the identifier and asserts that the status code is 200. + +**`test_content_type_is_fhir_json`** +Verifies `Content-Type: application/fhir+json` in the response. + +**`test_response_body_resource_type_is_bundle`** +Verifies `Bundle` as the top-level resource type. + +**`test_response_body_bundle_type_is_searchset`** +Verifies `Bundle.type` is `"searchset"`. + +**`test_response_bundle_total_matches_entry_count`** +Verifies that `Bundle.total` equals the length of `Bundle.entry`. + +**`test_response_bundle_has_entries_for_known_interaction_id`** +Asserts that querying by the known seeded interaction ID returns at least one +Endpoint entry. + +**`test_response_bundle_entry_has_full_url`** +Asserts every entry has a non-empty `fullUrl`. + +**`test_response_bundle_entry_full_url_contains_endpoint_id`** +Asserts each entry's `fullUrl` contains the corresponding Endpoint resource ID. + +**`test_response_bundle_entry_has_resource`** +Asserts every entry has a `resource` field. + +**`test_response_bundle_entry_has_search_mode_match`** +Asserts `entry.search.mode` equals `"match"` for every entry. + +**`test_organization_is_optional_for_endpoint`** +Makes a valid request with no `organization` parameter and asserts the status +code is 200. This explicitly documents the difference in required parameters +between `/Device` and `/Endpoint`. + +**`test_query_with_party_key_returns_matching_endpoint`** +Passes both `_INTERACTION_ID_PARAM` and `_PARTY_KEY_PROVIDER` as identifiers +and asserts at least one entry is returned. + +**`test_x_correlation_id_echoed_back_when_provided`** +Verifies the correlation ID echo behaviour for Endpoint responses. + +**`test_x_correlation_id_absent_when_not_provided`** +Verifies the correlation ID is absent when not provided. + +**`test_empty_bundle_returned_for_unknown_party_key`** +Queries with a party key that is not in the stub and asserts a 200 response +with `total: 0` and an empty `entry` array. + +--- + +### `TestGetEndpointResourceStructure` + +This class inspects the internal structure of each `Endpoint` resource returned +by the stub. All tests query the stub using `_PARTY_KEY_PROVIDER` as the +identifier so that results are limited to the PROVIDER's seeded endpoint. + +**`test_endpoint_resource_type_is_endpoint`** +Asserts every resource has `resourceType` equal to `"Endpoint"`. + +**`test_endpoint_has_id`** +Asserts every resource has a non-empty `id` field. + +**`test_endpoint_has_status_active`** +Asserts `Endpoint.status` equals `"active"`. The SDS spec requires active +endpoints. + +**`test_endpoint_has_connection_type`** +Asserts the `connectionType` field is present. + +**`test_endpoint_connection_type_has_system_and_code`** +Navigates into `connectionType` and asserts both `system` and `code` fields are +present. + +**`test_endpoint_has_payload_type`** +Asserts `Endpoint.payloadType` is a non-empty list. + +**`test_endpoint_has_address`** +Asserts `Endpoint.address` is present and non-empty. This is the actual network +URL of the endpoint. + +**`test_endpoint_has_managing_organization`** +Asserts the `managingOrganization` field is present. + +**`test_endpoint_managing_organization_uses_ods_system`** +Navigates to `Endpoint.managingOrganization.identifier.system` and asserts it +equals the ODS code FHIR system URL. + +**`test_endpoint_has_identifier_list`** +Asserts `Endpoint.identifier` is a non-empty list. + +**`test_endpoint_identifier_contains_asid`** +Searches `Endpoint.identifier` for an entry with the NHS Spine ASID system URL +and asserts it is present. + +**`test_endpoint_identifier_contains_party_key`** +Searches `Endpoint.identifier` for an entry with the MHS party key system URL +and asserts it is present. + +--- + +### `TestGetEndpointBundleValidationErrors` + +This class tests the stub's validation for `get_endpoint_bundle` inputs. + +**`test_missing_apikey_returns_400`** +Omits `apikey` and asserts 400. + +**`test_missing_identifier_returns_400`** +Passes empty params (no `identifier`) and asserts 400. For `/Endpoint`, the +`identifier` parameter is the only mandatory query parameter. + +**`test_error_response_resource_type_is_operation_outcome`** +Asserts the error body `resourceType` is `"OperationOutcome"`. + +**`test_error_response_has_non_empty_issue_list`** +Asserts `issue` is a non-empty list. + +**`test_error_response_issue_has_severity`** +Asserts `issue[0].severity` is present. + +**`test_error_response_issue_has_code`** +Asserts `issue[0].code` is present. + +**`test_error_response_issue_has_diagnostics`** +Asserts `issue[0].diagnostics` is a non-empty string. + +**`test_missing_apikey_echoes_correlation_id`** +Passes `X-Correlation-Id` without `apikey` and asserts the correlation ID +appears in the 400 response headers. + +--- + +### `TestGetConvenienceMethod` + +This class tests the `get` method, which is a unified entry point that +delegates to either `get_device_bundle` or `get_endpoint_bundle` based on the +URL, and also records all call metadata for later inspection. + +**`test_device_url_returns_device_bundle`** +Calls `get` with `_BASE_DEVICE_URL` (which contains `/Device`) and asserts the +response is a Bundle of Device resources. This verifies the routing logic: when +the URL contains the substring `/Device`, the call is delegated to +`get_device_bundle`. + +**`test_endpoint_url_returns_endpoint_bundle`** +Calls `get` with `_BASE_ENDPOINT_URL` (which contains `/Endpoint`) and asserts +the response is a Bundle of Endpoint resources. This verifies that any URL +containing `/Endpoint` is routed to `get_endpoint_bundle`. + +**`test_get_records_last_url`** +After calling `get`, asserts that `stub.get_url` equals the URL that was passed +in. The stub stores this so test code can later verify what URL was actually +called. + +**`test_get_records_last_headers`** +After calling `get`, asserts that `stub.get_headers` equals the headers +dictionary that was passed in. + +**`test_get_records_last_params`** +After calling `get`, asserts that `stub.get_params` equals the params +dictionary that was passed in. + +**`test_get_records_last_timeout`** +Calls `get` with `timeout=30` and asserts that `stub.get_timeout` equals 30. + +**`test_get_device_without_apikey_returns_400`** +Calls `get` with a device URL but no `apikey` header and asserts the response +is 400. This verifies that validation is not bypassed by the routing layer. + +**`test_get_endpoint_without_apikey_returns_400`** +Calls `get` with an endpoint URL but no `apikey` header and asserts 400. + +--- + +### `TestUpsertOperations` + +This class tests the stub's public data-management API: `upsert_device`, +`clear_devices`, `upsert_endpoint`, and `clear_endpoints`. These methods allow +test code to customise the stub's data store. + +**`test_upsert_device_is_returned_by_get_device_bundle`** +First clears all devices with `clear_devices`, then constructs a minimal Device +dictionary with `resourceType: "Device"`, `id: "new-device-123"`, an empty +identifier list, and an owner identifier using the ODS code system. It calls +`upsert_device` with ODS code `"NEWORG"`, the standard interaction ID, no party +key, and this device dictionary. It then queries `get_device_bundle` for +`"NEWORG"` and asserts that `Bundle.total` is 1 and that the single entry has +`id` equal to `"new-device-123"`. + +**`test_clear_devices_removes_all_devices`** +Calls `clear_devices` and then queries for the PROVIDER organisation. Asserts +that `Bundle.total` is 0, confirming that clearing removes all seeded data. + +**`test_upsert_endpoint_is_returned_by_get_endpoint_bundle`** +First clears all endpoints. Constructs a complete Endpoint dictionary with +`resourceType: "Endpoint"`, `id: "new-endpoint-456"`, `status: "active"`, a +`connectionType` using the stub's `CONNECTION_SYSTEM` constant, a `payloadType` +using the stub's `CODING_SYSTEM` constant, an `address` URL, a +`managingOrganization` using the ODS code system, and an `identifier` list +containing the new party key. Calls `upsert_endpoint` with ODS code `"NEWORG"`, +the standard interaction ID, the new party key, and this endpoint dictionary. +Queries `get_endpoint_bundle` by the new party key and asserts `Bundle.total` +is 1 and the entry ID is `"new-endpoint-456"`. + +**`test_clear_endpoints_removes_all_endpoints`** +Calls `clear_endpoints` and then queries using `_PARTY_KEY_PROVIDER`. Asserts +`Bundle.total` is 0. 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..b16aae58 --- /dev/null +++ b/gateway-api/tests/contract/stub/test_sds_stub_contract.py @@ -0,0 +1,1106 @@ +"""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 +import requests +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 successful retrieval +# --------------------------------------------------------------------------- + + +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_has_at_least_one_entry_for_known_org( + self, stub: SdsFhirApiStub + ) -> None: + """A known seeded organisation must return at least one Device 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() + assert body["total"] >= 1 + + 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_resource(self, stub: SdsFhirApiStub) -> None: + """Each entry must have a ``resource`` 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 "resource" in entry + + 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_has_entries_for_known_interaction_id( + self, stub: SdsFhirApiStub + ) -> None: + """A known seeded interaction ID must return at least one Endpoint entry.""" + response = stub.get_endpoint_bundle( + url=_BASE_ENDPOINT_URL, + headers={"apikey": "test-key"}, + params={"identifier": _INTERACTION_ID_PARAM}, + ) + body = response.json() + assert body["total"] >= 1 + + 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_resource(self, stub: SdsFhirApiStub) -> None: + """Each entry must have a ``resource`` 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 "resource" in entry + + 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_organization_is_optional_for_endpoint( + self, stub: SdsFhirApiStub + ) -> None: + """Unlike /Device, the ``organization`` parameter is optional for /Endpoint.""" + 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_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 were 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 + + def test_get_device_without_apikey_returns_400(self, stub: SdsFhirApiStub) -> None: + """Missing ``apikey`` header must yield 400 when routed to /Device.""" + response = stub.get( + url=_BASE_DEVICE_URL, + headers={}, + params={"organization": _ORG_PROVIDER, "identifier": _INTERACTION_ID_PARAM}, + ) + assert response.status_code == 400 + + def test_get_endpoint_without_apikey_returns_400( + self, stub: SdsFhirApiStub + ) -> None: + """Missing ``apikey`` header must yield 400 when routed to /Endpoint.""" + response = stub.get( + url=_BASE_ENDPOINT_URL, + headers={}, + params={"identifier": _INTERACTION_ID_PARAM}, + ) + assert response.status_code == 400 + + +# --------------------------------------------------------------------------- +# 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 = { + "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 = { + "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 a0a1fdc013c80cab1981ef4b619589bc6bfb4076 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:42:47 +0000 Subject: [PATCH 3/6] Remove trailing whitespace from sds_stub_contract_tests.md Agent-Logs-Url: https://github.com/NHSDigital/clinical-data-gateway-api/sessions/617138e2-3608-4bcd-8875-926a753af97d Co-authored-by: Vox-Ben <59649826+Vox-Ben@users.noreply.github.com> --- .../contract/stub/sds_stub_contract_tests.md | 156 +++++++++--------- 1 file changed, 78 insertions(+), 78 deletions(-) diff --git a/gateway-api/tests/contract/stub/sds_stub_contract_tests.md b/gateway-api/tests/contract/stub/sds_stub_contract_tests.md index f8e1b497..9a16c08e 100644 --- a/gateway-api/tests/contract/stub/sds_stub_contract_tests.md +++ b/gateway-api/tests/contract/stub/sds_stub_contract_tests.md @@ -90,68 +90,68 @@ Every call in this class supplies the `apikey` header and passes `_ORG_PROVIDER` as the `organization` parameter together with `_INTERACTION_ID_PARAM` as the `identifier` parameter. -**`test_status_code_is_200`** +**`test_status_code_is_200`** Calls `get_device_bundle` with valid inputs and asserts that the HTTP status code of the response is 200. -**`test_content_type_is_fhir_json`** +**`test_content_type_is_fhir_json`** Asserts that the `Content-Type` response header contains the string `application/fhir+json`, which is the media type mandated by the FHIR specification for all FHIR API responses. -**`test_response_body_resource_type_is_bundle`** +**`test_response_body_resource_type_is_bundle`** Parses the response body as JSON and asserts that the `resourceType` field equals `"Bundle"`. The SDS spec requires that search results are wrapped in a FHIR Bundle. -**`test_response_body_bundle_type_is_searchset`** +**`test_response_body_bundle_type_is_searchset`** Asserts that `Bundle.type` equals `"searchset"`. This is the mandatory Bundle type for FHIR search responses. -**`test_response_bundle_total_matches_entry_count`** +**`test_response_bundle_total_matches_entry_count`** Asserts that `Bundle.total` (an integer in the response body) equals the number of items in the `Bundle.entry` array. The FHIR spec requires these two values to be consistent. -**`test_response_bundle_has_at_least_one_entry_for_known_org`** +**`test_response_bundle_has_at_least_one_entry_for_known_org`** Asserts that `Bundle.total` is at least 1. Because the stub is pre-seeded with a Device for the PROVIDER organisation, a search for that organisation must return a non-empty result. -**`test_response_bundle_entry_has_full_url`** +**`test_response_bundle_entry_has_full_url`** Iterates over every entry in `Bundle.entry` and asserts that each entry has a non-empty `fullUrl` field. The FHIR spec requires `fullUrl` to be present in every Bundle entry. -**`test_response_bundle_entry_full_url_contains_device_id`** +**`test_response_bundle_entry_full_url_contains_device_id`** For every entry, reads `entry.resource.id` and asserts that this value appears as a substring of `entry.fullUrl`. This verifies that the URL is constructed from the resource's own identifier. -**`test_response_bundle_entry_has_resource`** +**`test_response_bundle_entry_has_resource`** Asserts that every Bundle entry has a `resource` field. This is the FHIR container that holds the actual Device or Endpoint object. -**`test_response_bundle_entry_has_search_mode_match`** +**`test_response_bundle_entry_has_search_mode_match`** Asserts that `entry.search.mode` equals `"match"` for every entry. This is the value required by FHIR for entries that directly satisfy the search criteria. -**`test_x_correlation_id_echoed_back_when_provided`** +**`test_x_correlation_id_echoed_back_when_provided`** Passes `X-Correlation-Id: test-correlation-id-12345` in the request headers and asserts that the same value appears under `X-Correlation-Id` in the response headers. The SDS spec states that this header must be mirrored back. -**`test_x_correlation_id_absent_when_not_provided`** +**`test_x_correlation_id_absent_when_not_provided`** Does not pass `X-Correlation-Id` and asserts that the header is absent from the response. This prevents the stub from inventing correlation IDs. -**`test_empty_bundle_returned_for_unknown_org`** +**`test_empty_bundle_returned_for_unknown_org`** Passes `_ORG_UNKNOWN` as the organisation parameter and asserts that the response is still 200 (not 404), that `resourceType` is `"Bundle"`, that `total` is 0, and that `entry` is an empty list. The SDS spec returns an empty Bundle rather than a 404 when no records match. -**`test_query_with_party_key_returns_matching_device`** +**`test_query_with_party_key_returns_matching_device`** Passes both `_INTERACTION_ID_PARAM` and `_PARTY_KEY_PROVIDER` as a list in the `identifier` parameter and asserts that `Bundle.total` is at least 1. This verifies that the stub correctly handles multi-value identifier queries. @@ -164,29 +164,29 @@ This class verifies the internal structure of each `Device` resource returned inside the Bundle. Every test in this class makes a valid query for the PROVIDER organisation and then inspects each `entry.resource` object. -**`test_device_resource_type_is_device`** +**`test_device_resource_type_is_device`** Asserts that every resource has `resourceType` equal to `"Device"`. -**`test_device_has_id`** +**`test_device_has_id`** Asserts that every resource has a non-empty `id` field. -**`test_device_has_identifier_list`** +**`test_device_has_identifier_list`** Asserts that `Device.identifier` is a list containing at least one element. -**`test_device_identifier_contains_asid`** +**`test_device_identifier_contains_asid`** Searches `Device.identifier` for an entry whose `system` equals the NHS Spine ASID system URL (`https://fhir.nhs.uk/Id/nhsSpineASID`) and asserts that at least one such entry exists. -**`test_device_identifier_contains_party_key`** +**`test_device_identifier_contains_party_key`** Searches `Device.identifier` for an entry whose `system` equals the MHS party key system URL (`https://fhir.nhs.uk/Id/nhsMhsPartyKey`) and asserts that at least one such entry exists. -**`test_device_has_owner`** +**`test_device_has_owner`** Asserts that every Device resource has an `owner` field. -**`test_device_owner_identifier_uses_ods_system`** +**`test_device_owner_identifier_uses_ods_system`** Navigates to `Device.owner.identifier.system` and asserts it equals the ODS code FHIR system URL (`https://fhir.nhs.uk/Id/ods-organization-code`). This links the device to its owning organisation. @@ -199,44 +199,44 @@ This class tests the stub's input validation for `get_device_bundle`. Each test deliberately omits or corrupts a required input and asserts that the stub responds with HTTP 400 and an `OperationOutcome` body. -**`test_missing_apikey_returns_400`** +**`test_missing_apikey_returns_400`** Passes an empty headers dictionary (no `apikey`). The SDS API requires this header; its absence must yield 400. -**`test_missing_organization_returns_400`** +**`test_missing_organization_returns_400`** Omits the `organization` query parameter. This parameter is mandatory for `/Device` queries. -**`test_missing_identifier_returns_400`** +**`test_missing_identifier_returns_400`** Omits the `identifier` query parameter entirely. -**`test_identifier_without_interaction_id_returns_400`** +**`test_identifier_without_interaction_id_returns_400`** Passes only `_PARTY_KEY_PROVIDER` as the `identifier`, omitting the `nhsServiceInteractionId` component. The stub requires the interaction ID to be present in the identifier list for Device queries. -**`test_error_response_resource_type_is_operation_outcome`** +**`test_error_response_resource_type_is_operation_outcome`** Sends a request with a missing `apikey` header and asserts that the response body has `resourceType` equal to `"OperationOutcome"`. All FHIR error responses must use this resource type. -**`test_error_response_has_non_empty_issue_list`** +**`test_error_response_has_non_empty_issue_list`** Asserts that the `OperationOutcome.issue` field is a list containing at least one element. -**`test_error_response_issue_has_severity`** +**`test_error_response_issue_has_severity`** Asserts that `issue[0]` contains a `severity` field. FHIR requires every issue to carry a severity. -**`test_error_response_issue_has_code`** +**`test_error_response_issue_has_code`** Asserts that `issue[0]` contains a `code` field. FHIR requires every issue to carry an issue type code. -**`test_error_response_issue_has_diagnostics`** +**`test_error_response_issue_has_diagnostics`** Asserts that `issue[0]` contains a non-empty `diagnostics` field. This free-text field carries the human-readable error description. -**`test_missing_apikey_echoes_correlation_id`** +**`test_missing_apikey_echoes_correlation_id`** Passes `X-Correlation-Id` without `apikey` and asserts that, even though the response is 400, the correlation ID is still echoed back in the response headers. @@ -249,54 +249,54 @@ This class mirrors `TestGetDeviceBundleSuccess` but for the `get_endpoint_bundle method. The key difference is that `organization` is optional for Endpoint queries; the minimum required parameter is `identifier`. -**`test_status_code_is_200`** +**`test_status_code_is_200`** Calls `get_endpoint_bundle` with the `apikey` header and `_INTERACTION_ID_PARAM` as the identifier and asserts that the status code is 200. -**`test_content_type_is_fhir_json`** +**`test_content_type_is_fhir_json`** Verifies `Content-Type: application/fhir+json` in the response. -**`test_response_body_resource_type_is_bundle`** +**`test_response_body_resource_type_is_bundle`** Verifies `Bundle` as the top-level resource type. -**`test_response_body_bundle_type_is_searchset`** +**`test_response_body_bundle_type_is_searchset`** Verifies `Bundle.type` is `"searchset"`. -**`test_response_bundle_total_matches_entry_count`** +**`test_response_bundle_total_matches_entry_count`** Verifies that `Bundle.total` equals the length of `Bundle.entry`. -**`test_response_bundle_has_entries_for_known_interaction_id`** +**`test_response_bundle_has_entries_for_known_interaction_id`** Asserts that querying by the known seeded interaction ID returns at least one Endpoint entry. -**`test_response_bundle_entry_has_full_url`** +**`test_response_bundle_entry_has_full_url`** Asserts every entry has a non-empty `fullUrl`. -**`test_response_bundle_entry_full_url_contains_endpoint_id`** +**`test_response_bundle_entry_full_url_contains_endpoint_id`** Asserts each entry's `fullUrl` contains the corresponding Endpoint resource ID. -**`test_response_bundle_entry_has_resource`** +**`test_response_bundle_entry_has_resource`** Asserts every entry has a `resource` field. -**`test_response_bundle_entry_has_search_mode_match`** +**`test_response_bundle_entry_has_search_mode_match`** Asserts `entry.search.mode` equals `"match"` for every entry. -**`test_organization_is_optional_for_endpoint`** +**`test_organization_is_optional_for_endpoint`** Makes a valid request with no `organization` parameter and asserts the status code is 200. This explicitly documents the difference in required parameters between `/Device` and `/Endpoint`. -**`test_query_with_party_key_returns_matching_endpoint`** +**`test_query_with_party_key_returns_matching_endpoint`** Passes both `_INTERACTION_ID_PARAM` and `_PARTY_KEY_PROVIDER` as identifiers and asserts at least one entry is returned. -**`test_x_correlation_id_echoed_back_when_provided`** +**`test_x_correlation_id_echoed_back_when_provided`** Verifies the correlation ID echo behaviour for Endpoint responses. -**`test_x_correlation_id_absent_when_not_provided`** +**`test_x_correlation_id_absent_when_not_provided`** Verifies the correlation ID is absent when not provided. -**`test_empty_bundle_returned_for_unknown_party_key`** +**`test_empty_bundle_returned_for_unknown_party_key`** Queries with a party key that is not in the stub and asserts a 200 response with `total: 0` and an empty `entry` array. @@ -308,45 +308,45 @@ This class inspects the internal structure of each `Endpoint` resource returned by the stub. All tests query the stub using `_PARTY_KEY_PROVIDER` as the identifier so that results are limited to the PROVIDER's seeded endpoint. -**`test_endpoint_resource_type_is_endpoint`** +**`test_endpoint_resource_type_is_endpoint`** Asserts every resource has `resourceType` equal to `"Endpoint"`. -**`test_endpoint_has_id`** +**`test_endpoint_has_id`** Asserts every resource has a non-empty `id` field. -**`test_endpoint_has_status_active`** +**`test_endpoint_has_status_active`** Asserts `Endpoint.status` equals `"active"`. The SDS spec requires active endpoints. -**`test_endpoint_has_connection_type`** +**`test_endpoint_has_connection_type`** Asserts the `connectionType` field is present. -**`test_endpoint_connection_type_has_system_and_code`** +**`test_endpoint_connection_type_has_system_and_code`** Navigates into `connectionType` and asserts both `system` and `code` fields are present. -**`test_endpoint_has_payload_type`** +**`test_endpoint_has_payload_type`** Asserts `Endpoint.payloadType` is a non-empty list. -**`test_endpoint_has_address`** +**`test_endpoint_has_address`** Asserts `Endpoint.address` is present and non-empty. This is the actual network URL of the endpoint. -**`test_endpoint_has_managing_organization`** +**`test_endpoint_has_managing_organization`** Asserts the `managingOrganization` field is present. -**`test_endpoint_managing_organization_uses_ods_system`** +**`test_endpoint_managing_organization_uses_ods_system`** Navigates to `Endpoint.managingOrganization.identifier.system` and asserts it equals the ODS code FHIR system URL. -**`test_endpoint_has_identifier_list`** +**`test_endpoint_has_identifier_list`** Asserts `Endpoint.identifier` is a non-empty list. -**`test_endpoint_identifier_contains_asid`** +**`test_endpoint_identifier_contains_asid`** Searches `Endpoint.identifier` for an entry with the NHS Spine ASID system URL and asserts it is present. -**`test_endpoint_identifier_contains_party_key`** +**`test_endpoint_identifier_contains_party_key`** Searches `Endpoint.identifier` for an entry with the MHS party key system URL and asserts it is present. @@ -356,29 +356,29 @@ and asserts it is present. This class tests the stub's validation for `get_endpoint_bundle` inputs. -**`test_missing_apikey_returns_400`** +**`test_missing_apikey_returns_400`** Omits `apikey` and asserts 400. -**`test_missing_identifier_returns_400`** +**`test_missing_identifier_returns_400`** Passes empty params (no `identifier`) and asserts 400. For `/Endpoint`, the `identifier` parameter is the only mandatory query parameter. -**`test_error_response_resource_type_is_operation_outcome`** +**`test_error_response_resource_type_is_operation_outcome`** Asserts the error body `resourceType` is `"OperationOutcome"`. -**`test_error_response_has_non_empty_issue_list`** +**`test_error_response_has_non_empty_issue_list`** Asserts `issue` is a non-empty list. -**`test_error_response_issue_has_severity`** +**`test_error_response_issue_has_severity`** Asserts `issue[0].severity` is present. -**`test_error_response_issue_has_code`** +**`test_error_response_issue_has_code`** Asserts `issue[0].code` is present. -**`test_error_response_issue_has_diagnostics`** +**`test_error_response_issue_has_diagnostics`** Asserts `issue[0].diagnostics` is a non-empty string. -**`test_missing_apikey_echoes_correlation_id`** +**`test_missing_apikey_echoes_correlation_id`** Passes `X-Correlation-Id` without `apikey` and asserts the correlation ID appears in the 400 response headers. @@ -390,38 +390,38 @@ This class tests the `get` method, which is a unified entry point that delegates to either `get_device_bundle` or `get_endpoint_bundle` based on the URL, and also records all call metadata for later inspection. -**`test_device_url_returns_device_bundle`** +**`test_device_url_returns_device_bundle`** Calls `get` with `_BASE_DEVICE_URL` (which contains `/Device`) and asserts the response is a Bundle of Device resources. This verifies the routing logic: when the URL contains the substring `/Device`, the call is delegated to `get_device_bundle`. -**`test_endpoint_url_returns_endpoint_bundle`** +**`test_endpoint_url_returns_endpoint_bundle`** Calls `get` with `_BASE_ENDPOINT_URL` (which contains `/Endpoint`) and asserts the response is a Bundle of Endpoint resources. This verifies that any URL containing `/Endpoint` is routed to `get_endpoint_bundle`. -**`test_get_records_last_url`** +**`test_get_records_last_url`** After calling `get`, asserts that `stub.get_url` equals the URL that was passed in. The stub stores this so test code can later verify what URL was actually called. -**`test_get_records_last_headers`** +**`test_get_records_last_headers`** After calling `get`, asserts that `stub.get_headers` equals the headers dictionary that was passed in. -**`test_get_records_last_params`** +**`test_get_records_last_params`** After calling `get`, asserts that `stub.get_params` equals the params dictionary that was passed in. -**`test_get_records_last_timeout`** +**`test_get_records_last_timeout`** Calls `get` with `timeout=30` and asserts that `stub.get_timeout` equals 30. -**`test_get_device_without_apikey_returns_400`** +**`test_get_device_without_apikey_returns_400`** Calls `get` with a device URL but no `apikey` header and asserts the response is 400. This verifies that validation is not bypassed by the routing layer. -**`test_get_endpoint_without_apikey_returns_400`** +**`test_get_endpoint_without_apikey_returns_400`** Calls `get` with an endpoint URL but no `apikey` header and asserts 400. --- @@ -432,7 +432,7 @@ This class tests the stub's public data-management API: `upsert_device`, `clear_devices`, `upsert_endpoint`, and `clear_endpoints`. These methods allow test code to customise the stub's data store. -**`test_upsert_device_is_returned_by_get_device_bundle`** +**`test_upsert_device_is_returned_by_get_device_bundle`** First clears all devices with `clear_devices`, then constructs a minimal Device dictionary with `resourceType: "Device"`, `id: "new-device-123"`, an empty identifier list, and an owner identifier using the ODS code system. It calls @@ -441,11 +441,11 @@ key, and this device dictionary. It then queries `get_device_bundle` for `"NEWORG"` and asserts that `Bundle.total` is 1 and that the single entry has `id` equal to `"new-device-123"`. -**`test_clear_devices_removes_all_devices`** +**`test_clear_devices_removes_all_devices`** Calls `clear_devices` and then queries for the PROVIDER organisation. Asserts that `Bundle.total` is 0, confirming that clearing removes all seeded data. -**`test_upsert_endpoint_is_returned_by_get_endpoint_bundle`** +**`test_upsert_endpoint_is_returned_by_get_endpoint_bundle`** First clears all endpoints. Constructs a complete Endpoint dictionary with `resourceType: "Endpoint"`, `id: "new-endpoint-456"`, `status: "active"`, a `connectionType` using the stub's `CONNECTION_SYSTEM` constant, a `payloadType` @@ -456,6 +456,6 @@ the standard interaction ID, the new party key, and this endpoint dictionary. Queries `get_endpoint_bundle` by the new party key and asserts `Bundle.total` is 1 and the entry ID is `"new-endpoint-456"`. -**`test_clear_endpoints_removes_all_endpoints`** +**`test_clear_endpoints_removes_all_endpoints`** Calls `clear_endpoints` and then queries using `_PARTY_KEY_PROVIDER`. Asserts `Bundle.total` is 0. From 5c25499faa6f1ae5eb1f719d6bcbfddf649ed882 Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:52:50 +0000 Subject: [PATCH 4/6] Comment change to kick pipelines --- .../contract/stub/test_sds_stub_contract.py | 61 ++++++------------- 1 file changed, 19 insertions(+), 42 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 b16aae58..f41a08a7 100644 --- a/gateway-api/tests/contract/stub/test_sds_stub_contract.py +++ b/gateway-api/tests/contract/stub/test_sds_stub_contract.py @@ -14,7 +14,6 @@ from __future__ import annotations import pytest -import requests from fhir.constants import FHIRSystem from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID from stubs.sds.stub import SdsFhirApiStub @@ -54,7 +53,7 @@ def stub() -> SdsFhirApiStub: # --------------------------------------------------------------------------- -# GET /Device – 200 successful retrieval +# GET /Device – 200 success # --------------------------------------------------------------------------- @@ -88,9 +87,7 @@ def test_response_body_resource_type_is_bundle(self, stub: SdsFhirApiStub) -> No body = response.json() assert body["resourceType"] == "Bundle" - def test_response_body_bundle_type_is_searchset( - self, stub: SdsFhirApiStub - ) -> None: + 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, @@ -196,9 +193,7 @@ def test_x_correlation_id_absent_when_not_provided( ) assert "X-Correlation-Id" not in response.headers - def test_empty_bundle_returned_for_unknown_org( - self, stub: SdsFhirApiStub - ) -> None: + 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, @@ -289,9 +284,7 @@ def test_device_identifier_contains_asid(self, stub: SdsFhirApiStub) -> None: ] assert len(asid_entries) >= 1 - def test_device_identifier_contains_party_key( - self, stub: SdsFhirApiStub - ) -> None: + 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, @@ -428,9 +421,7 @@ def test_error_response_issue_has_code(self, stub: SdsFhirApiStub) -> None: body = response.json() assert "code" in body["issue"][0] - def test_error_response_issue_has_diagnostics( - self, stub: SdsFhirApiStub - ) -> None: + 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, @@ -441,9 +432,7 @@ def test_error_response_issue_has_diagnostics( assert "diagnostics" in body["issue"][0] assert body["issue"][0]["diagnostics"] # non-empty - def test_missing_apikey_echoes_correlation_id( - 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_device_bundle( url=_BASE_DEVICE_URL, @@ -489,9 +478,7 @@ def test_response_body_resource_type_is_bundle(self, stub: SdsFhirApiStub) -> No body = response.json() assert body["resourceType"] == "Bundle" - def test_response_body_bundle_type_is_searchset( - self, stub: SdsFhirApiStub - ) -> None: + 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, @@ -575,9 +562,7 @@ def test_response_bundle_entry_has_search_mode_match( for entry in body["entry"]: assert entry["search"]["mode"] == "match" - def test_organization_is_optional_for_endpoint( - self, stub: SdsFhirApiStub - ) -> None: + def test_organization_is_optional_for_endpoint(self, stub: SdsFhirApiStub) -> None: """Unlike /Device, the ``organization`` parameter is optional for /Endpoint.""" response = stub.get_endpoint_bundle( url=_BASE_ENDPOINT_URL, @@ -746,7 +731,8 @@ def test_endpoint_has_managing_organization(self, stub: SdsFhirApiStub) -> None: def test_endpoint_managing_organization_uses_ods_system( self, stub: SdsFhirApiStub ) -> None: - """Endpoint.managingOrganization.identifier.system must be the ODS code system.""" + """Endpoint.managingOrganization.identifier.system must be the ODS code + system.""" response = stub.get_endpoint_bundle( url=_BASE_ENDPOINT_URL, headers={"apikey": "test-key"}, @@ -784,9 +770,7 @@ def test_endpoint_identifier_contains_asid(self, stub: SdsFhirApiStub) -> None: ] assert len(asid_entries) >= 1 - def test_endpoint_identifier_contains_party_key( - self, stub: SdsFhirApiStub - ) -> None: + 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, @@ -875,9 +859,7 @@ def test_error_response_issue_has_code(self, stub: SdsFhirApiStub) -> None: body = response.json() assert "code" in body["issue"][0] - def test_error_response_issue_has_diagnostics( - self, stub: SdsFhirApiStub - ) -> None: + 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, @@ -888,9 +870,7 @@ def test_error_response_issue_has_diagnostics( assert "diagnostics" in body["issue"][0] assert body["issue"][0]["diagnostics"] # non-empty - def test_missing_apikey_echoes_correlation_id( - 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, @@ -1010,13 +990,11 @@ def test_upsert_device_is_returned_by_get_device_bundle( ) -> None: """A device added via upsert_device must be returned in subsequent queries.""" stub.clear_devices() - new_device: dict = { + new_device: dict[str, object] = { "resourceType": "Device", "id": "new-device-123", "identifier": [], - "owner": { - "identifier": {"system": FHIRSystem.ODS_CODE, "value": "NEWORG"} - }, + "owner": {"identifier": {"system": FHIRSystem.ODS_CODE, "value": "NEWORG"}}, } stub.upsert_device( organization_ods="NEWORG", @@ -1052,10 +1030,11 @@ def test_clear_devices_removes_all_devices(self, stub: SdsFhirApiStub) -> None: 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.""" + """An endpoint added via upsert_endpoint must be returned in subsequent + queries.""" stub.clear_endpoints() new_party_key = "NEWORG-0000999" - new_endpoint: dict = { + new_endpoint: dict[str, object] = { "resourceType": "Endpoint", "id": "new-endpoint-456", "status": "active", @@ -1091,9 +1070,7 @@ def test_upsert_endpoint_is_returned_by_get_endpoint_bundle( assert body["total"] == 1 assert body["entry"][0]["resource"]["id"] == "new-endpoint-456" - def test_clear_endpoints_removes_all_endpoints( - self, stub: SdsFhirApiStub - ) -> None: + 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() From 231758f49851a3e1bc94339699095679f4f49e54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:18:40 +0000 Subject: [PATCH 5/6] Remove sds_stub_contract_tests.md Agent-Logs-Url: https://github.com/NHSDigital/clinical-data-gateway-api/sessions/0a918165-a604-41c0-bcb3-18fd0ab10008 Co-authored-by: Vox-Ben <59649826+Vox-Ben@users.noreply.github.com> --- .../contract/stub/sds_stub_contract_tests.md | 461 ------------------ 1 file changed, 461 deletions(-) delete mode 100644 gateway-api/tests/contract/stub/sds_stub_contract_tests.md diff --git a/gateway-api/tests/contract/stub/sds_stub_contract_tests.md b/gateway-api/tests/contract/stub/sds_stub_contract_tests.md deleted file mode 100644 index 9a16c08e..00000000 --- a/gateway-api/tests/contract/stub/sds_stub_contract_tests.md +++ /dev/null @@ -1,461 +0,0 @@ -# SDS Stub Contract Test Documentation - -## Overview - -The file `test_sds_stub_contract.py` contains contract tests for the -`SdsFhirApiStub` class. This stub is an in-memory implementation of the Spine -Directory Service (SDS) FHIR R4 API used during local and integration testing. -Its purpose is to stand in for the real SDS service without requiring a live -network connection. - -The contract tests verify that the stub faithfully reproduces the HTTP behaviour -described in the SDS OpenAPI specification at -. The tests call the -stub's methods directly (no HTTP server is started) and inspect the returned -`requests.Response` objects. - ---- - -## Module-Level Constants - -At the top of the module, several string constants are defined for use across -all test classes. - -### FHIR-Formatted Query Parameter Constants - -The SDS API uses FHIR-style parameter encoding where both a system URL and a -value are joined with a pipe character (`|`). Four constants capture the most -frequently used combinations: - -- `_ORG_PROVIDER` — The `organization` query parameter value for the seeded - "PROVIDER" organisation. Constructed by combining the ODS code FHIR system - URL (`https://fhir.nhs.uk/Id/ods-organization-code`) with the literal string - `PROVIDER`. - -- `_ORG_CONSUMER` — The same construction for the seeded "CONSUMER" - organisation. - -- `_ORG_UNKNOWN` — A syntactically valid organisation parameter whose ODS code - (`UNKNOWN_ORG_XYZ`) is guaranteed to have no matching records in the stub. - -- `_INTERACTION_ID_PARAM` — The `identifier` query parameter value representing - the GP Connect "Access Record Structured" service interaction. Constructed by - combining the NHS service interaction ID system URL - (`https://fhir.nhs.uk/Id/nhsServiceInteractionId`) with the canonical - interaction identifier - `urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1`. - -- `_PARTY_KEY_PROVIDER` — The `identifier` query parameter value for the - PROVIDER organisation's MHS party key. Constructed by combining the party - key system URL (`https://fhir.nhs.uk/Id/nhsMhsPartyKey`) with - `PROVIDER-0000806`. - -- `_PARTY_KEY_CONSUMER` — The same construction for the CONSUMER organisation - using `CONSUMER-0000807`. - -- `_VALID_CORRELATION_ID` — A fixed string used as a sample `X-Correlation-Id` - header value in tests that verify header echo behaviour. - -### URL Constants - -- `_BASE_DEVICE_URL` — The canonical sandbox URL for the SDS `/Device` - endpoint. - -- `_BASE_ENDPOINT_URL` — The canonical sandbox URL for the SDS `/Endpoint` - endpoint. - ---- - -## Fixtures - -### `stub` - -Creates and returns a new `SdsFhirApiStub` instance. The stub constructor -seeds itself with a set of deterministic Device and Endpoint records, so every -test starts with a known, consistent data state. Because the instance is created -fresh for each test, mutations in one test (such as clearing or adding records) -do not affect other tests. - ---- - -## Test Classes - -### `TestGetDeviceBundleSuccess` - -This class tests the normal ("happy path") behaviour of the `get_device_bundle` -method when all required inputs are present and the queried organisation exists -in the stub's data store. - -Every call in this class supplies the `apikey` header and passes `_ORG_PROVIDER` -as the `organization` parameter together with `_INTERACTION_ID_PARAM` as the -`identifier` parameter. - -**`test_status_code_is_200`** -Calls `get_device_bundle` with valid inputs and asserts that the HTTP status -code of the response is 200. - -**`test_content_type_is_fhir_json`** -Asserts that the `Content-Type` response header contains the string -`application/fhir+json`, which is the media type mandated by the FHIR -specification for all FHIR API responses. - -**`test_response_body_resource_type_is_bundle`** -Parses the response body as JSON and asserts that the `resourceType` field -equals `"Bundle"`. The SDS spec requires that search results are wrapped in a -FHIR Bundle. - -**`test_response_body_bundle_type_is_searchset`** -Asserts that `Bundle.type` equals `"searchset"`. This is the mandatory Bundle -type for FHIR search responses. - -**`test_response_bundle_total_matches_entry_count`** -Asserts that `Bundle.total` (an integer in the response body) equals the number -of items in the `Bundle.entry` array. The FHIR spec requires these two values -to be consistent. - -**`test_response_bundle_has_at_least_one_entry_for_known_org`** -Asserts that `Bundle.total` is at least 1. Because the stub is pre-seeded with -a Device for the PROVIDER organisation, a search for that organisation must -return a non-empty result. - -**`test_response_bundle_entry_has_full_url`** -Iterates over every entry in `Bundle.entry` and asserts that each entry has a -non-empty `fullUrl` field. The FHIR spec requires `fullUrl` to be present in -every Bundle entry. - -**`test_response_bundle_entry_full_url_contains_device_id`** -For every entry, reads `entry.resource.id` and asserts that this value appears -as a substring of `entry.fullUrl`. This verifies that the URL is constructed -from the resource's own identifier. - -**`test_response_bundle_entry_has_resource`** -Asserts that every Bundle entry has a `resource` field. This is the FHIR -container that holds the actual Device or Endpoint object. - -**`test_response_bundle_entry_has_search_mode_match`** -Asserts that `entry.search.mode` equals `"match"` for every entry. This is the -value required by FHIR for entries that directly satisfy the search criteria. - -**`test_x_correlation_id_echoed_back_when_provided`** -Passes `X-Correlation-Id: test-correlation-id-12345` in the request headers and -asserts that the same value appears under `X-Correlation-Id` in the response -headers. The SDS spec states that this header must be mirrored back. - -**`test_x_correlation_id_absent_when_not_provided`** -Does not pass `X-Correlation-Id` and asserts that the header is absent from the -response. This prevents the stub from inventing correlation IDs. - -**`test_empty_bundle_returned_for_unknown_org`** -Passes `_ORG_UNKNOWN` as the organisation parameter and asserts that the -response is still 200 (not 404), that `resourceType` is `"Bundle"`, that -`total` is 0, and that `entry` is an empty list. The SDS spec returns an empty -Bundle rather than a 404 when no records match. - -**`test_query_with_party_key_returns_matching_device`** -Passes both `_INTERACTION_ID_PARAM` and `_PARTY_KEY_PROVIDER` as a list in the -`identifier` parameter and asserts that `Bundle.total` is at least 1. This -verifies that the stub correctly handles multi-value identifier queries. - ---- - -### `TestGetDeviceResourceStructure` - -This class verifies the internal structure of each `Device` resource returned -inside the Bundle. Every test in this class makes a valid query for the PROVIDER -organisation and then inspects each `entry.resource` object. - -**`test_device_resource_type_is_device`** -Asserts that every resource has `resourceType` equal to `"Device"`. - -**`test_device_has_id`** -Asserts that every resource has a non-empty `id` field. - -**`test_device_has_identifier_list`** -Asserts that `Device.identifier` is a list containing at least one element. - -**`test_device_identifier_contains_asid`** -Searches `Device.identifier` for an entry whose `system` equals the NHS Spine -ASID system URL (`https://fhir.nhs.uk/Id/nhsSpineASID`) and asserts that at -least one such entry exists. - -**`test_device_identifier_contains_party_key`** -Searches `Device.identifier` for an entry whose `system` equals the MHS party -key system URL (`https://fhir.nhs.uk/Id/nhsMhsPartyKey`) and asserts that at -least one such entry exists. - -**`test_device_has_owner`** -Asserts that every Device resource has an `owner` field. - -**`test_device_owner_identifier_uses_ods_system`** -Navigates to `Device.owner.identifier.system` and asserts it equals the ODS -code FHIR system URL (`https://fhir.nhs.uk/Id/ods-organization-code`). This -links the device to its owning organisation. - ---- - -### `TestGetDeviceBundleValidationErrors` - -This class tests the stub's input validation for `get_device_bundle`. Each test -deliberately omits or corrupts a required input and asserts that the stub -responds with HTTP 400 and an `OperationOutcome` body. - -**`test_missing_apikey_returns_400`** -Passes an empty headers dictionary (no `apikey`). The SDS API requires this -header; its absence must yield 400. - -**`test_missing_organization_returns_400`** -Omits the `organization` query parameter. This parameter is mandatory for -`/Device` queries. - -**`test_missing_identifier_returns_400`** -Omits the `identifier` query parameter entirely. - -**`test_identifier_without_interaction_id_returns_400`** -Passes only `_PARTY_KEY_PROVIDER` as the `identifier`, omitting the -`nhsServiceInteractionId` component. The stub requires the interaction ID to be -present in the identifier list for Device queries. - -**`test_error_response_resource_type_is_operation_outcome`** -Sends a request with a missing `apikey` header and asserts that the response -body has `resourceType` equal to `"OperationOutcome"`. All FHIR error responses -must use this resource type. - -**`test_error_response_has_non_empty_issue_list`** -Asserts that the `OperationOutcome.issue` field is a list containing at least -one element. - -**`test_error_response_issue_has_severity`** -Asserts that `issue[0]` contains a `severity` field. FHIR requires every issue -to carry a severity. - -**`test_error_response_issue_has_code`** -Asserts that `issue[0]` contains a `code` field. FHIR requires every issue to -carry an issue type code. - -**`test_error_response_issue_has_diagnostics`** -Asserts that `issue[0]` contains a non-empty `diagnostics` field. This free-text -field carries the human-readable error description. - -**`test_missing_apikey_echoes_correlation_id`** -Passes `X-Correlation-Id` without `apikey` and asserts that, even though the -response is 400, the correlation ID is still echoed back in the response -headers. - ---- - -### `TestGetEndpointBundleSuccess` - -This class mirrors `TestGetDeviceBundleSuccess` but for the `get_endpoint_bundle` -method. The key difference is that `organization` is optional for Endpoint -queries; the minimum required parameter is `identifier`. - -**`test_status_code_is_200`** -Calls `get_endpoint_bundle` with the `apikey` header and `_INTERACTION_ID_PARAM` -as the identifier and asserts that the status code is 200. - -**`test_content_type_is_fhir_json`** -Verifies `Content-Type: application/fhir+json` in the response. - -**`test_response_body_resource_type_is_bundle`** -Verifies `Bundle` as the top-level resource type. - -**`test_response_body_bundle_type_is_searchset`** -Verifies `Bundle.type` is `"searchset"`. - -**`test_response_bundle_total_matches_entry_count`** -Verifies that `Bundle.total` equals the length of `Bundle.entry`. - -**`test_response_bundle_has_entries_for_known_interaction_id`** -Asserts that querying by the known seeded interaction ID returns at least one -Endpoint entry. - -**`test_response_bundle_entry_has_full_url`** -Asserts every entry has a non-empty `fullUrl`. - -**`test_response_bundle_entry_full_url_contains_endpoint_id`** -Asserts each entry's `fullUrl` contains the corresponding Endpoint resource ID. - -**`test_response_bundle_entry_has_resource`** -Asserts every entry has a `resource` field. - -**`test_response_bundle_entry_has_search_mode_match`** -Asserts `entry.search.mode` equals `"match"` for every entry. - -**`test_organization_is_optional_for_endpoint`** -Makes a valid request with no `organization` parameter and asserts the status -code is 200. This explicitly documents the difference in required parameters -between `/Device` and `/Endpoint`. - -**`test_query_with_party_key_returns_matching_endpoint`** -Passes both `_INTERACTION_ID_PARAM` and `_PARTY_KEY_PROVIDER` as identifiers -and asserts at least one entry is returned. - -**`test_x_correlation_id_echoed_back_when_provided`** -Verifies the correlation ID echo behaviour for Endpoint responses. - -**`test_x_correlation_id_absent_when_not_provided`** -Verifies the correlation ID is absent when not provided. - -**`test_empty_bundle_returned_for_unknown_party_key`** -Queries with a party key that is not in the stub and asserts a 200 response -with `total: 0` and an empty `entry` array. - ---- - -### `TestGetEndpointResourceStructure` - -This class inspects the internal structure of each `Endpoint` resource returned -by the stub. All tests query the stub using `_PARTY_KEY_PROVIDER` as the -identifier so that results are limited to the PROVIDER's seeded endpoint. - -**`test_endpoint_resource_type_is_endpoint`** -Asserts every resource has `resourceType` equal to `"Endpoint"`. - -**`test_endpoint_has_id`** -Asserts every resource has a non-empty `id` field. - -**`test_endpoint_has_status_active`** -Asserts `Endpoint.status` equals `"active"`. The SDS spec requires active -endpoints. - -**`test_endpoint_has_connection_type`** -Asserts the `connectionType` field is present. - -**`test_endpoint_connection_type_has_system_and_code`** -Navigates into `connectionType` and asserts both `system` and `code` fields are -present. - -**`test_endpoint_has_payload_type`** -Asserts `Endpoint.payloadType` is a non-empty list. - -**`test_endpoint_has_address`** -Asserts `Endpoint.address` is present and non-empty. This is the actual network -URL of the endpoint. - -**`test_endpoint_has_managing_organization`** -Asserts the `managingOrganization` field is present. - -**`test_endpoint_managing_organization_uses_ods_system`** -Navigates to `Endpoint.managingOrganization.identifier.system` and asserts it -equals the ODS code FHIR system URL. - -**`test_endpoint_has_identifier_list`** -Asserts `Endpoint.identifier` is a non-empty list. - -**`test_endpoint_identifier_contains_asid`** -Searches `Endpoint.identifier` for an entry with the NHS Spine ASID system URL -and asserts it is present. - -**`test_endpoint_identifier_contains_party_key`** -Searches `Endpoint.identifier` for an entry with the MHS party key system URL -and asserts it is present. - ---- - -### `TestGetEndpointBundleValidationErrors` - -This class tests the stub's validation for `get_endpoint_bundle` inputs. - -**`test_missing_apikey_returns_400`** -Omits `apikey` and asserts 400. - -**`test_missing_identifier_returns_400`** -Passes empty params (no `identifier`) and asserts 400. For `/Endpoint`, the -`identifier` parameter is the only mandatory query parameter. - -**`test_error_response_resource_type_is_operation_outcome`** -Asserts the error body `resourceType` is `"OperationOutcome"`. - -**`test_error_response_has_non_empty_issue_list`** -Asserts `issue` is a non-empty list. - -**`test_error_response_issue_has_severity`** -Asserts `issue[0].severity` is present. - -**`test_error_response_issue_has_code`** -Asserts `issue[0].code` is present. - -**`test_error_response_issue_has_diagnostics`** -Asserts `issue[0].diagnostics` is a non-empty string. - -**`test_missing_apikey_echoes_correlation_id`** -Passes `X-Correlation-Id` without `apikey` and asserts the correlation ID -appears in the 400 response headers. - ---- - -### `TestGetConvenienceMethod` - -This class tests the `get` method, which is a unified entry point that -delegates to either `get_device_bundle` or `get_endpoint_bundle` based on the -URL, and also records all call metadata for later inspection. - -**`test_device_url_returns_device_bundle`** -Calls `get` with `_BASE_DEVICE_URL` (which contains `/Device`) and asserts the -response is a Bundle of Device resources. This verifies the routing logic: when -the URL contains the substring `/Device`, the call is delegated to -`get_device_bundle`. - -**`test_endpoint_url_returns_endpoint_bundle`** -Calls `get` with `_BASE_ENDPOINT_URL` (which contains `/Endpoint`) and asserts -the response is a Bundle of Endpoint resources. This verifies that any URL -containing `/Endpoint` is routed to `get_endpoint_bundle`. - -**`test_get_records_last_url`** -After calling `get`, asserts that `stub.get_url` equals the URL that was passed -in. The stub stores this so test code can later verify what URL was actually -called. - -**`test_get_records_last_headers`** -After calling `get`, asserts that `stub.get_headers` equals the headers -dictionary that was passed in. - -**`test_get_records_last_params`** -After calling `get`, asserts that `stub.get_params` equals the params -dictionary that was passed in. - -**`test_get_records_last_timeout`** -Calls `get` with `timeout=30` and asserts that `stub.get_timeout` equals 30. - -**`test_get_device_without_apikey_returns_400`** -Calls `get` with a device URL but no `apikey` header and asserts the response -is 400. This verifies that validation is not bypassed by the routing layer. - -**`test_get_endpoint_without_apikey_returns_400`** -Calls `get` with an endpoint URL but no `apikey` header and asserts 400. - ---- - -### `TestUpsertOperations` - -This class tests the stub's public data-management API: `upsert_device`, -`clear_devices`, `upsert_endpoint`, and `clear_endpoints`. These methods allow -test code to customise the stub's data store. - -**`test_upsert_device_is_returned_by_get_device_bundle`** -First clears all devices with `clear_devices`, then constructs a minimal Device -dictionary with `resourceType: "Device"`, `id: "new-device-123"`, an empty -identifier list, and an owner identifier using the ODS code system. It calls -`upsert_device` with ODS code `"NEWORG"`, the standard interaction ID, no party -key, and this device dictionary. It then queries `get_device_bundle` for -`"NEWORG"` and asserts that `Bundle.total` is 1 and that the single entry has -`id` equal to `"new-device-123"`. - -**`test_clear_devices_removes_all_devices`** -Calls `clear_devices` and then queries for the PROVIDER organisation. Asserts -that `Bundle.total` is 0, confirming that clearing removes all seeded data. - -**`test_upsert_endpoint_is_returned_by_get_endpoint_bundle`** -First clears all endpoints. Constructs a complete Endpoint dictionary with -`resourceType: "Endpoint"`, `id: "new-endpoint-456"`, `status: "active"`, a -`connectionType` using the stub's `CONNECTION_SYSTEM` constant, a `payloadType` -using the stub's `CODING_SYSTEM` constant, an `address` URL, a -`managingOrganization` using the ODS code system, and an `identifier` list -containing the new party key. Calls `upsert_endpoint` with ODS code `"NEWORG"`, -the standard interaction ID, the new party key, and this endpoint dictionary. -Queries `get_endpoint_bundle` by the new party key and asserts `Bundle.total` -is 1 and the entry ID is `"new-endpoint-456"`. - -**`test_clear_endpoints_removes_all_endpoints`** -Calls `clear_endpoints` and then queries using `_PARTY_KEY_PROVIDER`. Asserts -`Bundle.total` is 0. From 6cfac9d5300585045ed1af3473bfa0f120e1a97d Mon Sep 17 00:00:00 2001 From: Ben Taylor <59649826+Vox-Ben@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:20:25 +0000 Subject: [PATCH 6/6] Comment change to kick pipelines --- gateway-api/tests/contract/stub/test_sds_stub_contract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f41a08a7..ad04e7c6 100644 --- a/gateway-api/tests/contract/stub/test_sds_stub_contract.py +++ b/gateway-api/tests/contract/stub/test_sds_stub_contract.py @@ -913,7 +913,7 @@ def test_endpoint_url_returns_endpoint_bundle(self, stub: SdsFhirApiStub) -> Non assert response.status_code == 200 body = response.json() assert body["resourceType"] == "Bundle" - # Verify Endpoint resources were returned + # Verify Endpoint resources returned for entry in body["entry"]: assert entry["resource"]["resourceType"] == "Endpoint"