Skip to content

Commit 5e39535

Browse files
committed
CCM-13278: Implement mesh-acknowledge lambda
1 parent 8efe067 commit 5e39535

13 files changed

Lines changed: 1408 additions & 28 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Fixtures for tests"""
2+
from typing import Dict
3+
4+
5+
def create_downloaded_event_dict(event_id: str) -> Dict[str, str | int | Dict[str, str]]:
6+
"""Create a dictionary representing a MESHInboxMessageDownloaded event"""
7+
return {
8+
"id": event_id,
9+
"specversion": "1.0",
10+
"source": (
11+
"/nhs/england/notify/production/primary/"
12+
'data-plane/digitalletters/mesh'
13+
),
14+
"subject": (
15+
'customer/920fca11-596a-4eca-9c47-99f624614658/'
16+
'recipient/769acdd4-6a47-496f-999f-76a6fd2c3959'
17+
),
18+
"type": (
19+
'uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1'
20+
),
21+
"time": '2026-01-08T10:00:00Z',
22+
"recordedtime": '2026-01-08T10:00:00Z',
23+
"severitynumber": 2,
24+
"severitytext": 'INFO',
25+
"traceparent": '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
26+
"datacontenttype": 'application/json',
27+
"dataschema": (
28+
'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/'
29+
'digital-letters-mesh-inbox-message-downloaded-data.schema.json'
30+
),
31+
"datacategory": "non-sensitive",
32+
"dataclassification": "public",
33+
"dataregulation": "GDPR",
34+
"data": {
35+
"meshMessageId": "MSG123456",
36+
"messageUri": f"https://example.com/ttl/resource/{event_id}",
37+
"messageReference": "REF123",
38+
"senderId": "SENDER001",
39+
}
40+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""
2+
Tests for MeshAcknowledger class
3+
"""
4+
import json
5+
from unittest.mock import Mock
6+
import pytest
7+
from mesh_acknowledge.acknowledger import (
8+
MeshAcknowledger,
9+
NOTIFY_ACK_WORKFLOW_ID,
10+
ACK_SUBJECT
11+
)
12+
13+
SENT_MESH_MESSAGE_ID = "MSG123456"
14+
15+
16+
@pytest.fixture(name='mock_mesh_client')
17+
def create_mock_mesh_client():
18+
"""Create a mock MeshClient for testing"""
19+
client = Mock()
20+
client.handshake = Mock()
21+
client.send_message = Mock(return_value=SENT_MESH_MESSAGE_ID)
22+
return client
23+
24+
25+
@pytest.fixture(name='mock_logger')
26+
def create_mock_logger():
27+
"""Create a mock logger for testing"""
28+
logger = Mock()
29+
logger.debug = Mock()
30+
return logger
31+
32+
33+
@pytest.fixture(name='acknowledger')
34+
def create_acknowledger(mock_mesh_client, mock_logger):
35+
"""Create a MeshAcknowledger instance with mocked dependencies"""
36+
return MeshAcknowledger(mock_mesh_client, mock_logger)
37+
38+
39+
class TestMeshAcknowledger:
40+
"""Test suite for MeshAcknowledger class"""
41+
42+
def test_init_performs_handshake(self, mock_mesh_client, mock_logger):
43+
"""Test that __init__ performs a MESH handshake"""
44+
MeshAcknowledger(mock_mesh_client, mock_logger)
45+
46+
mock_mesh_client.handshake.assert_called_once()
47+
48+
def test_acknowledge_message_sends_correct_message(
49+
self, acknowledger, mock_mesh_client
50+
):
51+
"""Test that acknowledge_message sends the correct message via MESH"""
52+
mailbox_id = "MAILBOX001"
53+
message_id = "MSG123456"
54+
message_reference = "REF789"
55+
sender_id = "SENDER001"
56+
57+
expected_body = json.dumps({
58+
"meshMessageId": message_id,
59+
"requestId": f"{sender_id}_{message_reference}"
60+
}).encode()
61+
62+
acknowledger.acknowledge_message(
63+
mailbox_id, message_id, message_reference, sender_id
64+
)
65+
66+
mock_mesh_client.send_message.assert_called_once_with(
67+
mailbox_id,
68+
expected_body,
69+
workflow_id=NOTIFY_ACK_WORKFLOW_ID,
70+
local_id=message_reference,
71+
subject=ACK_SUBJECT
72+
)
73+
74+
def test_acknowledge_message_returns_ack_id(
75+
self, acknowledger, mock_mesh_client
76+
):
77+
"""Test that acknowledge_message returns the acknowledgment ID"""
78+
mailbox_id = "MAILBOX001"
79+
message_id = "MSG123456"
80+
message_reference = "REF789"
81+
sender_id = "SENDER001"
82+
83+
expected_ack_id = "ACK_CUSTOM_ID"
84+
85+
mock_mesh_client.send_message.return_value = expected_ack_id
86+
87+
ack_message_id = acknowledger.acknowledge_message(
88+
mailbox_id, message_id, message_reference, sender_id
89+
)
90+
91+
assert ack_message_id == expected_ack_id
92+
93+
def test_acknowledge_message_raises_error_if_mesh_send_fails(
94+
self, acknowledger, mock_mesh_client
95+
):
96+
"""Test that the MeshAcknowledger raises an error if MESH send_message fails"""
97+
mailbox_id = "MAILBOX001"
98+
message_id = "MSG123"
99+
message_reference = "REF123"
100+
sender_id = "SENDER001"
101+
expected_exception_message = "MESH send failed"
102+
103+
mock_mesh_client.send_message.side_effect = Exception(
104+
expected_exception_message)
105+
106+
with pytest.raises(Exception, match=expected_exception_message):
107+
acknowledger.acknowledge_message(
108+
mailbox_id, message_id, message_reference, sender_id
109+
)
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
"""
2+
Tests for event parsing and publishing in mesh_acknowledge.events
3+
"""
4+
import json
5+
from datetime import datetime, timezone
6+
from typing import Dict
7+
from uuid import uuid4
8+
9+
from unittest.mock import Mock, patch
10+
import pytest
11+
from digital_letters_events import MESHInboxMessageAcknowledged, MESHInboxMessageDownloaded
12+
from mesh_acknowledge.events import (
13+
parse_downloaded_event,
14+
publish_acknowledged_event
15+
)
16+
17+
from .fixtures import create_downloaded_event_dict
18+
19+
20+
@pytest.fixture(name='mock_logger')
21+
def create_mock_logger():
22+
"""Create a mock logger for testing"""
23+
logger = Mock()
24+
logger.info = Mock()
25+
logger.error = Mock()
26+
return logger
27+
28+
29+
@pytest.fixture(name='mock_event_publisher')
30+
def create_mock_event_publisher():
31+
"""Create a mock EventPublisher for testing"""
32+
publisher = Mock()
33+
publisher.send_events = Mock(return_value=[])
34+
return publisher
35+
36+
37+
@pytest.fixture(name='event_id')
38+
def generate_event_id() -> str:
39+
"""Generate a unique event ID"""
40+
return str(uuid4())
41+
42+
43+
@pytest.fixture(name='downloaded_event')
44+
def downloaded_event_fixture(event_id: str) -> MESHInboxMessageDownloaded:
45+
"""Create a MESHInboxMessageDownloaded event"""
46+
return MESHInboxMessageDownloaded(**create_downloaded_event_dict(event_id))
47+
48+
49+
@pytest.fixture(name='valid_sqs_record')
50+
def create_valid_sqs_record(event_id: str) -> Dict[str, str | int]:
51+
"""Create a valid SQS record with MESHInboxMessageDownloaded event"""
52+
return {
53+
'body': json.dumps({
54+
'detail': {
55+
**create_downloaded_event_dict(event_id),
56+
}
57+
})
58+
}
59+
60+
61+
@pytest.fixture(name='invalid_sqs_record')
62+
def create_invalid_sqs_record(event_id: str) -> Dict[str, str]:
63+
"""Create a valid SQS record with MESHInboxMessageDownloaded event"""
64+
return {
65+
'body': json.dumps({
66+
'detail': {
67+
'id': event_id,
68+
'specversion': '1.0',
69+
'source': '/nhs/england/notify/production/primary/data-plane/digitalletters/mesh',
70+
'subject': (
71+
'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/'
72+
'769acdd4-6a47-496f-999f-76a6fd2c3959'
73+
),
74+
'type': 'uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1',
75+
'time': '2026-01-08T10:00:00Z',
76+
'recordedtime': '2026-01-08T10:00:00Z',
77+
'severitynumber': 2,
78+
'traceparent': '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
79+
'datacontenttype': 'application/json',
80+
'dataschema': (
81+
'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/'
82+
'digital-letters-mesh-inbox-message-downloaded-data.schema.json'
83+
),
84+
'data': {
85+
'meshMessageId': 'MSG123456',
86+
'messageUri': f"https://example.com/ttl/resource/{event_id}",
87+
'messageReference': 'REF123',
88+
'senderId': 'SENDER001',
89+
'extraField': 'INVALID' # Invalid extra field
90+
}
91+
}
92+
})
93+
}
94+
95+
96+
class TestParseDownloadedEvent:
97+
"""Test suite for parse_downloaded_event function"""
98+
99+
def test_parse_valid_event(
100+
self, valid_sqs_record: Dict[str, str | int],
101+
downloaded_event: MESHInboxMessageDownloaded,
102+
mock_logger):
103+
"""Test parsing a valid SQS record"""
104+
result = parse_downloaded_event(valid_sqs_record, mock_logger, )
105+
106+
assert result == downloaded_event
107+
108+
def test_parse_event_with_missing_detail(self, mock_logger):
109+
"""Test parsing SQS record with missing 'detail' field"""
110+
sqs_record = {'body': json.dumps({})}
111+
112+
with pytest.raises(ValueError):
113+
parse_downloaded_event(sqs_record, mock_logger)
114+
115+
def test_parse_event_validation_error(
116+
self, invalid_sqs_record: Dict[str, str | int],
117+
mock_logger):
118+
"""Test handling validation errors from Pydantic model"""
119+
with pytest.raises(ValueError, match="Error processing MESHInboxMessageDownloaded event"):
120+
parse_downloaded_event(invalid_sqs_record, mock_logger)
121+
122+
def test_parse_event_json_decode_error(self, mock_logger):
123+
"""Test handling JSON decode errors"""
124+
sqs_record = {'body': 'invalid json'}
125+
126+
with pytest.raises(ValueError, match="Error parsing SQS record"):
127+
parse_downloaded_event(sqs_record, mock_logger)
128+
129+
130+
class TestPublishAcknowledgedEvent:
131+
"""Test suite for publish_acknowledged_event function"""
132+
133+
@patch('mesh_acknowledge.events.uuid4')
134+
@patch('mesh_acknowledge.events.datetime')
135+
def test_publish_success(
136+
self,
137+
mock_datetime,
138+
mock_uuid,
139+
mock_logger,
140+
mock_event_publisher,
141+
downloaded_event: MESHInboxMessageDownloaded
142+
):
143+
"""Test successful event publishing"""
144+
new_event_id = str(uuid4())
145+
mock_uuid.return_value = new_event_id
146+
fixed_time = datetime(2026, 1, 8, 10, 30, 0, tzinfo=timezone.utc)
147+
mock_datetime.now.return_value = fixed_time
148+
149+
mock_event_publisher.send_events.return_value = []
150+
151+
mesh_mailbox_id = 'MAILBOX001'
152+
expected_ack_event = {
153+
**downloaded_event.model_dump(),
154+
'id': new_event_id,
155+
'time': fixed_time.isoformat(),
156+
'recordedtime': fixed_time.isoformat(),
157+
'type': 'uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1',
158+
'dataschema': (
159+
'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/'
160+
'digital-letters-mesh-inbox-message-acknowledged-data.schema.json'
161+
),
162+
'data': {
163+
'messageReference': downloaded_event.data.messageReference,
164+
'senderId': downloaded_event.data.senderId,
165+
'meshMailboxId': mesh_mailbox_id,
166+
}
167+
}
168+
169+
publish_acknowledged_event(
170+
mock_logger,
171+
mock_event_publisher,
172+
downloaded_event,
173+
mesh_mailbox_id
174+
)
175+
176+
# Verify event was sent
177+
mock_event_publisher.send_events.assert_called_once_with(
178+
[expected_ack_event], MESHInboxMessageAcknowledged)
179+
180+
@patch('mesh_acknowledge.events.uuid4')
181+
@patch('mesh_acknowledge.events.datetime')
182+
def test_publish_failure_raises_error(
183+
self,
184+
mock_datetime,
185+
mock_uuid,
186+
mock_logger,
187+
mock_event_publisher,
188+
downloaded_event
189+
):
190+
"""Test that publishing failures raise RuntimeError"""
191+
mock_uuid.return_value = str(uuid4())
192+
fixed_time = datetime(2026, 1, 8, 12, 0, 0, tzinfo=timezone.utc)
193+
mock_datetime.now.return_value = fixed_time
194+
195+
failed_events = [{'error': 'send failed'}]
196+
mock_event_publisher.send_events.return_value = failed_events
197+
198+
with pytest.raises(
199+
RuntimeError, match="Failed to publish MESHInboxMessageAcknowledged event"):
200+
publish_acknowledged_event(
201+
mock_logger,
202+
mock_event_publisher,
203+
downloaded_event,
204+
'MAILBOX001'
205+
)
206+
207+
@patch('mesh_acknowledge.events.uuid4')
208+
@patch('mesh_acknowledge.events.datetime')
209+
def test_publish_error_event_raises_error(
210+
self,
211+
mock_datetime,
212+
mock_uuid,
213+
mock_logger,
214+
mock_event_publisher,
215+
downloaded_event
216+
):
217+
"""Test that if the event publisher raises an error, an error is raised"""
218+
mock_uuid.return_value = str(uuid4())
219+
fixed_time = datetime(2026, 1, 8, 13, 0, 0, tzinfo=timezone.utc)
220+
mock_datetime.now.return_value = fixed_time
221+
222+
mock_event_publisher.send_events.side_effect = Exception("Publisher error")
223+
224+
225+
with pytest.raises(Exception, match="Publisher error"):
226+
publish_acknowledged_event(
227+
mock_logger,
228+
mock_event_publisher,
229+
downloaded_event,
230+
'MAILBOX001'
231+
)

0 commit comments

Comments
 (0)