Skip to content

Commit 00fa09b

Browse files
authored
VED-813-Fix NHS-Number-Change (#847)
Fix Bug NHS Number Change
1 parent 4fe4bbe commit 00fa09b

File tree

8 files changed

+129
-59
lines changed

8 files changed

+129
-59
lines changed

lambdas/id_sync/src/id_sync.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,21 @@ def handler(event_data: Dict[str, Any], _context) -> Dict[str, Any]:
2424

2525
logger.info("id_sync processing event with %d records", len(records))
2626

27-
results = [process_record(record) for record in records]
28-
nhs_numbers = [result["nhs_number"] for result in results]
29-
error_count = sum(1 for result in results if result.get("status") == "error")
27+
results = []
28+
nhs_numbers = []
29+
error_count = 0
3030

31-
if error_count:
31+
for record in records:
32+
result = process_record(record)
33+
results.append(result)
34+
35+
if "nhs_number" in result:
36+
nhs_numbers.append(result["nhs_number"])
37+
38+
if result.get("status") == "error":
39+
error_count += 1
40+
41+
if error_count > 0:
3242
raise IdSyncException(message=f"Processed {len(records)} records with {error_count} errors",
3343
nhs_numbers=nhs_numbers)
3444

lambdas/id_sync/src/ieds_db_operations.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from os_vars import get_ieds_table_name
33
from common.aws_dynamodb import get_dynamodb_table
44
from common.clients import logger, dynamodb_client
5+
import json
56
from utils import make_status
67
from exceptions.id_sync_exception import IdSyncException
78

@@ -19,7 +20,7 @@ def get_ieds_table():
1920

2021

2122
def ieds_update_patient_id(old_id: str, new_id: str, items_to_update: list | None = None) -> dict:
22-
"""Update the patient ID in the IEDS table."""
23+
"""Update the patient ID (new NHS number) in the IEDS table."""
2324
logger.info(f"ieds_update_patient_id. Update patient ID from {old_id} to {new_id}")
2425
if not old_id or not new_id or not old_id.strip() or not new_id.strip():
2526
return make_status("Old ID and New ID cannot be empty", old_id, "error")
@@ -133,13 +134,23 @@ def paginate_items_for_patient_pk(patient_pk: str) -> list:
133134

134135
def extract_patient_resource_from_item(item: dict) -> dict | None:
135136
"""
136-
Extract a Patient resource dict from an IEDS database.
137+
Extract a Patient resource from an IEDS database.
137138
"""
138139
patient_resource = item.get("Resource", None)
140+
141+
if isinstance(patient_resource, str):
142+
try:
143+
patient_resource_parsed = json.loads(patient_resource)
144+
except json.JSONDecodeError:
145+
logger.error("Failed to decode patient_resource JSON string")
146+
return None
147+
patient_resource = patient_resource_parsed
148+
139149
if not isinstance(patient_resource, dict):
140150
return None
141151

142-
for response in patient_resource.get("contained", []):
152+
contained = patient_resource.get("contained") or []
153+
for response in contained:
143154
if isinstance(response, dict) and response.get("resourceType") == "Patient":
144155
return response
145156

lambdas/id_sync/src/pds_details.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ def pds_get_patient_id(nhs_number: str) -> str:
4343
:return: PDS patient ID
4444
"""
4545
try:
46-
logger.info(f"get_pds_patient_id. nhs_number: {nhs_number}")
4746
patient_details = pds_get_patient_details(nhs_number)
4847
if not patient_details:
4948
return None

lambdas/id_sync/src/record_processor.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ def process_nhs_number(nhs_number: str) -> Dict[str, Any]:
5757
logger.exception("process_nhs_number: failed to fetch demographic details: %s", e)
5858
return make_status(str(e), nhs_number, "error")
5959

60+
logger.info("Fetched IEDS resources. IEDS count: %d", len(ieds_resources) if ieds_resources else 0)
61+
6062
if not ieds_resources:
6163
logger.info("No IEDS records returned for NHS number: %s", nhs_number)
6264
return make_status(f"No records returned for ID: {nhs_number}", nhs_number)
@@ -65,6 +67,7 @@ def process_nhs_number(nhs_number: str) -> Dict[str, Any]:
6567
matching_records = []
6668
discarded_count = 0
6769
for detail in ieds_resources:
70+
logger.info("Processing IEDS record: %s", detail)
6871
if demographics_match(pds_patient_resource, detail):
6972
matching_records.append(detail)
7073
else:
@@ -84,16 +87,21 @@ def process_nhs_number(nhs_number: str) -> Dict[str, Any]:
8487
return response
8588

8689

87-
# Function to fetch PDS Patient details and IEDS Immunisation records
90+
# Function to fetch PDS Patient details and IEDS Immunisation records.
8891
def fetch_pds_and_ieds_resources(nhs_number: str):
92+
logger.info("fetch_pds_and_ieds_resources: fetching for %s", nhs_number)
8993
try:
9094
pds = pds_get_patient_details(nhs_number)
9195
except Exception as e:
96+
logger.exception("fetch_pds_and_ieds_resources: failed to fetch PDS details for %s", nhs_number)
9297
raise RuntimeError("Failed to fetch PDS details") from e
98+
9399
try:
94100
ieds = get_items_from_patient_id(nhs_number)
95101
except Exception as e:
102+
logger.exception("fetch_pds_and_ieds_resources: failed to fetch IEDS items for %s", nhs_number)
96103
raise RuntimeError("Failed to fetch IEDS items") from e
104+
97105
return pds, ieds
98106

99107

@@ -132,11 +140,13 @@ def normalize_strings(item: Any) -> str | None:
132140
pds_name = normalize_strings(extract_normalized_name_from_patient(pds_details))
133141
pds_gender = normalize_strings(pds_details.get("gender"))
134142
pds_birth = normalize_strings(pds_details.get("birthDate"))
143+
logger.debug("demographics_match: demographics match for name=%s, gender=%s, birthDate=%s",
144+
pds_name, pds_gender, pds_birth)
135145

136146
# Retrieve patient resource from IEDS item
137147
patient = extract_patient_resource_from_item(ieds_item)
138148
if not patient:
139-
logger.debug("demographics_match: no patient resource in IEDS table item")
149+
logger.info("demographics_match: no patient resource in IEDS table item")
140150
return False
141151

142152
# normalize patient fields from IEDS

lambdas/id_sync/tests/test_id_sync.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -235,29 +235,30 @@ def test_handler_process_record_exception(self):
235235
self.assertEqual(exception.message, "Error processing id_sync event")
236236

237237
def test_handler_process_record_missing_nhs_number(self):
238-
"""Test handler when process_record returns incomplete data"""
238+
"""Test handler when process_record returns error and missing NHS number"""
239+
239240
# Setup mocks
240241
mock_event = MagicMock()
241242
mock_event.records = [MagicMock()]
242243
self.mock_aws_lambda_event.return_value = mock_event
243244

244-
# Missing "nhs_number" in response
245+
# Return result without 'nhs_number' but with an 'error' status
245246
self.mock_process_record.return_value = {
246-
"status": "success"
247-
# Missing "nhs_number"
247+
"status": "error",
248+
"message": "Missing NHS number"
249+
# No 'nhs_number'
248250
}
249251

250-
# Call handler
252+
# Call handler and expect exception
251253
with self.assertRaises(IdSyncException) as exception_context:
252254
handler(self.single_sqs_event, None)
253255

254256
exception = exception_context.exception
255257

256-
# convert exception payload to json
257258
self.assertIsInstance(exception, IdSyncException)
258-
self.assertEqual(exception.nhs_numbers, None)
259-
self.assertEqual(exception.message, "Error processing id_sync event")
260-
self.mock_logger.exception.assert_called_once_with("Error processing id_sync event")
259+
self.assertEqual(exception.nhs_numbers, [])
260+
self.assertEqual(exception.message, "Processed 1 records with 1 errors")
261+
self.mock_logger.exception.assert_called_once_with(f"id_sync error: {exception.message}")
261262

262263
def test_handler_context_parameter_ignored(self):
263264
"""Test that context parameter is properly ignored"""

lambdas/id_sync/tests/test_ieds_db_operations.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,51 @@
11
import unittest
2+
3+
from ieds_db_operations import extract_patient_resource_from_item
24
from unittest.mock import patch, MagicMock
35
from exceptions.id_sync_exception import IdSyncException
4-
56
import ieds_db_operations
67

78

9+
class TestExtractPatientResourceFromItem(unittest.TestCase):
10+
11+
def test_extract_from_dict_with_contained_patient(self):
12+
item = {
13+
"Resource": {
14+
"resourceType": "Immunization",
15+
"contained": [
16+
{"resourceType": "Patient", "id": "P1", "name": [{"family": "Doe"}]}
17+
],
18+
}
19+
}
20+
21+
patient = extract_patient_resource_from_item(item)
22+
self.assertIsNotNone(patient)
23+
self.assertIsInstance(patient, dict)
24+
self.assertEqual(patient.get("resourceType"), "Patient")
25+
self.assertEqual(patient.get("id"), "P1")
26+
27+
def test_extract_from_json_string(self):
28+
resource_json = '{"resourceType": "Immunization", "contained": [{"resourceType": "Patient", "id": "P2"}]}'
29+
item = {"Resource": resource_json}
30+
31+
patient = extract_patient_resource_from_item(item)
32+
self.assertIsNotNone(patient)
33+
self.assertEqual(patient.get("id"), "P2")
34+
35+
def test_malformed_json_string_returns_none(self):
36+
# A malformed JSON string should not raise, but return None
37+
item = {"Resource": "{not: valid json}"}
38+
self.assertIsNone(extract_patient_resource_from_item(item))
39+
40+
def test_non_dict_resource_returns_none(self):
41+
item = {"Resource": 12345}
42+
self.assertIsNone(extract_patient_resource_from_item(item))
43+
44+
def test_missing_resource_returns_none(self):
45+
item = {}
46+
self.assertIsNone(extract_patient_resource_from_item(item))
47+
48+
849
class TestIedsDbOperations(unittest.TestCase):
950
"""Base test class for IEDS database operations"""
1051

lambdas/shared/src/common/clients.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
import logging
33
from boto3 import client as boto3_client, resource as boto3_resource
44

5-
logging.basicConfig(level="INFO")
5+
logging.basicConfig(level=logging.INFO)
66
logger = logging.getLogger()
7-
logger.setLevel("INFO")
7+
logger.setLevel(logging.INFO)
88

99
STREAM_NAME = os.getenv("SPLUNK_FIREHOSE_NAME", "firehose-name-not-defined")
1010
CONFIG_BUCKET_NAME = os.getenv("CONFIG_BUCKET_NAME", "variconfig-bucketable-not-defined")
Lines changed: 35 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,61 @@
11
import unittest
2-
from unittest.mock import patch
3-
import common.clients as clients
2+
from unittest.mock import patch, MagicMock
3+
import logging
44
import importlib
5+
import common.clients as clients
56

67

78
class TestClients(unittest.TestCase):
8-
99
BUCKET_NAME = "default-bucket"
1010
AWS_REGION = "eu-west-2"
1111

1212
def setUp(self):
13-
self.boto3_client_patch = patch("boto3.client")
13+
# Patch boto3.client
14+
self.boto3_client_patch = patch("boto3.client", autospec=True)
1415
self.mock_boto3_client = self.boto3_client_patch.start()
15-
self.logging_patch = patch("logging.getLogger")
16-
self.mock_logging = self.logging_patch.start()
17-
self.logger_info_patcher = patch("logging.Logger.info")
18-
self.mock_logger_info = self.logger_info_patcher.start()
19-
self.getenv_patch = patch("os.getenv")
16+
self.addCleanup(self.boto3_client_patch.stop)
17+
18+
# Patch logging.getLogger
19+
self.logging_patch = patch("logging.getLogger", autospec=True)
20+
self.mock_getLogger = self.logging_patch.start()
21+
self.addCleanup(self.logging_patch.stop)
22+
23+
# Patch os.getenv
24+
self.getenv_patch = patch("os.getenv", autospec=True)
2025
self.mock_getenv = self.getenv_patch.start()
26+
self.addCleanup(self.getenv_patch.stop)
27+
28+
# Set environment variable mock return values
2129
self.mock_getenv.side_effect = lambda key, default=None: {
2230
"CONFIG_BUCKET_NAME": self.BUCKET_NAME,
2331
"AWS_REGION": self.AWS_REGION,
2432
}.get(key, default)
2533

26-
self.mock_boto3_client.return_value = self.mock_boto3_client
27-
self.mock_boto3_client.return_value.send_message = {}
28-
29-
def tearDown(self):
30-
patch.stopall()
34+
# Simulate logger instance and patch setLevel
35+
self.mock_logger_instance = MagicMock()
36+
self.mock_getLogger.return_value = self.mock_logger_instance
3137

32-
def test_os_environ(self):
33-
# Test if environment variables are set correctly
38+
# Reload the module under test to apply patches
3439
importlib.reload(clients)
40+
41+
def test_env_variables_loaded(self):
42+
"""Test that environment variables are loaded correctly"""
3543
self.assertEqual(clients.CONFIG_BUCKET_NAME, self.BUCKET_NAME)
3644
self.assertEqual(clients.REGION_NAME, self.AWS_REGION)
3745

38-
def test_boto3_client(self):
39-
''' Test boto3 client is created with correct parameters '''
40-
importlib.reload(clients)
46+
def test_boto3_client_created_for_s3(self):
47+
"""Test that S3 boto3 client is created with correct region"""
4148
self.mock_boto3_client.assert_any_call("s3", region_name=self.AWS_REGION)
4249

43-
def test_firehose_client(self):
44-
''' Test firehose client is created with correct parameters '''
45-
importlib.reload(clients)
50+
def test_boto3_client_created_for_firehose(self):
51+
"""Test that Firehose boto3 client is created with correct region"""
4652
self.mock_boto3_client.assert_any_call("firehose", region_name=self.AWS_REGION)
4753

48-
def test_logging_setup(self):
49-
''' Test logging is set up correctly '''
50-
importlib.reload(clients)
51-
self.assertTrue(hasattr(clients, 'logger'))
54+
def test_logger_is_initialized(self):
55+
"""Test that a logger instance is initialized"""
56+
self.mock_getLogger.assert_called_once_with()
57+
self.assertTrue(hasattr(clients, "logger"))
5258

53-
def test_logging_configuration(self):
54-
''' Test logging configuration '''
55-
importlib.reload(clients)
56-
clients.logger.setLevel.assert_called_once_with("INFO")
57-
58-
def test_logging_initialization(self):
59-
''' Test logging initialization '''
60-
importlib.reload(clients)
61-
self.mock_logging.assert_called_once_with()
62-
self.assertTrue(hasattr(clients, 'logger'))
63-
clients.logger.setLevel.assert_any_call("INFO")
59+
def test_logger_set_level(self):
60+
"""Test that logger level is set to INFO"""
61+
self.mock_logger_instance.setLevel.assert_called_once_with(logging.INFO)

0 commit comments

Comments
 (0)