Skip to content

Commit ee65563

Browse files
CCM-12858: code coverage and updated README
1 parent 3c03538 commit ee65563

File tree

6 files changed

+387
-3
lines changed

6 files changed

+387
-3
lines changed

lambdas/core-notifier-lambda/src/__tests__/apis/sqs-handler.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,49 @@ describe('createHandler', () => {
220220
mockEventPublisherFacade.publishMessageRequestRejected,
221221
).not.toHaveBeenCalled();
222222
});
223+
224+
it('publishes rejected event when error has messageReference property', async () => {
225+
const sqsEvent = createSqsEvent(1);
226+
const handler = createHandler(dependencies);
227+
const messageId = sqsEvent.Records[0].messageId;
228+
const errorCode = 'VALIDATION_ERROR';
229+
const correlationId = 'corr-123';
230+
const error = new RequestNotifyError(
231+
new Error('Validation failed'),
232+
correlationId,
233+
errorCode,
234+
);
235+
// Add messageReference property dynamically to trigger the terminal error path
236+
(error as any).messageReference = messageReference;
237+
238+
mockParseSqsRecord.mockReturnValueOnce(mockPdmEvent);
239+
mockSenderManagement.getSender.mockReturnValueOnce(mockSender);
240+
mockNotifyMessageProcessor.process.mockRejectedValueOnce(error);
241+
242+
const result = await handler(sqsEvent);
243+
244+
// With messageReference property, it's treated as terminal error and not retried
245+
expect(result).toEqual({ batchItemFailures: [] });
246+
expect(mockLogger.warn).toHaveBeenCalledWith({
247+
error: error.message,
248+
description: 'Failed processing message',
249+
messageId,
250+
});
251+
expect(
252+
mockEventPublisherFacade.publishMessageRequestRejected,
253+
).toHaveBeenCalledTimes(1);
254+
expect(
255+
mockEventPublisherFacade.publishMessageRequestRejected,
256+
).toHaveBeenCalledWith(
257+
expect.objectContaining({
258+
data: expect.objectContaining({
259+
senderId,
260+
messageReference,
261+
failureCode: errorCode,
262+
}),
263+
}),
264+
);
265+
});
223266
});
224267

225268
describe('when processing throws a generic error', () => {
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { mock } from 'jest-mock-extended';
2+
import { ParameterStoreCache, logger } from 'utils';
3+
import { NotifyClient } from 'app/notify-api-client';
4+
import { NotifyMessageProcessor } from 'app/notify-message-processor';
5+
import { SenderManagement } from 'sender-management';
6+
import { createContainer } from 'container';
7+
import { loadConfig } from 'infra/config';
8+
9+
jest.mock('utils', () => ({
10+
ParameterStoreCache: jest.fn(),
11+
createGetApimAccessToken: jest.fn(() => jest.fn()),
12+
logger: {
13+
info: jest.fn(),
14+
error: jest.fn(),
15+
warn: jest.fn(),
16+
debug: jest.fn(),
17+
},
18+
}));
19+
20+
jest.mock('app/notify-api-client');
21+
jest.mock('app/notify-message-processor');
22+
jest.mock('sender-management');
23+
jest.mock('infra/config');
24+
25+
describe('createContainer', () => {
26+
const mockParameterStore = mock<ParameterStoreCache>();
27+
const mockConfig = {
28+
eventPublisherEventBusArn:
29+
'arn:aws:events:eu-west-2:123456789012:event-bus/test-bus',
30+
eventPublisherDlqUrl:
31+
'https://sqs.eu-west-2.amazonaws.com/123456789012/test-dlq',
32+
apimAccessTokenSsmParameterName: '/test/apim/access-token',
33+
apimBaseUrl: 'https://api.test.nhs.uk',
34+
environment: 'test',
35+
};
36+
37+
const mockSenderManagement = mock<SenderManagement>();
38+
const mockNotifyClient = mock<NotifyClient>();
39+
const mockNotifyMessageProcessor = mock<NotifyMessageProcessor>();
40+
41+
beforeEach(() => {
42+
jest.clearAllMocks();
43+
44+
(ParameterStoreCache as jest.Mock).mockImplementation(
45+
() => mockParameterStore,
46+
);
47+
(loadConfig as jest.Mock).mockReturnValue(mockConfig);
48+
(SenderManagement as jest.Mock).mockImplementation(
49+
() => mockSenderManagement,
50+
);
51+
(NotifyClient as jest.Mock).mockImplementation(() => mockNotifyClient);
52+
(NotifyMessageProcessor as jest.Mock).mockImplementation(
53+
() => mockNotifyMessageProcessor,
54+
);
55+
});
56+
57+
it('creates and returns a container with all dependencies', async () => {
58+
const container = await createContainer();
59+
60+
expect(container).toEqual({
61+
notifyMessageProcessor: mockNotifyMessageProcessor,
62+
logger,
63+
senderManagement: mockSenderManagement,
64+
});
65+
});
66+
67+
it('initializes ParameterStoreCache', async () => {
68+
await createContainer();
69+
70+
expect(ParameterStoreCache).toHaveBeenCalledTimes(1);
71+
expect(ParameterStoreCache).toHaveBeenCalledWith();
72+
});
73+
74+
it('loads configuration', async () => {
75+
await createContainer();
76+
77+
expect(loadConfig).toHaveBeenCalledTimes(1);
78+
});
79+
80+
it('creates SenderManagement with parameter store', async () => {
81+
await createContainer();
82+
83+
expect(SenderManagement).toHaveBeenCalledTimes(1);
84+
expect(SenderManagement).toHaveBeenCalledWith({
85+
parameterStore: mockParameterStore,
86+
});
87+
});
88+
89+
it('creates NotifyClient with config and dependencies', async () => {
90+
await createContainer();
91+
92+
expect(NotifyClient).toHaveBeenCalledTimes(1);
93+
expect(NotifyClient).toHaveBeenCalledWith(
94+
mockConfig.apimBaseUrl,
95+
expect.objectContaining({
96+
getAccessToken: expect.any(Function),
97+
}),
98+
logger,
99+
);
100+
});
101+
102+
it('creates NotifyMessageProcessor with client and logger', async () => {
103+
await createContainer();
104+
105+
expect(NotifyMessageProcessor).toHaveBeenCalledTimes(1);
106+
expect(NotifyMessageProcessor).toHaveBeenCalledWith({
107+
nhsNotifyClient: mockNotifyClient,
108+
logger,
109+
});
110+
});
111+
112+
it('creates all dependencies in the correct order', async () => {
113+
const callOrder: string[] = [];
114+
115+
(ParameterStoreCache as jest.Mock).mockImplementation(() => {
116+
callOrder.push('ParameterStoreCache');
117+
return mockParameterStore;
118+
});
119+
120+
(loadConfig as jest.Mock).mockImplementation(() => {
121+
callOrder.push('loadConfig');
122+
return mockConfig;
123+
});
124+
125+
(SenderManagement as jest.Mock).mockImplementation(() => {
126+
callOrder.push('SenderManagement');
127+
return mockSenderManagement;
128+
});
129+
130+
(NotifyClient as jest.Mock).mockImplementation(() => {
131+
callOrder.push('NotifyClient');
132+
return mockNotifyClient;
133+
});
134+
135+
(NotifyMessageProcessor as jest.Mock).mockImplementation(() => {
136+
callOrder.push('NotifyMessageProcessor');
137+
return mockNotifyMessageProcessor;
138+
});
139+
140+
await createContainer();
141+
142+
expect(callOrder).toEqual([
143+
'ParameterStoreCache',
144+
'loadConfig',
145+
'SenderManagement',
146+
'NotifyClient',
147+
'NotifyMessageProcessor',
148+
]);
149+
});
150+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import type { SQSEvent, SQSRecord } from 'aws-lambda';
2+
import { handler } from 'index';
3+
import { createContainer } from 'container';
4+
import { createHandler as createSqsHandler } from 'apis/sqs-handler';
5+
import type { SqsHandlerDependencies } from 'apis/sqs-handler';
6+
import { mock } from 'jest-mock-extended';
7+
8+
jest.mock('container');
9+
jest.mock('apis/sqs-handler');
10+
11+
describe('Lambda handler', () => {
12+
const mockContainer = mock<SqsHandlerDependencies>();
13+
const mockSqsHandler = jest.fn();
14+
const mockCreateContainer = jest.mocked(createContainer);
15+
const mockCreateSqsHandler = jest.mocked(createSqsHandler);
16+
17+
const createSqsEvent = (recordCount: number): SQSEvent => ({
18+
Records: Array.from(
19+
{ length: recordCount },
20+
(_, i): SQSRecord => ({
21+
messageId: `message-id-${i + 1}`,
22+
receiptHandle: `receipt-handle-${i + 1}`,
23+
body: JSON.stringify({
24+
detail: {
25+
id: `event-id-${i + 1}`,
26+
source: 'test',
27+
specversion: '1.0',
28+
type: 'test.event',
29+
time: '2025-12-16T10:00:00Z',
30+
datacontenttype: 'application/json',
31+
data: {},
32+
},
33+
}),
34+
attributes: {
35+
ApproximateReceiveCount: '1',
36+
SentTimestamp: '1234567890',
37+
SenderId: 'sender-id',
38+
ApproximateFirstReceiveTimestamp: '1234567890',
39+
},
40+
messageAttributes: {},
41+
md5OfBody: 'md5',
42+
eventSource: 'aws:sqs',
43+
eventSourceARN: 'arn:aws:sqs:region:account:queue',
44+
awsRegion: 'eu-west-2',
45+
}),
46+
),
47+
});
48+
49+
beforeEach(() => {
50+
jest.clearAllMocks();
51+
mockCreateContainer.mockResolvedValue(mockContainer);
52+
mockCreateSqsHandler.mockReturnValue(mockSqsHandler);
53+
mockSqsHandler.mockResolvedValue({ batchItemFailures: [] });
54+
});
55+
56+
it('creates a container', async () => {
57+
const sqsEvent = createSqsEvent(1);
58+
59+
await handler(sqsEvent);
60+
61+
expect(mockCreateContainer).toHaveBeenCalledTimes(1);
62+
});
63+
64+
it('creates an SQS handler with the container dependencies', async () => {
65+
const sqsEvent = createSqsEvent(1);
66+
67+
await handler(sqsEvent);
68+
69+
expect(mockCreateSqsHandler).toHaveBeenCalledTimes(1);
70+
expect(mockCreateSqsHandler).toHaveBeenCalledWith(mockContainer);
71+
});
72+
73+
it('invokes the SQS handler with the event', async () => {
74+
const sqsEvent = createSqsEvent(2);
75+
76+
await handler(sqsEvent);
77+
78+
expect(mockSqsHandler).toHaveBeenCalledTimes(1);
79+
expect(mockSqsHandler).toHaveBeenCalledWith(sqsEvent);
80+
});
81+
82+
it('returns the result from the SQS handler', async () => {
83+
const sqsEvent = createSqsEvent(1);
84+
const expectedResult = {
85+
batchItemFailures: [{ itemIdentifier: 'message-id-1' }],
86+
};
87+
mockSqsHandler.mockResolvedValue(expectedResult);
88+
89+
const result = await handler(sqsEvent);
90+
91+
expect(result).toEqual(expectedResult);
92+
});
93+
94+
it('handles multiple records in the event', async () => {
95+
const sqsEvent = createSqsEvent(5);
96+
97+
await handler(sqsEvent);
98+
99+
expect(mockSqsHandler).toHaveBeenCalledWith(sqsEvent);
100+
expect(sqsEvent.Records).toHaveLength(5);
101+
});
102+
103+
it('propagates errors from createContainer', async () => {
104+
const sqsEvent = createSqsEvent(1);
105+
const error = new Error('Failed to create container');
106+
mockCreateContainer.mockRejectedValue(error);
107+
108+
await expect(handler(sqsEvent)).rejects.toThrow(
109+
'Failed to create container',
110+
);
111+
});
112+
113+
it('propagates errors from the SQS handler', async () => {
114+
const sqsEvent = createSqsEvent(1);
115+
const error = new Error('Handler failed');
116+
mockSqsHandler.mockRejectedValue(error);
117+
118+
await expect(handler(sqsEvent)).rejects.toThrow('Handler failed');
119+
});
120+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { loadConfig, NotifySendMessageConfig } from 'infra/config';
2+
import { defaultConfigReader } from 'utils';
3+
4+
jest.mock('utils', () => ({
5+
defaultConfigReader: {
6+
getValue: jest.fn(),
7+
},
8+
}));
9+
10+
describe('loadConfig', () => {
11+
const mockGetValue = jest.mocked(defaultConfigReader.getValue);
12+
13+
beforeEach(() => {
14+
jest.clearAllMocks();
15+
});
16+
17+
it('loads all configuration values from environment', () => {
18+
const mockConfig = {
19+
eventPublisherEventBusArn:
20+
'arn:aws:events:eu-west-2:123456789012:event-bus/test-bus',
21+
eventPublisherDlqUrl:
22+
'https://sqs.eu-west-2.amazonaws.com/123456789012/test-dlq',
23+
apimAccessTokenSsmParameterName: '/test/apim/access-token',
24+
apimBaseUrl: 'https://api.test.nhs.uk',
25+
environment: 'test',
26+
};
27+
28+
mockGetValue
29+
.mockReturnValueOnce(mockConfig.eventPublisherEventBusArn)
30+
.mockReturnValueOnce(mockConfig.eventPublisherDlqUrl)
31+
.mockReturnValueOnce(mockConfig.apimAccessTokenSsmParameterName)
32+
.mockReturnValueOnce(mockConfig.apimBaseUrl)
33+
.mockReturnValueOnce(mockConfig.environment);
34+
35+
const result = loadConfig();
36+
37+
expect(result).toEqual(mockConfig);
38+
expect(mockGetValue).toHaveBeenCalledTimes(5);
39+
expect(mockGetValue).toHaveBeenNthCalledWith(
40+
1,
41+
'EVENT_PUBLISHER_EVENT_BUS_ARN',
42+
);
43+
expect(mockGetValue).toHaveBeenNthCalledWith(
44+
2,
45+
'EVENT_PUBLISHER_DLQ_URL',
46+
);
47+
expect(mockGetValue).toHaveBeenNthCalledWith(
48+
3,
49+
'APIM_ACCESS_TOKEN_SSM_PARAMETER_NAME',
50+
);
51+
expect(mockGetValue).toHaveBeenNthCalledWith(4, 'APIM_BASE_URL');
52+
expect(mockGetValue).toHaveBeenNthCalledWith(5, 'ENVIRONMENT');
53+
});
54+
55+
it('returns config with correct types', () => {
56+
mockGetValue
57+
.mockReturnValueOnce('arn:test')
58+
.mockReturnValueOnce('https://dlq')
59+
.mockReturnValueOnce('/param')
60+
.mockReturnValueOnce('https://api')
61+
.mockReturnValueOnce('prod');
62+
63+
const result: NotifySendMessageConfig = loadConfig();
64+
65+
expect(typeof result.eventPublisherEventBusArn).toBe('string');
66+
expect(typeof result.eventPublisherDlqUrl).toBe('string');
67+
expect(typeof result.apimAccessTokenSsmParameterName).toBe('string');
68+
expect(typeof result.apimBaseUrl).toBe('string');
69+
expect(typeof result.environment).toBe('string');
70+
});
71+
});

0 commit comments

Comments
 (0)