Skip to content

Commit cc20b4c

Browse files
CCM-12858: Core Notifier code migrated from sms nudge
1 parent 6487e77 commit cc20b4c

16 files changed

+848
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { baseJestConfig } from '../../jest.config.base';
2+
3+
const config = baseJestConfig;
4+
5+
export default config;

lambdas/core-notifier/package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"dependencies": {
3+
"aws-lambda": "^1.0.7",
4+
"axios": "1.10.0",
5+
"digital-letters-events": "^0.0.1",
6+
"sender-management": "^0.0.1",
7+
"utils": "^0.0.1"
8+
},
9+
"devDependencies": {
10+
"@tsconfig/node22": "^22.0.2",
11+
"@types/aws-lambda": "^8.10.155",
12+
"@types/jest": "^29.5.14",
13+
"aws-sdk-client-mock": "^4.1.0",
14+
"aws-sdk-client-mock-jest": "^4.1.0",
15+
"jest": "^29.7.0",
16+
"jest-mock-extended": "^3.0.7",
17+
"typescript": "^5.9.3"
18+
},
19+
"name": "nhs-notify-digital-core-notifier",
20+
"private": true,
21+
"scripts": {
22+
"lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --entry-names=[name] --outdir=dist src/index.ts",
23+
"lint": "eslint .",
24+
"lint:fix": "eslint . --fix",
25+
"test:unit": "jest",
26+
"typecheck": "tsc --noEmit"
27+
},
28+
"version": "0.0.1"
29+
}
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import { randomUUID } from 'node:crypto';
2+
import { mock } from 'jest-mock-extended';
3+
import axios, {
4+
type AxiosInstance,
5+
type AxiosResponse,
6+
type AxiosStatic,
7+
} from 'axios';
8+
import {
9+
RetryErrorConditionFn,
10+
conditionalRetry as _retry,
11+
} from 'utils';
12+
import type { Logger } from 'utils';
13+
import { mockRequest1, mockResponse } from '__tests__/constants';
14+
import { IAccessTokenRepository, NotifyClient } from 'app/notify-api-client';
15+
import { RequestAlreadyReceivedError } from 'domain/request-already-received-error';
16+
17+
jest.mock('utils');
18+
jest.mock('node:crypto');
19+
jest.mock('axios', () => {
20+
const original: AxiosStatic = jest.requireActual('axios');
21+
22+
return { ...original, create: jest.fn() };
23+
});
24+
25+
const mockRetry = async <T>(
26+
fn: (attempt: number) => Promise<T>,
27+
isRetryable: RetryErrorConditionFn,
28+
_: unknown,
29+
attempt = 1,
30+
): Promise<T> => {
31+
try {
32+
return await fn(attempt);
33+
} catch (error) {
34+
if (isRetryable(error)) {
35+
return mockRetry(fn, isRetryable, attempt + 1);
36+
}
37+
throw error;
38+
}
39+
};
40+
41+
jest.mocked(_retry).mockImplementation(mockRetry);
42+
43+
beforeEach(() => {
44+
jest.useFakeTimers();
45+
});
46+
47+
afterEach(() => {
48+
jest.useRealTimers();
49+
jest.clearAllMocks();
50+
});
51+
52+
const apimBaseUrl = 'https://api.service.nhs.uk';
53+
54+
function setup() {
55+
const logger = mock<Logger>();
56+
57+
const accessTokenRepository = mock<IAccessTokenRepository>();
58+
accessTokenRepository.getAccessToken.mockResolvedValue('fake-access-token');
59+
60+
const axiosInstance = mock<AxiosInstance>();
61+
(axios.create as jest.Mock).mockReturnValueOnce(axiosInstance);
62+
63+
(randomUUID as jest.Mock).mockReturnValue('not-random-uuid');
64+
65+
const mocks = { accessTokenRepository, axiosInstance, logger };
66+
67+
const client = new NotifyClient(apimBaseUrl, accessTokenRepository, logger);
68+
69+
return { client, mocks };
70+
}
71+
72+
describe('constructor', () => {
73+
it('creates a new axios instance with correct config', () => {
74+
setup();
75+
76+
expect(axios.create).toHaveBeenCalledWith({
77+
baseURL: apimBaseUrl,
78+
});
79+
});
80+
});
81+
82+
describe('Accessibility', () => {
83+
it('returns true when the service is available', async () => {
84+
const { client, mocks } = setup();
85+
86+
mocks.axiosInstance.head.mockResolvedValueOnce({ status: 200 });
87+
88+
const actual = await client.isAccessible();
89+
90+
expect(mocks.axiosInstance.head).toHaveBeenCalledWith('/', {
91+
headers: {
92+
Authorization: 'Bearer fake-access-token',
93+
},
94+
});
95+
96+
expect(actual).toBe(true);
97+
});
98+
99+
it('returns false when the service is unavailable', async () => {
100+
const { client, mocks } = setup();
101+
102+
const error = new Error('Service Unavailable');
103+
mocks.axiosInstance.head.mockRejectedValueOnce(error);
104+
105+
const actual = await client.isAccessible();
106+
107+
expect(mocks.axiosInstance.head).toHaveBeenCalledWith('/', {
108+
headers: {
109+
Authorization: 'Bearer fake-access-token',
110+
},
111+
});
112+
113+
expect(actual).toBe(false);
114+
});
115+
});
116+
117+
describe('sendRequest', () => {
118+
it('successfully sends a request', async () => {
119+
const { client, mocks } = setup();
120+
121+
const response = {
122+
status: 200,
123+
data: mockResponse,
124+
};
125+
126+
mocks.axiosInstance.post.mockResolvedValueOnce(response);
127+
128+
const actual = await client.sendRequest(
129+
mockRequest1,
130+
mockRequest1.data.attributes.messageReference,
131+
);
132+
133+
expect(mocks.accessTokenRepository.getAccessToken).toHaveBeenCalledTimes(1);
134+
expect(mocks.axiosInstance.post).toHaveBeenCalledTimes(1);
135+
expect(mocks.axiosInstance.post).toHaveBeenCalledWith(
136+
'/comms/v1/messages',
137+
{
138+
data: mockRequest1.data,
139+
},
140+
{
141+
headers: {
142+
Authorization: 'Bearer fake-access-token',
143+
'Content-Type': 'application/json',
144+
'X-Correlation-ID': 'request-item-id_request-item-plan-id',
145+
},
146+
},
147+
);
148+
149+
expect(actual).toBe(response.data);
150+
});
151+
152+
it('successfully sends a request without auhtorisation header', async () => {
153+
const { client, mocks } = setup();
154+
155+
mocks.accessTokenRepository.getAccessToken.mockResolvedValue('');
156+
157+
const response = {
158+
status: 200,
159+
data: mockResponse,
160+
};
161+
162+
mocks.axiosInstance.post.mockResolvedValueOnce(response);
163+
164+
const actual = await client.sendRequest(
165+
mockRequest1,
166+
mockRequest1.data.attributes.messageReference,
167+
);
168+
169+
expect(mocks.accessTokenRepository.getAccessToken).toHaveBeenCalledTimes(1);
170+
expect(mocks.axiosInstance.post).toHaveBeenCalledTimes(1);
171+
expect(mocks.axiosInstance.post).toHaveBeenCalledWith(
172+
'/comms/v1/messages',
173+
{
174+
data: mockRequest1.data,
175+
},
176+
{
177+
headers: {
178+
'Content-Type': 'application/json',
179+
'X-Correlation-ID': 'request-item-id_request-item-plan-id',
180+
},
181+
},
182+
);
183+
184+
expect(actual).toBe(response.data);
185+
});
186+
187+
it('retries on 429 status code errors and re-fetches access token each time', async () => {
188+
const { client, mocks } = setup();
189+
190+
const error = {
191+
isAxiosError: true,
192+
response: { status: 429 },
193+
};
194+
195+
const response = mock<AxiosResponse>({
196+
status: 200,
197+
data: { type: 'Message' },
198+
});
199+
200+
mocks.axiosInstance.post
201+
.mockRejectedValueOnce(error)
202+
.mockResolvedValueOnce(response);
203+
204+
await client.sendRequest(
205+
mockRequest1,
206+
mockRequest1.data.attributes.messageReference,
207+
);
208+
209+
expect(mocks.accessTokenRepository.getAccessToken).toHaveBeenCalledTimes(2);
210+
expect(mocks.axiosInstance.post).toHaveBeenCalledTimes(2);
211+
});
212+
213+
it.each([400, 401, 403, 404])(
214+
'rejects %d status code errors immediately',
215+
async (status) => {
216+
const { client, mocks } = setup();
217+
218+
const error = {
219+
isAxiosError: true,
220+
response: { status },
221+
};
222+
223+
mocks.axiosInstance.post.mockRejectedValue(error);
224+
225+
await expect(
226+
client.sendRequest(
227+
mockRequest1,
228+
mockRequest1.data.attributes.messageReference,
229+
),
230+
).rejects.toEqual(error);
231+
},
232+
);
233+
234+
it('throws the appropriate error when a 422 status is returned', async () => {
235+
const { client, mocks } = setup();
236+
237+
const error = {
238+
isAxiosError: true,
239+
response: { status: 422 },
240+
};
241+
242+
mocks.axiosInstance.post.mockRejectedValue(error);
243+
244+
await expect(
245+
client.sendRequest(
246+
mockRequest1,
247+
mockRequest1.data.attributes.messageReference,
248+
),
249+
).rejects.toBeInstanceOf(RequestAlreadyReceivedError);
250+
});
251+
252+
it('rejects non-axios errors immediately', async () => {
253+
const { client, mocks } = setup();
254+
255+
const error = new Error('wahh');
256+
257+
mocks.axiosInstance.post.mockRejectedValue(error);
258+
259+
await expect(
260+
client.sendRequest(
261+
mockRequest1,
262+
mockRequest1.data.attributes.messageReference,
263+
),
264+
).rejects.toEqual(error);
265+
});
266+
267+
it('rejects if unable to get the access token', async () => {
268+
const { client, mocks } = setup();
269+
270+
const error = new Error('wahh');
271+
272+
mocks.accessTokenRepository.getAccessToken.mockRejectedValue(error);
273+
274+
await expect(
275+
client.sendRequest(
276+
mockRequest1,
277+
mockRequest1.data.attributes.messageReference,
278+
),
279+
).rejects.toEqual(error);
280+
});
281+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { mock } from 'jest-mock-extended';
2+
import { logger } from 'utils';
3+
import { NotifyMessageProcessor } from 'app/notify-message-processor';
4+
import { mockRequest1, mockResponse } from '__tests__/constants';
5+
import { NotifyClient } from 'app/notify-api-client';
6+
import { RequestAlreadyReceivedError } from 'domain/request-already-received-error';
7+
8+
jest.mock('utils');
9+
10+
const mockClient = mock<NotifyClient>();
11+
12+
const mockLogger = jest.mocked(logger);
13+
14+
const notifyMessageProcessor = new NotifyMessageProcessor({
15+
nhsNotifyClient: mockClient,
16+
logger: mockLogger,
17+
});
18+
19+
describe('NotifyMessageProcessor', () => {
20+
beforeEach(() => {
21+
jest.clearAllMocks();
22+
});
23+
24+
it('completes when the API client succeeds', async () => {
25+
mockClient.sendRequest.mockResolvedValueOnce(mockResponse);
26+
27+
expect(await notifyMessageProcessor.process(mockRequest1)).toBeUndefined();
28+
29+
expect(mockClient.sendRequest).toHaveBeenCalledTimes(1);
30+
expect(mockClient.sendRequest).toHaveBeenCalledWith(
31+
mockRequest1,
32+
mockRequest1.data.attributes.messageReference,
33+
);
34+
35+
expect(mockLogger.info).toHaveBeenCalledWith('Processing request', {
36+
messageReference: mockRequest1.data.attributes.messageReference,
37+
});
38+
39+
expect(mockLogger.info).toHaveBeenCalledWith(
40+
'Successfully processed request',
41+
{
42+
messageReference: mockRequest1.data.attributes.messageReference,
43+
messageItemId: mockResponse.data.id,
44+
},
45+
);
46+
});
47+
48+
it('re-throws when the API client fails', async () => {
49+
const errorMessage = 'API failure';
50+
const err = new Error(errorMessage);
51+
mockClient.sendRequest.mockRejectedValue(err);
52+
53+
await expect(notifyMessageProcessor.process(mockRequest1)).rejects.toThrow(
54+
err,
55+
);
56+
57+
expect(mockLogger.error).toHaveBeenCalledWith('Failed processing request', {
58+
messageReference: mockRequest1.data.attributes.messageReference,
59+
error: errorMessage,
60+
});
61+
});
62+
63+
it('does not re-throw when a RequestAlreadyReceivedError is thrown by the API client', async () => {
64+
const { messageReference } = mockRequest1.data.attributes;
65+
const err = new RequestAlreadyReceivedError(
66+
new Error('Request was already received!'),
67+
messageReference,
68+
);
69+
mockClient.sendRequest.mockRejectedValue(err);
70+
71+
expect(await notifyMessageProcessor.process(mockRequest1)).toBeUndefined();
72+
73+
expect(mockLogger.info).toHaveBeenCalledWith(
74+
'Request has already been received by Notify',
75+
{
76+
messageReference,
77+
},
78+
);
79+
});
80+
});

0 commit comments

Comments
 (0)