Skip to content

Commit 3b595db

Browse files
Adding tests
1 parent f114b63 commit 3b595db

File tree

13 files changed

+1153
-54
lines changed

13 files changed

+1153
-54
lines changed
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import type { SQSEvent, SQSRecord } from 'aws-lambda';
2+
import { mock } from 'jest-mock-extended';
3+
import { Logger, Sender } from 'utils';
4+
import { PDMResourceAvailable } from 'digital-letters-events';
5+
import { NotifyMessageProcessor } from 'app/notify-message-processor';
6+
import { SenderManagement } from 'sender-management';
7+
import { EventPublisherFacade } from 'infra/event-publisher-facade';
8+
import { createHandler, SqsHandlerDependencies } from 'apis/sqs-handler';
9+
import { parseSqsRecord } from 'app/parse-sqs-message';
10+
import { InvalidPdmResourceAvailableEvent } from 'domain/invalid-pdm-resource-available-event';
11+
import { RequestNotifyError } from 'domain/request-notify-error';
12+
13+
jest.mock('app/parse-sqs-message');
14+
15+
const mockLogger = mock<Logger>();
16+
const mockNotifyMessageProcessor = mock<NotifyMessageProcessor>();
17+
const mockSenderManagement = mock<SenderManagement>();
18+
const mockEventPublisherFacade = mock<EventPublisherFacade>();
19+
const mockParseSqsRecord = jest.mocked(parseSqsRecord);
20+
21+
describe('createHandler', () => {
22+
const dependencies: SqsHandlerDependencies = {
23+
logger: mockLogger,
24+
notifyMessageProcessor: mockNotifyMessageProcessor,
25+
senderManagement: mockSenderManagement,
26+
eventPublisherFacade: mockEventPublisherFacade,
27+
};
28+
29+
const senderId = 'sender-123';
30+
const routingConfigId = 'routing-config-123';
31+
const messageReference = 'msg-ref-123';
32+
const notifyId = 'notify-id-123';
33+
34+
const mockSender: Sender = {
35+
senderId,
36+
routingConfigId,
37+
displayName: 'Test Sender',
38+
clientId: 'client-123',
39+
campaignId: 'campaign-123',
40+
};
41+
42+
const mockPdmEvent: PDMResourceAvailable = {
43+
id: 'event-id-123',
44+
source: 'urn:nhs:names:services:notify:pdm',
45+
specversion: '1.0',
46+
type: 'uk.nhs.notify.pdm.resource.available',
47+
time: '2025-12-15T10:00:00Z',
48+
datacontenttype: 'application/json',
49+
data: {
50+
senderId,
51+
messageReference,
52+
resourceType: 'letter',
53+
resourceLocation: 's3://bucket/key',
54+
},
55+
};
56+
57+
const createSqsRecord = (messageId: string): SQSRecord => ({
58+
messageId,
59+
receiptHandle: 'receipt-handle',
60+
body: JSON.stringify({
61+
detail: mockPdmEvent,
62+
}),
63+
attributes: {
64+
ApproximateReceiveCount: '1',
65+
SentTimestamp: '1234567890',
66+
SenderId: 'sender-id',
67+
ApproximateFirstReceiveTimestamp: '1234567890',
68+
},
69+
messageAttributes: {},
70+
md5OfBody: 'md5',
71+
eventSource: 'aws:sqs',
72+
eventSourceARN: 'arn:aws:sqs:region:account:queue',
73+
awsRegion: 'eu-west-2',
74+
});
75+
76+
const createSqsEvent = (recordCount: number): SQSEvent => ({
77+
Records: Array.from({ length: recordCount }, (_, i) =>
78+
createSqsRecord(`message-id-${i + 1}`),
79+
),
80+
});
81+
82+
beforeEach(() => {
83+
jest.clearAllMocks();
84+
});
85+
86+
describe('when processing a single successful SQS record', () => {
87+
it('processes the message and returns no batch item failures', async () => {
88+
const sqsEvent = createSqsEvent(1);
89+
const handler = createHandler(dependencies);
90+
91+
mockParseSqsRecord.mockReturnValueOnce(mockPdmEvent);
92+
mockSenderManagement.getSender.mockReturnValueOnce(mockSender);
93+
mockNotifyMessageProcessor.process.mockResolvedValueOnce(notifyId);
94+
95+
const result = await handler(sqsEvent);
96+
97+
expect(result).toEqual({ batchItemFailures: [] });
98+
expect(mockLogger.info).toHaveBeenCalledWith(
99+
'Received SQS Event of 1 record(s)',
100+
);
101+
expect(mockLogger.info).toHaveBeenCalledWith(
102+
'1 of 1 records processed successfully',
103+
);
104+
expect(mockParseSqsRecord).toHaveBeenCalledWith(
105+
sqsEvent.Records[0],
106+
mockLogger,
107+
);
108+
expect(mockSenderManagement.getSender).toHaveBeenCalledWith(senderId);
109+
expect(mockNotifyMessageProcessor.process).toHaveBeenCalledTimes(1);
110+
expect(
111+
mockEventPublisherFacade.publishMessageRequestSubmitted,
112+
).toHaveBeenCalledTimes(1);
113+
});
114+
});
115+
116+
describe('when sender has no routing config', () => {
117+
it('skips the message and publishes a skipped event', async () => {
118+
const sqsEvent = createSqsEvent(1);
119+
const handler = createHandler(dependencies);
120+
const senderWithoutRouting: Sender = {
121+
...mockSender,
122+
routingConfigId: undefined,
123+
};
124+
125+
mockParseSqsRecord.mockReturnValueOnce(mockPdmEvent);
126+
mockSenderManagement.getSender.mockReturnValueOnce(senderWithoutRouting);
127+
128+
const result = await handler(sqsEvent);
129+
130+
expect(result).toEqual({ batchItemFailures: [] });
131+
expect(mockLogger.debug).toHaveBeenCalledWith(
132+
`No routing config for sender ${senderId}, skipping message`,
133+
);
134+
expect(mockNotifyMessageProcessor.process).not.toHaveBeenCalled();
135+
expect(
136+
mockEventPublisherFacade.publishMessageRequestSkipped,
137+
).toHaveBeenCalledTimes(1);
138+
});
139+
});
140+
141+
describe('when processing multiple SQS records', () => {
142+
it('processes all records successfully', async () => {
143+
const sqsEvent = createSqsEvent(3);
144+
const handler = createHandler(dependencies);
145+
146+
mockParseSqsRecord.mockReturnValue(mockPdmEvent);
147+
mockSenderManagement.getSender.mockReturnValue(mockSender);
148+
mockNotifyMessageProcessor.process.mockResolvedValue(notifyId);
149+
150+
const result = await handler(sqsEvent);
151+
152+
expect(result).toEqual({ batchItemFailures: [] });
153+
expect(mockLogger.info).toHaveBeenCalledWith(
154+
'Received SQS Event of 3 record(s)',
155+
);
156+
expect(mockLogger.info).toHaveBeenCalledWith(
157+
'3 of 3 records processed successfully',
158+
);
159+
expect(mockParseSqsRecord).toHaveBeenCalledTimes(3);
160+
expect(mockNotifyMessageProcessor.process).toHaveBeenCalledTimes(3);
161+
});
162+
});
163+
164+
describe('when parseSqsRecord throws InvalidPdmResourceAvailableEvent', () => {
165+
it('marks the message as failed for retry', async () => {
166+
const sqsEvent = createSqsEvent(1);
167+
const handler = createHandler(dependencies);
168+
const messageId = sqsEvent.Records[0].messageId;
169+
170+
mockParseSqsRecord.mockImplementationOnce(() => {
171+
throw new InvalidPdmResourceAvailableEvent(messageId);
172+
});
173+
174+
const result = await handler(sqsEvent);
175+
176+
expect(result).toEqual({
177+
batchItemFailures: [{ itemIdentifier: messageId }],
178+
});
179+
expect(mockLogger.warn).toHaveBeenCalledWith({
180+
error: 'Unable to parse PDMResourceAvailable event from SQS message',
181+
description: 'Failed processing message',
182+
messageId,
183+
});
184+
expect(mockLogger.info).toHaveBeenCalledWith(
185+
'0 of 1 records processed successfully',
186+
);
187+
});
188+
});
189+
190+
describe('when processing throws RequestNotifyError', () => {
191+
it('marks the message as failed for retry since error lacks messageReference', async () => {
192+
const sqsEvent = createSqsEvent(1);
193+
const handler = createHandler(dependencies);
194+
const messageId = sqsEvent.Records[0].messageId;
195+
const errorCode = 'VALIDATION_ERROR';
196+
const correlationId = 'corr-123';
197+
const error = new RequestNotifyError(
198+
new Error('Validation failed'),
199+
correlationId,
200+
errorCode,
201+
);
202+
203+
mockParseSqsRecord.mockReturnValueOnce(mockPdmEvent);
204+
mockSenderManagement.getSender.mockReturnValueOnce(mockSender);
205+
mockNotifyMessageProcessor.process.mockRejectedValueOnce(error);
206+
207+
const result = await handler(sqsEvent);
208+
209+
// Since RequestNotifyError doesn't have messageReference property,
210+
// it falls through to the else branch and is treated as a transient error
211+
expect(result).toEqual({
212+
batchItemFailures: [{ itemIdentifier: messageId }],
213+
});
214+
expect(mockLogger.warn).toHaveBeenCalledWith({
215+
error: error.message,
216+
description: 'Failed processing message',
217+
messageId,
218+
});
219+
expect(
220+
mockEventPublisherFacade.publishMessageRequestRejected,
221+
).not.toHaveBeenCalled();
222+
});
223+
});
224+
225+
describe('when processing throws a generic error', () => {
226+
it('marks the message as failed for retry', async () => {
227+
const sqsEvent = createSqsEvent(1);
228+
const handler = createHandler(dependencies);
229+
const messageId = sqsEvent.Records[0].messageId;
230+
const error = new Error('Unexpected error');
231+
232+
mockParseSqsRecord.mockReturnValueOnce(mockPdmEvent);
233+
mockSenderManagement.getSender.mockReturnValueOnce(mockSender);
234+
mockNotifyMessageProcessor.process.mockRejectedValueOnce(error);
235+
236+
const result = await handler(sqsEvent);
237+
238+
expect(result).toEqual({
239+
batchItemFailures: [{ itemIdentifier: messageId }],
240+
});
241+
expect(mockLogger.warn).toHaveBeenCalledWith({
242+
error: error.message,
243+
description: 'Failed processing message',
244+
messageId,
245+
});
246+
expect(
247+
mockEventPublisherFacade.publishMessageRequestRejected,
248+
).not.toHaveBeenCalled();
249+
});
250+
});
251+
252+
describe('when processing mixed success and failure records', () => {
253+
it('returns only failed message IDs', async () => {
254+
const sqsEvent = createSqsEvent(3);
255+
const handler = createHandler(dependencies);
256+
257+
mockParseSqsRecord
258+
.mockReturnValueOnce(mockPdmEvent)
259+
.mockImplementationOnce(() => {
260+
throw new Error('Parse error');
261+
})
262+
.mockReturnValueOnce(mockPdmEvent);
263+
264+
mockSenderManagement.getSender.mockReturnValue(mockSender);
265+
mockNotifyMessageProcessor.process.mockResolvedValue(notifyId);
266+
267+
const result = await handler(sqsEvent);
268+
269+
expect(result).toEqual({
270+
batchItemFailures: [{ itemIdentifier: 'message-id-2' }],
271+
});
272+
expect(mockLogger.info).toHaveBeenCalledWith(
273+
'2 of 3 records processed successfully',
274+
);
275+
});
276+
});
277+
278+
describe('when notifyMessageProcessor returns undefined', () => {
279+
it('does not publish submitted event', async () => {
280+
const sqsEvent = createSqsEvent(1);
281+
const handler = createHandler(dependencies);
282+
283+
mockParseSqsRecord.mockReturnValueOnce(mockPdmEvent);
284+
mockSenderManagement.getSender.mockReturnValueOnce(mockSender);
285+
mockNotifyMessageProcessor.process.mockResolvedValueOnce(undefined);
286+
287+
const result = await handler(sqsEvent);
288+
289+
expect(result).toEqual({ batchItemFailures: [] });
290+
expect(
291+
mockEventPublisherFacade.publishMessageRequestSubmitted,
292+
).not.toHaveBeenCalled();
293+
});
294+
});
295+
});

lambdas/core-notifier/src/__tests__/app/notify-api-client.test.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { Logger } from 'utils';
1010
import { mockRequest1, mockResponse } from '__tests__/constants';
1111
import { IAccessTokenRepository, NotifyClient } from 'app/notify-api-client';
1212
import { RequestAlreadyReceivedError } from 'domain/request-already-received-error';
13+
import { RequestNotifyError } from 'domain/request-notify-error';
1314

1415
jest.mock('utils');
1516
jest.mock('node:crypto');
@@ -214,7 +215,28 @@ describe('sendRequest', () => {
214215

215216
const error = {
216217
isAxiosError: true,
217-
response: { status },
218+
response: {
219+
status,
220+
data: {
221+
errors: [
222+
{
223+
id: 'rrt-1931948104716186917-c-geu2-10664-3111479-3.0',
224+
code: 'CM_MISSING_ROUTING_PLAN_TEMPLATE',
225+
links: {
226+
about:
227+
'https://digital.nhs.uk/developer/api-catalogue/nhs-notify',
228+
},
229+
status,
230+
title: 'Templates missing',
231+
detail:
232+
'The templates required to use the routing plan were not found.',
233+
source: {
234+
pointer: '/data/attributes/routingPlanId',
235+
},
236+
},
237+
],
238+
},
239+
},
218240
};
219241

220242
mocks.axiosInstance.post.mockRejectedValue(error);
@@ -224,7 +246,10 @@ describe('sendRequest', () => {
224246
mockRequest1,
225247
mockRequest1.data.attributes.messageReference,
226248
),
227-
).rejects.toEqual(error);
249+
).rejects.toMatchObject({
250+
errorCode: 'CM_MISSING_ROUTING_PLAN_TEMPLATE',
251+
correlationId: 'request-item-id_request-item-plan-id',
252+
});
228253
},
229254
);
230255

lambdas/core-notifier/src/__tests__/app/notify-message-processor.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ describe('NotifyMessageProcessor', () => {
2424
it('completes when the API client succeeds', async () => {
2525
mockClient.sendRequest.mockResolvedValueOnce(mockResponse);
2626

27-
expect(await notifyMessageProcessor.process(mockRequest1)).toBeUndefined();
27+
expect(await notifyMessageProcessor.process(mockRequest1)).toEqual(
28+
mockResponse.data.id,
29+
);
2830

2931
expect(mockClient.sendRequest).toHaveBeenCalledTimes(1);
3032
expect(mockClient.sendRequest).toHaveBeenCalledWith(

0 commit comments

Comments
 (0)