Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9a59f2e
[CDAPI-148]: Added initial PDM mock client for integration tests
nhsd-jack-wainwright Apr 23, 2026
aaf76f4
[CDAPI-148]: Added initial MNS mock client for use by the integration…
nhsd-jack-wainwright Apr 23, 2026
0095c8f
[CDAPI-148]: Added new environment variables for PDM and MNS mock cli…
nhsd-jack-wainwright Apr 23, 2026
f1bc70d
[CDAPI-148]: Moved new mock clients into integration specific conftes…
nhsd-jack-wainwright Apr 23, 2026
75842fc
[CDAPI-148]: Removed exports from `run-test-action` action
nhsd-jack-wainwright Apr 23, 2026
9025158
[CDAPI-148]: Added actions for fetching secrets within run-test-suite…
nhsd-jack-wainwright Apr 24, 2026
384d927
[CDAPI-148]: Removed unnecessary secret retrieval from run-test-suite…
nhsd-jack-wainwright Apr 24, 2026
c50a7a6
[CDAPI-148]: Added in replacements for dashes within secret names
nhsd-jack-wainwright Apr 27, 2026
51d991d
[CDAPI-148]: Added environment variables to make test command
nhsd-jack-wainwright Apr 27, 2026
60fa126
[CDAPI-148]: Fixed fixture name within integration test
nhsd-jack-wainwright Apr 27, 2026
4e306e2
[CDAPI-148]: Swapped around mTLS key and cert parameter
nhsd-jack-wainwright Apr 27, 2026
475ddf0
[CDAPI-148]: Updated PDM mock to store received document instead of c…
nhsd-jack-wainwright Apr 27, 2026
78be376
[CDAPI-148]: Dumped retrieved PDM request as a JSON string for compar…
nhsd-jack-wainwright Apr 27, 2026
ad9c69e
[CDAPI-148]: Swapped assertion within test_endpoints.py to compare di…
nhsd-jack-wainwright Apr 27, 2026
4839b0e
[CDAPI-148]: Excluded None values from sent request comparison
nhsd-jack-wainwright Apr 27, 2026
27b84ad
[CDAPI-148]: Using unique subject identifier within integration test …
nhsd-jack-wainwright Apr 27, 2026
18143e1
[CDAPI-148]: Added PDM bundle URL to test suite action and integratio…
nhsd-jack-wainwright Apr 27, 2026
3089e14
[CDAPI-148]: Fixed supplied Bundle URL
nhsd-jack-wainwright Apr 27, 2026
6f5eca2
[CDAPI-148]: Updated API gateway mock to include correlation ID and s…
nhsd-jack-wainwright Apr 30, 2026
1661795
[CDAPI-148]: Updated conftest configuration to allow for no mtls cert
nhsd-jack-wainwright Apr 30, 2026
81d3b52
[CDAPI-148]: Minor fixes to openapi.yaml examples and schema
nhsd-jack-wainwright May 1, 2026
716338f
[CDAPI-148]: Added configuration to fetch mTLS secrets from AWS Secre…
nhsd-jack-wainwright May 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion .github/actions/run-test-suite/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@ inputs:
description: "Environment: local or remote"
required: false
default: "remote"
apim_client_key_secret_name:
description: "Secret name of the APIM client certificate key (if needed)"
required: false
default: ""
apim_client_cert_secret_name:
description: "Secret name of the APIM client certificate (if needed)"
required: false
default: ""
pdm_mock_document_url:
description: "PDM mock document URL (if needed)"
required: false
default: ""
mns_mock_events_url:
description: "MNS mock events URL (if needed)"
required: false
default: ""
pdm_bundle_url:
description: "PDM bundle URL (if needed)"
required: false
default: ""

runs:
using: composite
Expand All @@ -27,11 +47,31 @@ runs:
APIGEE_ACCESS_TOKEN: ${{ inputs.apigee-access-token }}
ENV: ${{ inputs.env }}
TEST_TYPE: ${{ inputs.test-type }}
CLIENT_KEY_NAME: ${{ inputs.apim_client_key_secret_name }}
CLIENT_CERT_NAME: ${{ inputs.apim_client_cert_secret_name }}
PDM_MOCK_DOCUMENT_URL: ${{ inputs.pdm_mock_document_url }}
MNS_MOCK_EVENTS_URL: ${{ inputs.mns_mock_events_url }}
PDM_BUNDLE_URL: ${{ inputs.pdm_bundle_url }}
run: |
if [[ -n "${APIGEE_ACCESS_TOKEN}" ]]; then
echo "::add-mask::${APIGEE_ACCESS_TOKEN}"
fi
make test-${TEST_TYPE}

if [[ -n "${CLIENT_KEY_NAME}" ]]; then
SECRET_NAME=${CLIENT_KEY_NAME//\//_}
SECRET_NAME=${SECRET_NAME//-/_}
echo "Using APIM client certificate key from name: ${SECRET_NAME}"
CLIENT_KEY=${!SECRET_NAME}
fi

if [[ -n "${CLIENT_CERT_NAME}" ]]; then
SECRET_NAME=${CLIENT_CERT_NAME//\//_}
SECRET_NAME=${SECRET_NAME//-/_}
echo "Using APIM client certificate from name: ${SECRET_NAME}"
CLIENT_CERT=${!SECRET_NAME}
fi

CLIENT_KEY=${CLIENT_KEY} CLIENT_CERT=${CLIENT_CERT} make test-${TEST_TYPE}

- name: "Upload ${{ inputs.test-type }} test results"
if: always()
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/preview-env.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -685,9 +685,17 @@ jobs:
- name: "Run integration tests"
if: github.event.action != 'closed'
uses: ./.github/actions/run-test-suite
env:
API_MTLS_CERT: ${{ secrets.API_MTLS_CERT }}
API_MTLS_KEY: ${{ secrets.API_MTLS_KEY }}
with:
test-type: integration
apigee-access-token: ${{ steps.apigee-token.outputs.apigee-access-token }}
apim_client_cert_secret_name: "${{ env.API_MTLS_CERT || '/cds/pathology/dev/mtls/client1-key-public' }}"
apim_client_key_secret_name: "${{ env.API_MTLS_KEY || '/cds/pathology/dev/mtls/client1-key-secret' }}"
pdm_mock_document_url: ${{ steps.names.outputs.mock_preview_url }}/pdm/mock/Bundle
mns_mock_events_url: ${{ steps.names.outputs.mock_preview_url }}/mns/mock/event
pdm_bundle_url: ${{ steps.names.outputs.mock_preview_url }}/pdm/FHIR/R4/Bundle

- name: "Run acceptance tests"
if: github.event.action != 'closed'
Expand Down
9 changes: 6 additions & 3 deletions infrastructure/images/api-gateway-mock/resources/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
def forward_request(path_params):
x_correlation_id = request.headers.get("X-Correlation-ID", "")
forwarded_headers = {k.lower(): v for k, v in request.headers.items()}
forwarded_headers["nhsd-correlation-id"] = x_correlation_id
forwarded_headers["nhsd-correlation-id"] = f".{x_correlation_id}.test"

response = requests.post(
f"{TARGET_URL}/2015-03-31/functions/function/invocations",
Expand All @@ -61,8 +61,9 @@ def forward_request(path_params):
},
"httpMethod": request.method,
"rawPath": f"/{path_params}",
"rawQueryString": "",
"rawQueryString": request.query_string.decode("utf-8"),
"pathParameters": {"proxy": path_params},
"queryStringParameters": request.args.to_dict(),
},
headers={"Content-Type": "application/json"},
timeout=120,
Expand All @@ -75,11 +76,13 @@ def forward_request(path_params):
app.logger.info("response: %s", response.text)
response_data = response.json()

headers = {"x-correlation-id": x_correlation_id} | response_data.get("headers", {})

output = (
(
response_data["body"],
response_data["statusCode"],
response_data["headers"],
headers,
)
if "body" in response_data
else (response_data, 500, {"Content-Type": "text/plain"})
Expand Down
2 changes: 1 addition & 1 deletion mocks/src/pdm_mock/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def handle_post_request(payload: dict[str, Any]) -> PDMResponse:
item: DocumentItem = {
"sessionId": document_id,
"expiresAt": int(time()) + 600,
"document": json.dumps(created_document),
"document": json.dumps(payload),
"type": "pdm_document",
}

Expand Down
47 changes: 25 additions & 22 deletions pathology-api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,10 @@ paths:
identifier:
system: "https://fhir.nhs.uk/Id/nhs-number"
value: "9999999999"
extension:
- url: "http://hl7.eu/fhir/StructureDefinition/composition-basedOn-order-or-requisition"
valueReference:
reference: "ServiceRequest"
extension:
- url: "http://hl7.eu/fhir/StructureDefinition/composition-basedOn-order-or-requisition"
valueReference:
reference: "ServiceRequest"
- fullUrl: "ServiceRequest"
resource:
resourceType: ServiceRequest
Expand All @@ -252,8 +252,8 @@ paths:
resource:
resourceType: Organization
identifier:
system: "https://fhir.nhs.uk/Id/ods-organization-code"
value: "A12345"
- system: "https://fhir.nhs.uk/Id/ods-organization-code"
value: "A12345"
- fullUrl: "Patient"
resource:
resourceType: Patient
Expand Down Expand Up @@ -367,8 +367,8 @@ paths:
resource:
resourceType: Organization
identifier:
system: "https://fhir.nhs.uk/Id/ods-organization-code"
value: "A12345"
- system: "https://fhir.nhs.uk/Id/ods-organization-code"
value: "A12345"
- fullUrl: "Patient"
resource:
resourceType: Patient
Expand Down Expand Up @@ -567,20 +567,23 @@ components:
- Organization
example: Organization
identifier:
type: object
required:
- system
- value
properties:
system:
type: string
enum:
- "https://fhir.nhs.uk/Id/ods-organization-code"
example: "https://fhir.nhs.uk/Id/ods-organization-code"
value:
type: string
example: "A12345"
description: ODS code of the requesting organisation
type: array
minItems: 1
items:
type: object
required:
- system
- value
properties:
system:
type: string
enum:
- "https://fhir.nhs.uk/Id/ods-organization-code"
example: "https://fhir.nhs.uk/Id/ods-organization-code"
value:
type: string
example: "A12345"
description: ODS code of the requesting organisation
OperationOutcome:
type: object
required:
Expand Down
37 changes: 24 additions & 13 deletions pathology-api/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Pytest configuration and shared fixtures for pathology API tests."""

import os
from collections.abc import Callable
from datetime import timedelta
from typing import Any, Literal, Protocol, cast
from typing import Any, Literal, Protocol

import pytest
import requests
Expand Down Expand Up @@ -58,7 +59,7 @@ def __init__(
self,
lambda_url: str,
headers: dict[str, str] | None = None,
timeout: timedelta = timedelta(seconds=1),
timeout: timedelta = timedelta(seconds=10),
):
self._lambda_url = lambda_url
self._default_headers = {"Content-Type": "application/fhir+json"} | (
Expand Down Expand Up @@ -209,15 +210,32 @@ def _send(


@pytest.fixture(scope="module")
def base_url() -> str:
def fetch_env_variable[T]() -> Callable[[str, type[T]], T]:
def _fetch_env_variable(name: str, required_type: type[T]) -> T:
value = os.getenv(name)
if not value:
raise ValueError(f"{name} environment variable is not set.")

if not isinstance(value, required_type):
raise ValueError(
f"{name} environment variable is not required type {required_type}"
)

return value

return _fetch_env_variable


@pytest.fixture(scope="module")
def base_url(fetch_env_variable: Callable[[str, type[str]], str]) -> str:
"""Retrieves the base URL of the currently deployed application."""
return _fetch_env_variable("BASE_URL", str)
return fetch_env_variable("BASE_URL", str)


@pytest.fixture
def hostname() -> str:
def hostname(fetch_env_variable: Callable[[str, type[str]], str]) -> str:
"""Retrieves the hostname of the currently deployed application."""
return _fetch_env_variable("HOST", str)
return fetch_env_variable("HOST", str)


@pytest.fixture
Expand Down Expand Up @@ -280,13 +298,6 @@ def _create_remote_client(request: pytest.FixtureRequest) -> RemoteClient:
)


def _fetch_env_variable[T](name: str, _: type[T]) -> T:
value = os.getenv(name)
if not value:
raise ValueError(f"{name} environment variable is not set.")
return cast("T", value)


def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption(
"--env",
Expand Down
67 changes: 67 additions & 0 deletions pathology-api/tests/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import os
import tempfile
from collections.abc import Callable, Generator
from datetime import timedelta

import pytest

from tests.mock_client import CertificateDetails, MNSMockClient, PDMMockClient


@pytest.fixture(scope="module")
def client_cert() -> Generator[CertificateDetails | None, None, None]:
client_cert = os.getenv("CLIENT_CERT")
client_key = os.getenv("CLIENT_KEY")

if client_cert and client_key:
with (
tempfile.NamedTemporaryFile(delete=True) as cert_file,
tempfile.NamedTemporaryFile(delete=True) as key_file,
):
cert_file.write(client_cert.encode())
cert_file.flush()
key_file.write(client_key.encode())
key_file.flush()
yield {
"cert_path": cert_file.name,
"key_path": key_file.name,
}
else:
yield None


@pytest.fixture(scope="module")
def pdm_mock_document_url(fetch_env_variable: Callable[[str, type[str]], str]) -> str:
return fetch_env_variable("PDM_MOCK_DOCUMENT_URL", str)


@pytest.fixture(scope="module")
def mns_mock_events_url(fetch_env_variable: Callable[[str, type[str]], str]) -> str:
return fetch_env_variable("MNS_MOCK_EVENTS_URL", str)


@pytest.fixture(scope="module")
def pdm_bundle_url(fetch_env_variable: Callable[[str, type[str]], str]) -> str:
return fetch_env_variable("PDM_BUNDLE_URL", str)


@pytest.fixture(scope="module")
def pdm_mock_client(
client_cert: CertificateDetails | None, pdm_mock_document_url: str
) -> PDMMockClient:
return PDMMockClient(
document_url=pdm_mock_document_url,
timeout=timedelta(seconds=5),
client_cert=client_cert,
)


@pytest.fixture(scope="module")
def mns_mock_client(
client_cert: CertificateDetails | None, mns_mock_events_url: str
) -> MNSMockClient:
return MNSMockClient(
events_url=mns_mock_events_url,
timeout=timedelta(seconds=5),
client_cert=client_cert,
)
32 changes: 30 additions & 2 deletions pathology-api/tests/integration/test_endpoints.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Integration tests for the pathology API using pytest."""

import json
import uuid
from collections.abc import Callable
from typing import Any, Literal

Expand All @@ -12,13 +13,21 @@
from pydantic import BaseModel, HttpUrl

from tests.conftest import Client
from tests.mock_client import MNSMockClient, PDMMockClient


class TestBundleEndpoint:
def test_bundle_returns_200(
self, client: Client, build_valid_test_result: Callable[[str, str], Bundle]
self,
client: Client,
build_valid_test_result: Callable[[str, str], Bundle],
pdm_mock_client: PDMMockClient,
mns_mock_client: MNSMockClient,
pdm_bundle_url: str,
) -> None:
bundle = build_valid_test_result("nhs_number", "ods_code")
subject = "subject-" + str(uuid.uuid4())
requesting_ods_code = "ods_code"
bundle = build_valid_test_result(subject, requesting_ods_code)

response = client.send(
data=bundle.model_dump_json(by_alias=True),
Expand Down Expand Up @@ -53,6 +62,25 @@ def test_bundle_returns_200(

assert response.headers["etag"] == 'W/"1"'

sent_request = pdm_mock_client.retrieve_sent_request(response_bundle.id)
assert sent_request == bundle.model_dump(by_alias=True, exclude_none=True)

published_events = mns_mock_client.retrieve_sent_messages(subject)
assert len(published_events) == 1

published_event = published_events[0]
assert published_event["subject"] == subject
assert published_event["dataref"] == pdm_bundle_url + "/" + response_bundle.id
assert published_event["filtering"] == {
"requestingOrganisationODS": requesting_ods_code
}
assert (
published_event["type"]
== "pathology-laboratory-reporting-test-result-stored-1"
)
assert published_event["source"] == "uk.nhs.pathology-laboratory-reporting"
assert published_event["specversion"] == "1.0"

def test_no_payload_returns_error(self, client: Client) -> None:
response = client.send_without_payload(
request_method="POST", path="FHIR/R4/Bundle"
Expand Down
Loading
Loading