Skip to content

Commit 6d1b003

Browse files
committed
merge master into staging branch
2 parents fe82c86 + f03cdcd commit 6d1b003

8 files changed

Lines changed: 445 additions & 13 deletions

File tree

README.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ See https://nhsd-confluence.digital.nhs.uk/display/APM/Glossary.
2626
| `id_sync` | **Imms Cross-cutting** – Handles [MNS](https://digital.nhs.uk/developer/api-catalogue/multicast-notification-service) NHS Number Change events and applies updates to affected records. |
2727
| `mesh_processor` | **Imms Batch** – Triggered when new files are received via MESH. Moves them into the Imms Batch processing system. |
2828
| `mns_subscription` | **Imms Cross-cutting** – Simple helper Lambda which sets up our required MNS subscription. Used in pipelines in DEV. |
29+
| `perf_tests` | **Imms API** – Locust performance tests for the Immunisation API. |
2930
| `recordforwarder` | **Imms Batch** – Consumes from the stream and applies the processed batch file row operations (CUD) to IEDS. |
3031
| `recordprocessor` | **Imms Batch** – ECS Task - **not** a Lambda function - responsible for processing batch file rows and forwarding to the stream. |
3132
| `redis_sync` | **Imms Cross-cutting** – Handles config file updates. E.g. disease mapping or permission files. |
@@ -142,22 +143,12 @@ Steps:
142143
pip install poetry
143144
```
144145
145-
### Install Pre-Commit Hooks
146-
147-
[Husky](https://typicode.github.io/husky/) is used to perform automatic checks upon making a commit.
148-
It is configured within `.husky/pre-commit` to run the checks defined in the root level `package.json` under `lint-staged`.
149-
To set this up:
150-
151-
1. Ensure you have installed nodejs at the same version or later as per .tool-versions and
146+
8. Install pre-commit hooks. Ensure you have installed nodejs at the same version or later as per .tool-versions and
152147
then, from the repo root, run:
153-
154148
```
155149
npm install
156150
```
157151
158-
2. Run `cd quality_checks` then `poetry install --no-root`. This will make sure your version of ruff is the same as used in the GitHub pipeline.
159-
You can check your version is correct by running `poetry run ruff --version` from within the `quality_checks` directory and comparing to the version in the poetry.lock file.
160-
161152
### Setting up a virtual environment with poetry
162153
163154
The steps below must be performed in each Lambda function folder and e2e_automation folder to ensure the environment is correctly configured.
@@ -216,6 +207,18 @@ Steps:
216207
217208
It is not necessary to activate the virtual environment (using `source .venv/bin/activate`) before running a unit test suite from the command line; `direnv` will pick up the correct configurations for us. Run `pip list` to verify that the expected packages are installed. You should for example see that `recordprocessor` is specifically running `moto` v4, regardless of which if any `.venv` is active.
218209
210+
### Setting up the root level environment
211+
212+
The root-level virtual environment is primarily used for linting, as we create separate virtual environments for each folder that contains Lambda functions.
213+
Steps:
214+
215+
1. Follow instructions above to [install dependencies](#install-dependencies) & [set up a virtual environment](#setting-up-a-virtual-environment-with-poetry).
216+
**Note: While this project uses Python 3.11 (e.g. for Lambdas), the NHSDigital/api-management-utils repository — which orchestrates setup and linting — defaults to Python 3.8.
217+
The linting command is executed from within that repo but calls the Makefile in this project, so be aware of potential Python version mismatches when running or debugging locally or in the pipeline.**
218+
2. Run `make lint`. This will:
219+
- Check the linting of the API specification yaml.
220+
- Run Flake8 on all Python files in the repository, excluding files inside .venv and .terraform directories.
221+
219222
## IDE setup
220223
221224
The current team uses VS Code mainly. So this setup is targeted towards VS code. If you use another IDE please add the documentation to set up workspaces here.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import unittest
2+
from unittest.mock import MagicMock, patch
3+
4+
from exceptions.id_sync_exception import IdSyncException
5+
from pds_details import get_nhs_number_from_pds_resource, pds_get_patient_details
6+
7+
8+
class TestGetPdsPatientDetails(unittest.TestCase):
9+
def setUp(self):
10+
"""Set up test fixtures and mocks"""
11+
self.test_patient_id = "9912003888"
12+
13+
# Patch all external dependencies
14+
self.logger_patcher = patch("pds_details.logger")
15+
self.mock_logger = self.logger_patcher.start()
16+
17+
self.secrets_manager_patcher = patch("common.clients.global_secrets_manager_client")
18+
self.mock_secrets_manager = self.secrets_manager_patcher.start()
19+
20+
self.pds_env_patcher = patch("pds_details.get_pds_env")
21+
self.mock_pds_env = self.pds_env_patcher.start()
22+
self.mock_pds_env.return_value = "test-env"
23+
24+
self.auth_patcher = patch("pds_details.AppRestrictedAuth")
25+
self.mock_auth_class = self.auth_patcher.start()
26+
self.mock_auth_instance = MagicMock()
27+
self.mock_auth_class.return_value = self.mock_auth_instance
28+
29+
self.pds_service_patcher = patch("pds_details.PdsService")
30+
self.mock_pds_service_class = self.pds_service_patcher.start()
31+
self.mock_pds_service_instance = MagicMock()
32+
self.mock_pds_service_class.return_value = self.mock_pds_service_instance
33+
34+
def tearDown(self):
35+
"""Clean up patches"""
36+
patch.stopall()
37+
38+
def test_pds_get_patient_details_success(self):
39+
"""Test successful retrieval of patient details"""
40+
# Arrange
41+
expected_patient_data = {
42+
"identifier": [{"value": "9912003888"}],
43+
"name": "John Doe",
44+
"birthDate": "1990-01-01",
45+
"gender": "male",
46+
}
47+
self.mock_pds_service_instance.get_patient_details.return_value = expected_patient_data
48+
49+
# Act
50+
result = pds_get_patient_details(self.test_patient_id)
51+
52+
# Assert
53+
self.assertEqual(result["identifier"][0]["value"], "9912003888")
54+
55+
# Verify get_patient_details was called
56+
self.mock_pds_service_instance.get_patient_details.assert_called_once()
57+
58+
def test_pds_get_patient_details_no_patient_found(self):
59+
"""Test when PDS returns None (no patient found)"""
60+
# Arrange
61+
self.mock_pds_service_instance.get_patient_details.return_value = None
62+
63+
# Act
64+
result = pds_get_patient_details(self.test_patient_id)
65+
66+
# Assert
67+
self.assertIsNone(result)
68+
69+
self.mock_pds_service_instance.get_patient_details.assert_called_once_with(self.test_patient_id)
70+
71+
def test_pds_get_patient_details_empty_response(self):
72+
"""Test when PDS returns empty dict (falsy)"""
73+
# Arrange
74+
self.mock_pds_service_instance.get_patient_details.return_value = None
75+
76+
# Act
77+
result = pds_get_patient_details(self.test_patient_id)
78+
79+
# Assert
80+
self.assertIsNone(result)
81+
82+
def test_pds_get_patient_details_pds_service_exception(self):
83+
"""Test when PdsService.get_patient_details raises an exception"""
84+
# Arrange
85+
mock_exception = Exception("My custom error")
86+
self.mock_pds_service_instance.get_patient_details.side_effect = mock_exception
87+
88+
# Act
89+
with self.assertRaises(IdSyncException) as context:
90+
pds_get_patient_details(self.test_patient_id)
91+
92+
exception = context.exception
93+
94+
# Assert
95+
self.assertEqual(
96+
exception.message,
97+
"Error retrieving patient details from PDS",
98+
)
99+
100+
# Verify exception was logged
101+
self.mock_logger.exception.assert_called_once_with("Error retrieving patient details from PDS")
102+
103+
self.mock_pds_service_instance.get_patient_details.assert_called_once_with(self.test_patient_id)
104+
105+
def test_pds_get_patient_details_auth_initialization_error(self):
106+
"""Test when AppRestrictedAuth initialization fails"""
107+
# Arrange
108+
self.mock_auth_class.side_effect = ValueError("Invalid authentication parameters")
109+
110+
# Act
111+
with self.assertRaises(IdSyncException) as context:
112+
pds_get_patient_details(self.test_patient_id)
113+
114+
# Assert
115+
exception = context.exception
116+
self.assertEqual(
117+
exception.message,
118+
"Error retrieving patient details from PDS",
119+
)
120+
121+
# Verify exception was logged
122+
self.mock_logger.exception.assert_called_once_with("Error retrieving patient details from PDS")
123+
124+
def test_pds_get_patient_details_exception(self):
125+
"""Test when logger.info throws an exception"""
126+
# Arrange
127+
test_exception = Exception("some-random-error")
128+
self.mock_pds_service_class.side_effect = test_exception
129+
test_nhs_number = "another-nhs-number"
130+
131+
# Act
132+
with self.assertRaises(Exception) as context:
133+
pds_get_patient_details(test_nhs_number)
134+
135+
exception = context.exception
136+
# Assert
137+
self.assertEqual(
138+
exception.message,
139+
"Error retrieving patient details from PDS",
140+
)
141+
# Verify logger.exception was called due to the caught exception
142+
self.mock_logger.exception.assert_called_once_with("Error retrieving patient details from PDS")
143+
144+
def test_pds_get_patient_details_different_patient_ids(self):
145+
"""Test with different patient ID formats"""
146+
test_cases = [
147+
("9912003888", {"identifier": [{"value": "9912003888"}]}),
148+
("1234567890", {"identifier": [{"value": "1234567890"}]}),
149+
("0000000000", {"identifier": [{"value": "0000000000"}]}),
150+
]
151+
152+
for patient_id, expected_response in test_cases:
153+
with self.subTest(patient_id=patient_id):
154+
# Reset mocks
155+
self.mock_pds_service_instance.reset_mock()
156+
self.mock_logger.reset_mock()
157+
158+
# Arrange
159+
self.mock_pds_service_instance.get_patient_details.return_value = expected_response
160+
161+
# Act
162+
result = pds_get_patient_details(patient_id)
163+
164+
# Assert
165+
self.assertEqual(result, expected_response)
166+
self.mock_pds_service_instance.get_patient_details.assert_called_once_with(patient_id)
167+
168+
def test_pds_get_patient_details(self):
169+
"""Test with complex identifier structure"""
170+
# Arrange
171+
test_nhs_number = "9912003888"
172+
pds_id = "abcefghijkl"
173+
mock_pds_response = {"identifier": [{"value": pds_id}]}
174+
self.mock_pds_service_instance.get_patient_details.return_value = mock_pds_response
175+
# Act
176+
result = pds_get_patient_details(test_nhs_number)
177+
178+
# Assert - function should extract the value from first identifier
179+
self.assertEqual(result, mock_pds_response)
180+
self.mock_pds_service_instance.get_patient_details.assert_called_once_with(test_nhs_number)
181+
182+
def test_get_nhs_number_from_pds_resource(self):
183+
"""Test that the NHS Number is retrieved from a full PDS patient resource."""
184+
mock_pds_resource = {
185+
"identifier": [
186+
{
187+
"system": "https://fhir.nhs.uk/Id/nhs-number",
188+
"value": "123456789012",
189+
}
190+
]
191+
}
192+
193+
result = get_nhs_number_from_pds_resource(mock_pds_resource)
194+
195+
self.assertEqual(result, "123456789012")
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import logging
2+
3+
import boto3
4+
from botocore.config import Config
5+
6+
from common.api_clients.authentication import AppRestrictedAuth
7+
from common.api_clients.mns_service import MnsService
8+
9+
logging.basicConfig(level=logging.INFO)
10+
11+
12+
def get_mns_service(mns_env: str = "int"):
13+
boto_config = Config(region_name="eu-west-2")
14+
15+
authenticator = AppRestrictedAuth(
16+
secret_manager_client=boto3.client("secretsmanager", config=boto_config),
17+
environment=mns_env,
18+
)
19+
20+
return MnsService(authenticator)

manifest_template.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ APIGEE_ENVIRONMENTS:
55
display_name_suffix: Internal Development
66
ratelimiting:
77
immunisation-fhir-api-internal-dev:
8-
# 5 requests per second on average
8+
# 500 requests per second on average
99
quota:
1010
enabled: true
1111
limit: 300
@@ -112,7 +112,7 @@ APIGEE_ENVIRONMENTS:
112112
limit: 300
113113
interval: 1
114114
timeunit: minute
115-
# 10 requests per second max
115+
# 1000 requests per second max
116116
spikeArrest:
117117
enabled: true
118118
ratelimit: 600pm

tests/perf_tests/Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
test:
2+
poetry run locust -f src/locustfile.py
3+
4+
.PHONY: test

tests/perf_tests/Readme.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Perf tests
2+
3+
This project contains Locust performance tests for the Immunisation FHIR API.
4+
5+
To run them, ensure you have the
6+
`APIGEE_ENVIRONMENT` : Currently, only the ref environment is supported.
7+
`PERF_SUPPLIER_SYSTEM` : `EMIS` or `TPP`
8+
`PERF_CREATE_RPS_PER_USER` : numeric
9+
10+
env vars set, and call `make test`.

tests/perf_tests/pyproject.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[tool.poetry]
2+
name = "perf-tests"
3+
version = "0.1.0"
4+
description = ""
5+
authors = ["Matt Jarvis matt.jarvis2@nhs.net"]
6+
readme = "README.md"
7+
packages = [
8+
{ include = "objectModels", from = "../e2e_automation/src" },
9+
{ include = "utilities", from = "../e2e_automation" }
10+
]
11+
12+
[tool.poetry.dependencies]
13+
python = ">=3.11,<3.12"
14+
locust = ">=2.42.3,<3.0.0"
15+
pyjwt = { version = ">=2.11.0,<3.0.0", extras = ["crypto"] }
16+
boto3 = ">=1.42.59,<2.0.0"
17+
shared = { path = "../../lambdas/shared", develop = true }
18+
pandas = "2.3.0"
19+
pydantic = "1.10.13"
20+
typing_extensions = "~4.15.0"
21+
22+
[build-system]
23+
requires = ["poetry-core>=2.0.0,<3.0.0"]
24+
build-backend = "poetry.core.masonry.api"

0 commit comments

Comments
 (0)