Skip to content

Commit 072431f

Browse files
committed
Merge branch 'main' into feature/CCM-14566_validate_fhir_request
2 parents 191c71e + cc997d2 commit 072431f

15 files changed

Lines changed: 662 additions & 145 deletions

File tree

infrastructure/terraform/components/dl/module_lambda_mesh_acknowledge.tf

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ module "mesh_acknowledge" {
4040
EVENT_PUBLISHER_DLQ_URL = module.sqs_event_publisher_errors.sqs_queue_url
4141
EVENT_PUBLISHER_EVENT_BUS_ARN = aws_cloudwatch_event_bus.main.arn
4242
MOCK_MESH_BUCKET = module.s3bucket_non_pii_data.bucket
43-
SSM_MESH_PREFIX = "${local.ssm_mesh_prefix}"
44-
SSM_SENDERS_PREFIX = "${local.ssm_senders_prefix}"
43+
SSM_MESH_PREFIX = local.ssm_mesh_prefix
44+
SSM_SENDERS_PREFIX = local.ssm_senders_prefix
4545
USE_MESH_MOCK = var.enable_mock_mesh ? "true" : "false"
4646
}
4747

infrastructure/terraform/components/dl/module_lambda_mesh_download.tf

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,16 @@ module "mesh_download" {
3737
log_subscription_role_arn = local.acct.log_subscription_role_arn
3838

3939
lambda_env_vars = {
40-
DOWNLOAD_METRIC_NAME = "mesh-download-successful-downloads"
41-
DOWNLOAD_METRIC_NAMESPACE = "dl-mesh-download"
42-
ENVIRONMENT = var.environment
43-
EVENT_PUBLISHER_DLQ_URL = module.sqs_event_publisher_errors.sqs_queue_url
44-
EVENT_PUBLISHER_EVENT_BUS_ARN = aws_cloudwatch_event_bus.main.arn
45-
PII_BUCKET = module.s3bucket_pii_data.bucket
46-
SSM_MESH_PREFIX = "${local.ssm_mesh_prefix}"
47-
SSM_SENDERS_PREFIX = "${local.ssm_senders_prefix}"
48-
USE_MESH_MOCK = var.enable_mock_mesh ? "true" : "false"
40+
DOWNLOAD_METRIC_NAME = "mesh-download-successful-downloads"
41+
DUPLICATE_DOWNLOAD_METRIC_NAME = "mesh-duplicate-downloads"
42+
DOWNLOAD_METRIC_NAMESPACE = "dl-mesh-download"
43+
ENVIRONMENT = var.environment
44+
EVENT_PUBLISHER_DLQ_URL = module.sqs_event_publisher_errors.sqs_queue_url
45+
EVENT_PUBLISHER_EVENT_BUS_ARN = aws_cloudwatch_event_bus.main.arn
46+
PII_BUCKET = module.s3bucket_pii_data.bucket
47+
SSM_MESH_PREFIX = local.ssm_mesh_prefix
48+
SSM_SENDERS_PREFIX = local.ssm_senders_prefix
49+
USE_MESH_MOCK = var.enable_mock_mesh ? "true" : "false"
4950
}
5051

5152
}

infrastructure/terraform/components/dl/module_lambda_mesh_poll.tf

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ module "mesh_poll" {
4545
MAXIMUM_RUNTIME_MILLISECONDS = "240000" # 4 minutes (Lambda has 5 min timeout)
4646
POLLING_METRIC_NAME = "mesh-poll-successful-polls"
4747
POLLING_METRIC_NAMESPACE = "dl-mesh-poll"
48-
SSM_MESH_PREFIX = "${local.ssm_mesh_prefix}"
49-
SSM_SENDERS_PREFIX = "${local.ssm_senders_prefix}"
48+
SSM_MESH_PREFIX = local.ssm_mesh_prefix
49+
SSM_SENDERS_PREFIX = local.ssm_senders_prefix
5050
USE_MESH_MOCK = var.enable_mock_mesh ? "true" : "false"
5151
}
5252

infrastructure/terraform/components/dl/module_lambda_report_sender.tf

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ module "report_sender" {
4242
EVENT_PUBLISHER_DLQ_URL = module.sqs_event_publisher_errors.sqs_queue_url
4343
EVENT_PUBLISHER_EVENT_BUS_ARN = aws_cloudwatch_event_bus.main.arn
4444
MOCK_MESH_BUCKET = module.s3bucket_non_pii_data.bucket
45-
SSM_MESH_PREFIX = "${local.ssm_mesh_prefix}"
46-
SSM_SENDERS_PREFIX = "${local.ssm_senders_prefix}"
45+
SSM_MESH_PREFIX = local.ssm_mesh_prefix
46+
SSM_SENDERS_PREFIX = local.ssm_senders_prefix
4747
USE_MESH_MOCK = var.enable_mock_mesh ? "true" : "false"
4848
}
4949

lambdas/mesh-download/mesh_download/__tests__/test_document_store.py

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
"""Tests for DocumentStore"""
22
import pytest
33
from unittest.mock import Mock
4-
from mesh_download.document_store import DocumentStore, IntermediaryBodyStoreError
4+
from botocore.exceptions import ClientError
5+
from mesh_download.document_store import DocumentStore, IntermediaryBodyStoreError, DocumentAlreadyExistsError
6+
7+
8+
def make_client_error(code):
9+
"""Helper to build a botocore ClientError with a given error code"""
10+
return ClientError(
11+
{'Error': {'Code': code, 'Message': 'test'}},
12+
'PutObject'
13+
)
514

615

716
class TestDocumentStore:
@@ -21,20 +30,41 @@ def test_store_document_success(self):
2130
store = DocumentStore(config)
2231

2332
result = store.store_document(
24-
sender_id='SENDER_001',
25-
message_reference='ref_123',
33+
sender_id='SENDER-001',
34+
message_reference='ref-123',
35+
mesh_message_id='mesh-456',
2636
content=b'test content'
2737
)
2838

29-
assert result == 'document-reference/SENDER_001_ref_123'
39+
assert result == 'document-reference/SENDER-001/ref-123_mesh-456'
3040
mock_s3_client.put_object.assert_called_once_with(
3141
Bucket='test-pii-bucket',
32-
Key='document-reference/SENDER_001_ref_123',
33-
Body=b'test content'
42+
Key='document-reference/SENDER-001/ref-123_mesh-456',
43+
Body=b'test content',
44+
IfNoneMatch='*'
3445
)
3546

3647
def test_store_document_s3_failure_raises_error(self):
37-
"""Raises IntermediaryBodyStoreError when S3 put_object fails"""
48+
"""Raises IntermediaryBodyStoreError when S3 put_object fails with a non-HTTP error"""
49+
mock_s3_client = Mock()
50+
mock_s3_client.put_object.side_effect = make_client_error('InternalError')
51+
52+
config = Mock()
53+
config.s3_client = mock_s3_client
54+
config.transactional_data_bucket = 'test-pii-bucket'
55+
56+
store = DocumentStore(config)
57+
58+
with pytest.raises(IntermediaryBodyStoreError):
59+
store.store_document(
60+
sender_id='SENDER-001',
61+
message_reference='ref-123',
62+
mesh_message_id='mesh-456',
63+
content=b'test content'
64+
)
65+
66+
def test_store_document_raises_error_on_non_200_response(self):
67+
"""Raises IntermediaryBodyStoreError when S3 returns a non-200 HTTP status"""
3868
mock_s3_client = Mock()
3969
mock_s3_client.put_object.return_value = {
4070
'ResponseMetadata': {'HTTPStatusCode': 500}
@@ -48,7 +78,27 @@ def test_store_document_s3_failure_raises_error(self):
4878

4979
with pytest.raises(IntermediaryBodyStoreError):
5080
store.store_document(
51-
sender_id='SENDER_001',
52-
message_reference='ref_123',
81+
sender_id='SENDER-001',
82+
message_reference='ref-123',
83+
mesh_message_id='mesh-456',
84+
content=b'test content'
85+
)
86+
87+
def test_store_document_precondition_failed_raises_document_already_exists(self):
88+
"""Raises DocumentAlreadyExistsError when S3 returns PreconditionFailed (object already exists)"""
89+
mock_s3_client = Mock()
90+
mock_s3_client.put_object.side_effect = make_client_error('PreconditionFailed')
91+
92+
config = Mock()
93+
config.s3_client = mock_s3_client
94+
config.transactional_data_bucket = 'test-pii-bucket'
95+
96+
store = DocumentStore(config)
97+
98+
with pytest.raises(DocumentAlreadyExistsError, match='document-reference/SENDER-001/ref-123_mesh-456'):
99+
store.store_document(
100+
sender_id='SENDER-001',
101+
message_reference='ref-123',
102+
mesh_message_id='mesh-456',
53103
content=b'test content'
54104
)

lambdas/mesh-download/mesh_download/__tests__/test_handler.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def setup_mocks():
1515
mock_config.mesh_client = Mock()
1616

1717
mock_processor = Mock()
18-
mock_processor.process_sqs_message = Mock()
18+
mock_processor.process_sqs_message = Mock(return_value='downloaded')
1919

2020
return (
2121
mock_context,
@@ -149,9 +149,9 @@ def test_handler_partial_batch_failure(self, mock_processor_class, mock_config_c
149149

150150
# Make second message fail
151151
mock_processor.process_sqs_message.side_effect = [
152-
None,
152+
'downloaded',
153153
Exception("Test error"),
154-
None
154+
'downloaded'
155155
]
156156

157157
event = create_sqs_event(num_records=3)

lambdas/mesh-download/mesh_download/__tests__/test_processor.py

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from datetime import datetime, timezone
1010
from pydantic import ValidationError
1111
from mesh_download.errors import MeshMessageNotFound
12+
from mesh_download.document_store import DocumentAlreadyExistsError
1213

1314

1415
def setup_mocks():
@@ -19,6 +20,7 @@ def setup_mocks():
1920
# Set up default config attributes
2021
config.mesh_client = Mock()
2122
config.download_metric = Mock()
23+
config.duplicate_download_metric = Mock()
2224
config.s3_client = Mock()
2325
config.environment = 'development'
2426
config.transactional_data_bucket = 'test-pii-bucket'
@@ -48,9 +50,9 @@ def create_valid_cloud_event():
4850
'traceparent': '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
4951
'dataschema': 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-received-data.schema.json',
5052
'data': {
51-
'meshMessageId': 'test_message_123',
52-
'senderId': 'TEST_SENDER',
53-
'messageReference': 'ref_001'
53+
'meshMessageId': 'test-message-123',
54+
'senderId': 'TEST-SENDER',
55+
'messageReference': 'ref-001'
5456
}
5557
}
5658

@@ -152,6 +154,7 @@ def test_processor_initialization_calls_mesh_handshake(self):
152154
log=log,
153155
mesh_client=config.mesh_client,
154156
download_metric=config.download_metric,
157+
duplicate_download_metric=config.duplicate_download_metric,
155158
document_store=document_store,
156159
event_publisher=event_publisher
157160
)
@@ -168,7 +171,7 @@ def test_process_sqs_message_success(self, mock_datetime):
168171
fixed_time = datetime(2025, 11, 19, 15, 30, 45, tzinfo=timezone.utc)
169172
mock_datetime.now.return_value = fixed_time
170173

171-
document_store.store_document.return_value = 'document-reference/SENDER_001_ref_001'
174+
document_store.store_document.return_value = 'document-reference/SENDER-001/ref-001_test-message-123'
172175

173176
event_publisher.send_events.return_value = []
174177

@@ -177,6 +180,7 @@ def test_process_sqs_message_success(self, mock_datetime):
177180
log=log,
178181
mesh_client=config.mesh_client,
179182
download_metric=config.download_metric,
183+
duplicate_download_metric=config.duplicate_download_metric,
180184
document_store=document_store,
181185
event_publisher=event_publisher
182186
)
@@ -186,15 +190,17 @@ def test_process_sqs_message_success(self, mock_datetime):
186190

187191
sqs_record = create_sqs_record()
188192

189-
processor.process_sqs_message(sqs_record)
193+
outcome = processor.process_sqs_message(sqs_record)
190194

191-
config.mesh_client.retrieve_message.assert_called_once_with('test_message_123')
195+
assert outcome == 'downloaded'
196+
config.mesh_client.retrieve_message.assert_called_once_with('test-message-123')
192197

193198
mesh_message.read.assert_called_once()
194199

195200
document_store.store_document.assert_called_once_with(
196201
sender_id='TEST_SENDER',
197202
message_reference='ref_001',
203+
mesh_message_id='test-message-123',
198204
content=create_fhir_content()
199205
)
200206

@@ -225,9 +231,9 @@ def test_process_sqs_message_success(self, mock_datetime):
225231

226232
# Verify CloudEvent data payload
227233
event_data = published_event['data']
228-
assert event_data['senderId'] == 'TEST_SENDER'
229-
assert event_data['messageReference'] == 'ref_001'
230-
assert event_data['messageUri'] == 's3://test-pii-bucket/document-reference/SENDER_001_ref_001'
234+
assert event_data['senderId'] == 'TEST-SENDER'
235+
assert event_data['messageReference'] == 'ref-001'
236+
assert event_data['messageUri'] == 's3://test-pii-bucket/document-reference/SENDER-001/ref-001_test-message-123'
231237
assert set(event_data.keys()) == {'senderId', 'messageReference', 'messageUri', 'meshMessageId'}
232238

233239
@patch('mesh_download.processor.datetime')
@@ -310,6 +316,7 @@ def test_process_sqs_message_validation_failure(self):
310316
log=log,
311317
mesh_client=config.mesh_client,
312318
download_metric=config.download_metric,
319+
duplicate_download_metric=config.duplicate_download_metric,
313320
document_store=document_store,
314321
event_publisher=event_publisher
315322
)
@@ -334,6 +341,7 @@ def test_process_sqs_message_missing_mesh_message_id(self):
334341
log=log,
335342
mesh_client=config.mesh_client,
336343
download_metric=config.download_metric,
344+
duplicate_download_metric=config.duplicate_download_metric,
337345
document_store=document_store,
338346
event_publisher=event_publisher
339347
)
@@ -361,17 +369,18 @@ def test_download_and_store_message_not_found(self):
361369
log=log,
362370
mesh_client=config.mesh_client,
363371
download_metric=config.download_metric,
372+
duplicate_download_metric=config.duplicate_download_metric,
364373
document_store=document_store,
365374
event_publisher=event_publisher
366375
)
367376

368377
config.mesh_client.retrieve_message.return_value = None
369378
sqs_record = create_sqs_record()
370379

371-
with pytest.raises(MeshMessageNotFound, match="MESH message with ID test_message_123 not found"):
380+
with pytest.raises(MeshMessageNotFound, match="MESH message with ID test-message-123 not found"):
372381
processor.process_sqs_message(sqs_record)
373382

374-
config.mesh_client.retrieve_message.assert_called_once_with('test_message_123')
383+
config.mesh_client.retrieve_message.assert_called_once_with('test-message-123')
375384
document_store.store_document.assert_not_called()
376385
event_publisher.send_events.assert_not_called()
377386
config.download_metric.record.assert_not_called()
@@ -391,6 +400,7 @@ def test_document_store_failure_prevents_ack_and_raises(self):
391400
log=log,
392401
mesh_client=config.mesh_client,
393402
download_metric=config.download_metric,
403+
duplicate_download_metric=config.duplicate_download_metric,
394404
document_store=document_store,
395405
event_publisher=event_publisher
396406
)
@@ -426,6 +436,7 @@ def test_bucket_selection_with_mesh_mock_enabled(self, mock_datetime):
426436
log=log,
427437
mesh_client=config.mesh_client,
428438
download_metric=config.download_metric,
439+
duplicate_download_metric=config.duplicate_download_metric,
429440
document_store=document_store,
430441
event_publisher=event_publisher
431442
)
@@ -434,8 +445,9 @@ def test_bucket_selection_with_mesh_mock_enabled(self, mock_datetime):
434445
config.mesh_client.retrieve_message.return_value = mesh_message
435446
sqs_record = create_sqs_record()
436447

437-
processor.process_sqs_message(sqs_record)
448+
outcome = processor.process_sqs_message(sqs_record)
438449

450+
assert outcome == 'downloaded'
439451
# Verify event was published with PII bucket in URI
440452
event_publisher.send_events.assert_called_once()
441453
published_events = event_publisher.send_events.call_args[0][0]
@@ -464,6 +476,7 @@ def test_bucket_selection_with_mesh_mock_disabled(self, mock_datetime):
464476
log=log,
465477
mesh_client=config.mesh_client,
466478
download_metric=config.download_metric,
479+
duplicate_download_metric=config.duplicate_download_metric,
467480
document_store=document_store,
468481
event_publisher=event_publisher
469482
)
@@ -472,10 +485,52 @@ def test_bucket_selection_with_mesh_mock_disabled(self, mock_datetime):
472485
config.mesh_client.retrieve_message.return_value = mesh_message
473486
sqs_record = create_sqs_record()
474487

475-
processor.process_sqs_message(sqs_record)
488+
outcome = processor.process_sqs_message(sqs_record)
476489

490+
assert outcome == 'downloaded'
477491
event_publisher.send_events.assert_called_once()
478492
published_events = event_publisher.send_events.call_args[0][0]
479493
assert len(published_events) == 1
480494
message_uri = published_events[0]['data']['messageUri']
481495
assert message_uri.startswith('s3://test-pii-bucket/')
496+
497+
def test_duplicate_delivery_skips_publish_and_acknowledge(self):
498+
"""When S3 signals the object already exists, processor logs a warning, skips publishing and metric, but still acknowledges"""
499+
from mesh_download.processor import MeshDownloadProcessor
500+
501+
config, log, event_publisher, document_store = setup_mocks()
502+
bound_logger = Mock()
503+
log.bind.return_value = bound_logger
504+
505+
document_store.store_document.side_effect = DocumentAlreadyExistsError(
506+
"Document already exists for key: document-reference/TEST-SENDER/ref-001_mesh-123"
507+
)
508+
509+
processor = MeshDownloadProcessor(
510+
config=config,
511+
log=log,
512+
mesh_client=config.mesh_client,
513+
download_metric=config.download_metric,
514+
duplicate_download_metric=config.duplicate_download_metric,
515+
document_store=document_store,
516+
event_publisher=event_publisher
517+
)
518+
519+
mesh_message = create_mesh_message()
520+
config.mesh_client.retrieve_message.return_value = mesh_message
521+
sqs_record = create_sqs_record()
522+
523+
# Should complete without raising
524+
outcome = processor.process_sqs_message(sqs_record)
525+
526+
assert outcome == 'skipped'
527+
bound_logger.warning.assert_called_once()
528+
warning_msg = bound_logger.warning.call_args[0][0]
529+
assert "already stored" in warning_msg
530+
config.duplicate_download_metric.record.assert_called_once()
531+
532+
event_publisher.send_events.assert_not_called()
533+
config.download_metric.record.assert_not_called()
534+
535+
# Acknowledge should still be called to remove message from MESH inbox
536+
mesh_message.acknowledge.assert_called_once()

0 commit comments

Comments
 (0)