Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
223e984
[CDAPI-148]: Added initial PDM mock client for integration tests
nhsd-jack-wainwright Apr 23, 2026
c680679
[CDAPI-148]: Added initial MNS mock client for use by the integration…
nhsd-jack-wainwright Apr 23, 2026
021dff5
[CDAPI-148]: Added new environment variables for PDM and MNS mock cli…
nhsd-jack-wainwright Apr 23, 2026
3112f59
[CDAPI-148]: Moved new mock clients into integration specific conftes…
nhsd-jack-wainwright Apr 23, 2026
882925a
[CDAPI-148]: Removed exports from `run-test-action` action
nhsd-jack-wainwright Apr 23, 2026
f8e01d1
[CDAPI-148]: Added actions for fetching secrets within run-test-suite…
nhsd-jack-wainwright Apr 24, 2026
0a8f1b2
[CDAPI-148]: Removed unnecessary secret retrieval from run-test-suite…
nhsd-jack-wainwright Apr 24, 2026
64f629a
[CDAPI-148]: Added in replacements for dashes within secret names
nhsd-jack-wainwright Apr 27, 2026
0c383bd
[CDAPI-148]: Added environment variables to make test command
nhsd-jack-wainwright Apr 27, 2026
1976ec3
[CDAPI-148]: Fixed fixture name within integration test
nhsd-jack-wainwright Apr 27, 2026
2ef3f5f
[CDAPI-148]: Swapped around mTLS key and cert parameter
nhsd-jack-wainwright Apr 27, 2026
0a04274
[CDAPI-148]: Updated PDM mock to store received document instead of c…
nhsd-jack-wainwright Apr 27, 2026
68c980b
[CDAPI-148]: Dumped retrieved PDM request as a JSON string for compar…
nhsd-jack-wainwright Apr 27, 2026
d12073b
[CDAPI-148]: Swapped assertion within test_endpoints.py to compare di…
nhsd-jack-wainwright Apr 27, 2026
6394b55
[CDAPI-148]: Excluded None values from sent request comparison
nhsd-jack-wainwright Apr 27, 2026
47ec114
[CDAPI-148]: Using unique subject identifier within integration test …
nhsd-jack-wainwright Apr 27, 2026
b9f2b8c
[CDAPI-148]: Added PDM bundle URL to test suite action and integratio…
nhsd-jack-wainwright Apr 27, 2026
cf36e0d
[CDAPI-148]: Fixed supplied Bundle URL
nhsd-jack-wainwright Apr 27, 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 @@ -680,9 +680,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
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
35 changes: 23 additions & 12 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 @@ -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
68 changes: 68 additions & 0 deletions pathology-api/tests/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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(
fetch_env_variable: Callable[[str, type[str]], str],
) -> Generator[CertificateDetails | None, None, None]:
client_cert = fetch_env_variable("CLIENT_CERT", str)
client_key = fetch_env_variable("CLIENT_KEY", str)

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
61 changes: 61 additions & 0 deletions pathology-api/tests/mock_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from datetime import timedelta
from typing import Any, TypedDict, cast

import requests


class CertificateDetails(TypedDict):
cert_path: str
key_path: str


class PDMMockClient:
def __init__(
self,
document_url: str,
timeout: timedelta,
client_cert: CertificateDetails | None,
):
self._document_url = document_url
self._timeout = timeout
self._client_cert = client_cert

def retrieve_sent_request(self, request_id: str) -> Any:
certs = (
(self._client_cert["cert_path"], self._client_cert["key_path"])
if self._client_cert
else None
)

response = requests.get(
self._document_url + "/" + request_id,
timeout=self._timeout.total_seconds(),
cert=certs,
)
return response.json()


class MNSMockClient:
def __init__(
self,
events_url: str,
timeout: timedelta,
client_cert: CertificateDetails | None,
):
self._events_url = events_url
self._timeout = timeout
self._client_cert = client_cert

def retrieve_sent_messages(self, subject: str) -> list[Any]:
certs = (
(self._client_cert["cert_path"], self._client_cert["key_path"])
if self._client_cert
else None
)

response = requests.get(
self._events_url + "?subject=" + subject,
timeout=self._timeout.total_seconds(),
cert=certs,
)
return cast("list[Any]", response.json().get("events", []))
Loading