Skip to content

Commit bf0685a

Browse files
committed
CCM-12614: add some basic event handling
1 parent 7f417cf commit bf0685a

File tree

11 files changed

+511
-112
lines changed

11 files changed

+511
-112
lines changed

infrastructure/terraform/components/dl/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ No requirements.
2828
| <a name="input_parent_acct_environment"></a> [parent\_acct\_environment](#input\_parent\_acct\_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no |
2929
| <a name="input_project"></a> [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes |
3030
| <a name="input_queue_batch_size"></a> [queue\_batch\_size](#input\_queue\_batch\_size) | maximum number of queue items to process | `number` | `10` | no |
31-
| <a name="input_queue_batch_window_seconds"></a> [queue\_batch\_window\_seconds](#input\_queue\_batch\_window\_seconds) | maximum time in seconds between processing events | `number` | `10` | no |
31+
| <a name="input_queue_batch_window_seconds"></a> [queue\_batch\_window\_seconds](#input\_queue\_batch\_window\_seconds) | maximum time in seconds between processing events | `number` | `1` | no |
3232
| <a name="input_region"></a> [region](#input\_region) | The AWS Region | `string` | n/a | yes |
3333
| <a name="input_shared_infra_account_id"></a> [shared\_infra\_account\_id](#input\_shared\_infra\_account\_id) | The AWS Shared Infra Account ID (numeric) | `string` | n/a | yes |
3434
| <a name="input_ttl_poll_schedule"></a> [ttl\_poll\_schedule](#input\_ttl\_poll\_schedule) | Schedule to poll for any overdue TTL records | `string` | `"rate(10 minutes)"` | no |
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
resource "aws_cloudwatch_event_rule" "pdm_resource_unavailable" {
2+
name = "${local.csi}-pdm-resource-unavailable"
3+
description = "PDM resource unavailable event rule"
4+
event_bus_name = aws_cloudwatch_event_bus.main.name
5+
6+
event_pattern = jsonencode({
7+
"detail" : {
8+
"type" : [
9+
"uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1"
10+
]
11+
}
12+
})
13+
}
14+
15+
resource "aws_cloudwatch_event_target" "pdm_resource_unavailable" {
16+
rule = aws_cloudwatch_event_rule.pdm_resource_unavailable.name
17+
arn = module.sqs_pdm_poll.sqs_queue_arn
18+
target_id = "pdm-resource-unavailable-target"
19+
event_bus_name = aws_cloudwatch_event_bus.main.name
20+
}

infrastructure/terraform/components/dl/variables.tf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ variable "queue_batch_size" {
101101
variable "queue_batch_window_seconds" {
102102
type = number
103103
description = "maximum time in seconds between processing events"
104-
default = 10
104+
default = 1
105105
}
106106

107107
variable "enable_dynamodb_delete_protection" {
Lines changed: 176 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,30 @@
1-
import { createHandler } from 'apis/sqs-handler';
2-
import { Logger } from 'utils';
3-
import { SQSEvent, SQSRecord } from 'aws-lambda';
41
import { mock } from 'jest-mock-extended';
2+
import { randomUUID } from 'node:crypto';
3+
import { createHandler } from 'apis/sqs-handler';
4+
import { EventPublisher, Logger } from 'utils';
5+
import { Pdm } from 'app/pdm';
6+
import {
7+
pdmResourceSubmittedEvent,
8+
pdmResourceUnavailableEvent,
9+
recordEvent,
10+
} from '__tests__/test-data';
511

612
const logger = mock<Logger>();
13+
const eventPublisher = mock<EventPublisher>();
14+
const pdm = mock<Pdm>();
15+
16+
jest.mock('node:crypto', () => ({
17+
randomUUID: jest.fn(),
18+
}));
719

8-
const event = {
9-
sourceEventId: 'test-event-id',
10-
};
11-
12-
const sqsRecord1: SQSRecord = {
13-
messageId: '1',
14-
receiptHandle: 'abc',
15-
body: JSON.stringify(event),
16-
attributes: {
17-
ApproximateReceiveCount: '1',
18-
SentTimestamp: '2025-07-03T14:23:30Z',
19-
SenderId: 'sender-id',
20-
ApproximateFirstReceiveTimestamp: '2025-07-03T14:23:30Z',
21-
},
22-
messageAttributes: {},
23-
md5OfBody: '',
24-
eventSource: 'aws:sqs',
25-
eventSourceARN: '',
26-
awsRegion: '',
27-
};
28-
29-
const singleRecordEvent: SQSEvent = {
30-
Records: [sqsRecord1],
31-
};
20+
const mockRandomUUID = randomUUID as jest.MockedFunction<typeof randomUUID>;
21+
const mockDate = jest.spyOn(Date.prototype, 'toISOString');
22+
mockRandomUUID.mockReturnValue('550e8400-e29b-41d4-a716-446655440001');
23+
mockDate.mockReturnValue('2023-06-20T12:00:00.250Z');
3224

3325
const handler = createHandler({
26+
eventPublisher,
27+
pdm,
3428
logger,
3529
});
3630

@@ -39,35 +33,169 @@ describe('SQS Handler', () => {
3933
jest.clearAllMocks();
4034
});
4135

42-
it('processes a single record', async () => {
43-
const response = await handler(singleRecordEvent);
36+
describe('pdm.resource.submitted', () => {
37+
it('should send pdm.resource.available event when the document is ready', async () => {
38+
pdm.poll.mockResolvedValueOnce('available');
39+
40+
const response = await handler(recordEvent([pdmResourceSubmittedEvent]));
41+
42+
expect(eventPublisher.sendEvents).toHaveBeenCalledWith([
43+
{
44+
...pdmResourceSubmittedEvent,
45+
id: '550e8400-e29b-41d4-a716-446655440001',
46+
time: '2023-06-20T12:00:00.250Z',
47+
recordedtime: '2023-06-20T12:00:00.250Z',
48+
type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1',
49+
},
50+
]);
51+
expect(logger.info).toHaveBeenCalledWith(
52+
'Received SQS Event of 1 record(s)',
53+
);
54+
expect(logger.info).toHaveBeenCalledWith(
55+
'1 of 1 records processed successfully',
56+
);
57+
expect(response).toEqual({ batchItemFailures: [] });
58+
});
4459

45-
expect(logger.info).toHaveBeenCalledWith(
46-
'Received SQS Event of 1 record(s)',
47-
);
48-
expect(logger.info).toHaveBeenCalledWith(
49-
'1 of 1 records processed successfully',
50-
);
51-
expect(response).toEqual({ batchItemFailures: [] });
60+
it('should send pdm.resource.unavailable event when the document is not ready', async () => {
61+
pdm.poll.mockResolvedValueOnce('unavailable');
62+
63+
const response = await handler(recordEvent([pdmResourceSubmittedEvent]));
64+
65+
expect(eventPublisher.sendEvents).toHaveBeenCalledWith([
66+
{
67+
...pdmResourceSubmittedEvent,
68+
id: '550e8400-e29b-41d4-a716-446655440001',
69+
time: '2023-06-20T12:00:00.250Z',
70+
recordedtime: '2023-06-20T12:00:00.250Z',
71+
type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1',
72+
data: {
73+
...pdmResourceSubmittedEvent.data,
74+
retryCount: 1,
75+
},
76+
},
77+
]);
78+
expect(logger.info).toHaveBeenCalledWith(
79+
'Received SQS Event of 1 record(s)',
80+
);
81+
expect(logger.info).toHaveBeenCalledWith(
82+
'1 of 1 records processed successfully',
83+
);
84+
expect(response).toEqual({ batchItemFailures: [] });
85+
});
5286
});
5387

54-
it('should return failed items to the queue if an error occurs while processing them', async () => {
55-
singleRecordEvent.Records[0].body = 'not-json';
88+
describe('pdm.resource.unavailable', () => {
89+
it('should send pdm.resource.available event when the document is ready', async () => {
90+
pdm.poll.mockResolvedValueOnce('available');
91+
92+
const response = await handler(
93+
recordEvent([pdmResourceUnavailableEvent]),
94+
);
95+
96+
expect(eventPublisher.sendEvents).toHaveBeenCalledWith([
97+
{
98+
...pdmResourceUnavailableEvent,
99+
id: '550e8400-e29b-41d4-a716-446655440001',
100+
time: '2023-06-20T12:00:00.250Z',
101+
recordedtime: '2023-06-20T12:00:00.250Z',
102+
type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1',
103+
},
104+
]);
105+
expect(logger.info).toHaveBeenCalledWith(
106+
'Received SQS Event of 1 record(s)',
107+
);
108+
expect(logger.info).toHaveBeenCalledWith(
109+
'1 of 1 records processed successfully',
110+
);
111+
expect(response).toEqual({ batchItemFailures: [] });
112+
});
113+
114+
it('should send pdm.resource.unavailable event when the document is not ready', async () => {
115+
pdm.poll.mockResolvedValueOnce('unavailable');
56116

57-
const result = await handler(singleRecordEvent);
117+
const response = await handler(
118+
recordEvent([pdmResourceUnavailableEvent]),
119+
);
58120

59-
expect(logger.warn).toHaveBeenCalledWith({
60-
error: `Unexpected token 'o', "not-json" is not valid JSON`,
61-
description: 'Failed processing message',
62-
messageId: '1',
121+
expect(eventPublisher.sendEvents).toHaveBeenCalledWith([
122+
{
123+
...pdmResourceUnavailableEvent,
124+
id: '550e8400-e29b-41d4-a716-446655440001',
125+
time: '2023-06-20T12:00:00.250Z',
126+
recordedtime: '2023-06-20T12:00:00.250Z',
127+
type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1',
128+
data: {
129+
...pdmResourceSubmittedEvent.data,
130+
retryCount: 2,
131+
},
132+
},
133+
]);
134+
expect(logger.info).toHaveBeenCalledWith(
135+
'Received SQS Event of 1 record(s)',
136+
);
137+
expect(logger.info).toHaveBeenCalledWith(
138+
'1 of 1 records processed successfully',
139+
);
140+
expect(response).toEqual({ batchItemFailures: [] });
63141
});
64142

65-
expect(logger.info).toHaveBeenCalledWith(
66-
'0 of 1 records processed successfully',
67-
);
143+
it('should send pdm.resource.retries.exceeded event when the document is not ready after 10 retries', async () => {
144+
pdm.poll.mockResolvedValueOnce('unavailable');
145+
146+
const testEvent = {
147+
...pdmResourceUnavailableEvent,
148+
data: {
149+
...pdmResourceUnavailableEvent.data,
150+
retryCount: 9,
151+
},
152+
};
153+
154+
const response = await handler(recordEvent([testEvent]));
155+
156+
expect(eventPublisher.sendEvents).toHaveBeenCalledWith([
157+
{
158+
...pdmResourceUnavailableEvent,
159+
id: '550e8400-e29b-41d4-a716-446655440001',
160+
time: '2023-06-20T12:00:00.250Z',
161+
recordedtime: '2023-06-20T12:00:00.250Z',
162+
type: 'uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1',
163+
data: {
164+
...pdmResourceSubmittedEvent.data,
165+
retryCount: 10,
166+
},
167+
},
168+
]);
169+
expect(logger.info).toHaveBeenCalledWith(
170+
'Received SQS Event of 1 record(s)',
171+
);
172+
expect(logger.info).toHaveBeenCalledWith(
173+
'1 of 1 records processed successfully',
174+
);
175+
expect(response).toEqual({ batchItemFailures: [] });
176+
});
177+
});
178+
179+
describe('errors', () => {
180+
it('should return failed items to the queue if an error occurs while processing them', async () => {
181+
const event = recordEvent([pdmResourceSubmittedEvent]);
182+
event.Records[0].body = 'not-json';
183+
184+
const result = await handler(event);
185+
186+
expect(logger.warn).toHaveBeenCalledWith({
187+
error: `Unexpected token 'o', "not-json" is not valid JSON`,
188+
description: 'Failed processing message',
189+
messageId: '1',
190+
});
191+
192+
expect(logger.info).toHaveBeenCalledWith(
193+
'0 of 1 records processed successfully',
194+
);
68195

69-
expect(result).toEqual({
70-
batchItemFailures: [{ itemIdentifier: '1' }],
196+
expect(result).toEqual({
197+
batchItemFailures: [{ itemIdentifier: '1' }],
198+
});
71199
});
72200
});
73201
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { mock } from 'jest-mock-extended';
2+
import { Logger } from 'utils';
3+
import { Pdm, PdmDependencies } from 'app/pdm';
4+
import { pdmResourceSubmittedEvent } from '__tests__/test-data';
5+
6+
const logger = mock<Logger>();
7+
const validConfig = (): PdmDependencies => ({
8+
pdmUrl: 'https://example.com/pdm',
9+
logger,
10+
});
11+
12+
describe('Pdm', () => {
13+
describe('constructor', () => {
14+
it('is created when required deps are provided', () => {
15+
const cfg = validConfig();
16+
expect(() => new Pdm(cfg)).not.toThrow();
17+
});
18+
19+
it('throws if pdmUrl is not provided', () => {
20+
const cfg = {
21+
logger,
22+
} as unknown as PdmDependencies;
23+
24+
expect(() => new Pdm(cfg)).toThrow('pdmUrl has not been specified');
25+
});
26+
27+
it('throws if logger is not provided', () => {
28+
const cfg = {
29+
pdmUrl: 'https://example.com/pdm',
30+
} as PdmDependencies;
31+
32+
expect(() => new Pdm(cfg)).toThrow('logger has not been provided');
33+
});
34+
});
35+
36+
describe('poll', () => {
37+
it('returns available when the document is ready', async () => {
38+
const cfg = validConfig();
39+
const pdm = new Pdm(cfg);
40+
41+
const result = await pdm.poll(pdmResourceSubmittedEvent);
42+
43+
expect(result).toBe('available');
44+
});
45+
46+
it('returns unavailable when the document is not ready', async () => {
47+
const cfg = validConfig();
48+
const pdm = new Pdm(cfg);
49+
50+
pdmResourceSubmittedEvent.data.messageReference = 'ref2';
51+
52+
const result = await pdm.poll(pdmResourceSubmittedEvent);
53+
54+
expect(result).toBe('unavailable');
55+
});
56+
57+
it('returns failed and logs error when logger.info throws', async () => {
58+
const cfg = validConfig();
59+
const thrown = new Error('logger failure');
60+
cfg.logger.info = jest.fn(() => {
61+
throw thrown;
62+
});
63+
64+
const pdm = new Pdm(cfg);
65+
66+
await expect(pdm.poll(pdmResourceSubmittedEvent)).rejects.toThrow(thrown);
67+
68+
expect(logger.error).toHaveBeenCalledTimes(1);
69+
expect(logger.error).toHaveBeenCalledWith(
70+
expect.objectContaining({
71+
description: 'Error getting document resource from PDM',
72+
err: thrown,
73+
}),
74+
);
75+
});
76+
});
77+
});

0 commit comments

Comments
 (0)