Skip to content

Commit cd45cd8

Browse files
committed
CCM-12745: initial send event code
1 parent 58e5198 commit cd45cd8

File tree

11 files changed

+1002
-54
lines changed

11 files changed

+1002
-54
lines changed

.tool-versions

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ gitleaks 8.24.0
33
jq 1.6
44
nodejs 22.11.0
55
pre-commit 3.6.0
6+
python 3.13.2
67
terraform 1.10.1
78
terraform-docs 0.19.0
89
trivy 0.61.0

lambdas/ttl-create-lambda/src/infra/types.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,4 @@
1-
export type CloudEvent = {
2-
id: string;
3-
source: string;
4-
specversion: string;
5-
type: string;
6-
plane: string;
7-
subject: string;
8-
time: string; // ISO 8601 datetime string
9-
datacontenttype: string;
10-
dataschema: string;
11-
dataschemaversion: string;
12-
};
1+
import { CloudEvent } from 'utils';
132

143
export type TtlItemEvent = CloudEvent & {
154
data: {

package-lock.json

Lines changed: 466 additions & 41 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

utils/utils/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"dependencies": {
33
"@aws-sdk/client-dynamodb": "^3.914.0",
4+
"@aws-sdk/client-eventbridge": "^3.918.0",
45
"@aws-sdk/client-lambda": "^3.914.0",
56
"@aws-sdk/client-s3": "^3.914.0",
67
"@aws-sdk/client-sqs": "^3.914.0",
@@ -10,7 +11,7 @@
1011
"async-mutex": "^0.4.0",
1112
"date-fns": "^4.1.0",
1213
"winston": "^3.17.0",
13-
"zod": "^3.24.2"
14+
"zod": "^4.1.12"
1415
},
1516
"devDependencies": {
1617
"@aws-sdk/types": "^3.914.0",
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import { SQSClient, SendMessageBatchCommand } from '@aws-sdk/client-sqs';
2+
import {
3+
EventBridgeClient,
4+
PutEventsCommand,
5+
} from '@aws-sdk/client-eventbridge';
6+
import { mockClient } from 'aws-sdk-client-mock';
7+
import { CloudEvent } from 'types';
8+
9+
import { EventPublishConfig, sendEvents } from 'event-publish';
10+
11+
process.env.EVENT_BUS_ARN =
12+
'arn:aws:events:us-east-1:123456789012:event-bus/test-bus';
13+
process.env.DLQ_URL =
14+
'https://sqs.us-east-1.amazonaws.com/123456789012/test-dlq';
15+
16+
const eventBridgeMock = mockClient(EventBridgeClient);
17+
const sqsMock = mockClient(SQSClient);
18+
19+
const testConfig: EventPublishConfig = {
20+
eventBusArn: 'arn:aws:events:us-east-1:123456789012:event-bus/test-bus',
21+
dlqUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/test-dlq',
22+
};
23+
24+
const validCloudEvent: CloudEvent = {
25+
id: '1',
26+
source: 'test-source',
27+
specversion: '1.0',
28+
type: 'test-type',
29+
plane: 'control',
30+
subject: 'test-subject',
31+
time: '2023-01-01T00:00:00Z',
32+
datacontenttype: 'application/json',
33+
dataschema: 'https://example.com/schema',
34+
dataschemaversion: '1.0',
35+
};
36+
37+
const validCloudEvent2: CloudEvent = {
38+
...validCloudEvent,
39+
id: '2',
40+
};
41+
42+
const invalidCloudEvent = {
43+
type: 'data',
44+
id: 'missing-source',
45+
};
46+
47+
const validEvents = [validCloudEvent, validCloudEvent2];
48+
const invalidEvents = [invalidCloudEvent as unknown as CloudEvent];
49+
const mixedEvents = [
50+
validCloudEvent,
51+
invalidCloudEvent as unknown as CloudEvent,
52+
];
53+
54+
describe('Event Publishing', () => {
55+
beforeEach(() => {
56+
eventBridgeMock.reset();
57+
sqsMock.reset();
58+
});
59+
60+
test('should return empty array when no events provided', async () => {
61+
const result = await sendEvents([], testConfig);
62+
63+
expect(result).toEqual([]);
64+
expect(eventBridgeMock.calls()).toHaveLength(0);
65+
expect(sqsMock.calls()).toHaveLength(0);
66+
});
67+
68+
test('should send valid events to EventBridge', async () => {
69+
eventBridgeMock.on(PutEventsCommand).resolves({
70+
FailedEntryCount: 0,
71+
Entries: [{ EventId: 'event-1' }],
72+
});
73+
74+
const result = await sendEvents(validEvents, testConfig);
75+
76+
expect(result).toEqual([]);
77+
expect(eventBridgeMock.calls()).toHaveLength(1);
78+
expect(sqsMock.calls()).toHaveLength(0);
79+
80+
const eventBridgeCall = eventBridgeMock.calls()[0];
81+
expect(eventBridgeCall.args[0].input).toEqual({
82+
Entries: [
83+
{
84+
Source: 'custom.event',
85+
DetailType: 'test-type',
86+
Detail: JSON.stringify(validCloudEvent),
87+
EventBusName:
88+
'arn:aws:events:us-east-1:123456789012:event-bus/test-bus',
89+
},
90+
{
91+
Source: 'custom.event',
92+
DetailType: 'test-type',
93+
Detail: JSON.stringify(validCloudEvent2),
94+
EventBusName:
95+
'arn:aws:events:us-east-1:123456789012:event-bus/test-bus',
96+
},
97+
],
98+
});
99+
});
100+
101+
test('should send invalid events directly to DLQ', async () => {
102+
sqsMock.on(SendMessageBatchCommand).resolves({
103+
Successful: [
104+
{ Id: 'msg-1', MessageId: 'success-1', MD5OfMessageBody: 'hash1' },
105+
],
106+
});
107+
108+
const result = await sendEvents(invalidEvents, testConfig);
109+
110+
expect(result).toEqual([]);
111+
expect(eventBridgeMock.calls()).toHaveLength(0);
112+
expect(sqsMock.calls()).toHaveLength(1);
113+
114+
const sqsCall = sqsMock.calls()[0];
115+
const sqsInput = sqsCall.args[0].input as any;
116+
expect(sqsInput.QueueUrl).toBe(
117+
'https://sqs.us-east-1.amazonaws.com/123456789012/test-dlq',
118+
);
119+
expect(sqsInput.Entries).toHaveLength(1);
120+
expect(sqsInput.Entries[0].MessageBody).toBe(
121+
JSON.stringify(invalidCloudEvent),
122+
);
123+
expect(sqsInput.Entries[0].Id).toBeDefined();
124+
});
125+
126+
test('should handle mixed valid and invalid events', async () => {
127+
eventBridgeMock.on(PutEventsCommand).resolves({
128+
FailedEntryCount: 0,
129+
Entries: [{ EventId: 'event-1' }],
130+
});
131+
sqsMock.on(SendMessageBatchCommand).resolves({
132+
Successful: [
133+
{ Id: 'msg-1', MessageId: 'success-1', MD5OfMessageBody: 'hash1' },
134+
],
135+
});
136+
137+
const result = await sendEvents(mixedEvents, testConfig);
138+
139+
expect(result).toEqual([]);
140+
expect(eventBridgeMock.calls()).toHaveLength(1);
141+
expect(sqsMock.calls()).toHaveLength(1);
142+
143+
// Verify EventBridge only gets valid events
144+
const eventBridgeCall = eventBridgeMock.calls()[0];
145+
expect(eventBridgeCall.args[0].input).toEqual({
146+
Entries: [
147+
{
148+
Source: 'custom.event',
149+
DetailType: 'test-type',
150+
Detail: JSON.stringify(validCloudEvent),
151+
EventBusName:
152+
'arn:aws:events:us-east-1:123456789012:event-bus/test-bus',
153+
},
154+
],
155+
});
156+
157+
// Verify DLQ only gets invalid events
158+
const sqsCall = sqsMock.calls()[0];
159+
const sqsInput = sqsCall.args[0].input as any;
160+
expect(sqsInput.Entries).toHaveLength(1);
161+
expect(sqsInput.Entries[0].MessageBody).toBe(
162+
JSON.stringify(invalidCloudEvent),
163+
);
164+
});
165+
166+
test('should send failed EventBridge events to DLQ', async () => {
167+
eventBridgeMock.on(PutEventsCommand).resolves({
168+
FailedEntryCount: 1,
169+
Entries: [
170+
{ ErrorCode: 'InternalFailure', ErrorMessage: 'Internal error' },
171+
{ EventId: 'event-2' },
172+
],
173+
});
174+
sqsMock.on(SendMessageBatchCommand).resolves({
175+
Successful: [
176+
{ Id: 'msg-1', MessageId: 'success-1', MD5OfMessageBody: 'hash1' },
177+
],
178+
});
179+
180+
const result = await sendEvents(validEvents, testConfig);
181+
182+
expect(result).toEqual([]);
183+
expect(eventBridgeMock.calls()).toHaveLength(1);
184+
expect(sqsMock.calls()).toHaveLength(1);
185+
186+
// Verify EventBridge was called with both events
187+
const eventBridgeCall = eventBridgeMock.calls()[0];
188+
const eventBridgeInput = eventBridgeCall.args[0].input as any;
189+
expect(eventBridgeInput.Entries).toHaveLength(2);
190+
191+
// Verify DLQ gets the failed event (first one)
192+
const sqsCall = sqsMock.calls()[0];
193+
const sqsInput = sqsCall.args[0].input as any;
194+
expect(sqsInput.Entries).toHaveLength(1);
195+
expect(sqsInput.Entries[0].MessageBody).toBe(
196+
JSON.stringify(validCloudEvent),
197+
);
198+
});
199+
200+
test('should handle EventBridge send error and send all events to DLQ', async () => {
201+
eventBridgeMock
202+
.on(PutEventsCommand)
203+
.rejects(new Error('EventBridge error'));
204+
sqsMock.on(SendMessageBatchCommand).resolves({
205+
Successful: [
206+
{ Id: 'msg-1', MessageId: 'success-1', MD5OfMessageBody: 'hash1' },
207+
],
208+
});
209+
210+
const result = await sendEvents(validEvents, testConfig);
211+
212+
expect(result).toEqual([]);
213+
expect(eventBridgeMock.calls()).toHaveLength(1);
214+
expect(sqsMock.calls()).toHaveLength(1);
215+
});
216+
217+
test('should return failed events when DLQ also fails', async () => {
218+
sqsMock.on(SendMessageBatchCommand).callsFake((params) => {
219+
const firstEntryId = params.Entries[0].Id;
220+
return Promise.resolve({
221+
Failed: [
222+
{
223+
Id: firstEntryId,
224+
Code: 'SenderFault',
225+
Message: 'Invalid message',
226+
SenderFault: true,
227+
},
228+
],
229+
});
230+
});
231+
232+
const result = await sendEvents(invalidEvents, testConfig);
233+
234+
expect(result).toHaveLength(1);
235+
expect(eventBridgeMock.calls()).toHaveLength(0);
236+
expect(sqsMock.calls()).toHaveLength(1);
237+
});
238+
239+
test('should handle DLQ send error and return all events as failed', async () => {
240+
sqsMock.on(SendMessageBatchCommand).rejects(new Error('DLQ error'));
241+
242+
const result = await sendEvents(invalidEvents, testConfig);
243+
244+
expect(result).toEqual(invalidEvents);
245+
expect(eventBridgeMock.calls()).toHaveLength(0);
246+
expect(sqsMock.calls()).toHaveLength(1);
247+
});
248+
249+
test('should process multiple batches for large event arrays', async () => {
250+
const largeEventArray = Array.from({ length: 25 })
251+
.fill(null)
252+
.map((_, i) => ({
253+
...validCloudEvent,
254+
id: `event-${i}`,
255+
}));
256+
257+
eventBridgeMock.on(PutEventsCommand).resolves({
258+
FailedEntryCount: 0,
259+
Entries: [{ EventId: 'success' }],
260+
});
261+
262+
const result = await sendEvents(largeEventArray, testConfig);
263+
264+
expect(result).toEqual([]);
265+
expect(eventBridgeMock.calls()).toHaveLength(3);
266+
267+
// Verify batch sizes: 10, 10, 5
268+
const calls = eventBridgeMock.calls();
269+
const firstBatchInput = calls[0].args[0].input as any;
270+
const secondBatchInput = calls[1].args[0].input as any;
271+
const thirdBatchInput = calls[2].args[0].input as any;
272+
273+
expect(firstBatchInput.Entries).toHaveLength(10);
274+
expect(secondBatchInput.Entries).toHaveLength(10);
275+
expect(thirdBatchInput.Entries).toHaveLength(5);
276+
277+
// Verify all use the same EventBusName
278+
expect(firstBatchInput.Entries[0].EventBusName).toBe(
279+
'arn:aws:events:us-east-1:123456789012:event-bus/test-bus',
280+
);
281+
expect(secondBatchInput.Entries[0].EventBusName).toBe(
282+
'arn:aws:events:us-east-1:123456789012:event-bus/test-bus',
283+
);
284+
expect(thirdBatchInput.Entries[0].EventBusName).toBe(
285+
'arn:aws:events:us-east-1:123456789012:event-bus/test-bus',
286+
);
287+
});
288+
289+
test('should preserve CloudEvent structure in EventBridge Detail field', async () => {
290+
eventBridgeMock.on(PutEventsCommand).resolves({
291+
FailedEntryCount: 0,
292+
Entries: [{ EventId: 'event-1' }],
293+
});
294+
295+
const customEvent: CloudEvent = {
296+
...validCloudEvent,
297+
id: 'custom-event-123',
298+
source: 'nhs.notify.digital-letters',
299+
type: 'letter.created',
300+
subject: 'Patient/12345',
301+
};
302+
303+
await sendEvents([customEvent], testConfig);
304+
305+
const eventBridgeCall = eventBridgeMock.calls()[0];
306+
const entry = (eventBridgeCall.args[0].input as any).Entries[0];
307+
308+
// Verify the CloudEvent is preserved as-is in the Detail field
309+
expect(entry.Detail).toBe(JSON.stringify(customEvent));
310+
311+
// Verify EventBridge-specific fields are correctly mapped
312+
expect(entry.Source).toBe('custom.event');
313+
expect(entry.DetailType).toBe('letter.created');
314+
expect(entry.EventBusName).toBe(
315+
'arn:aws:events:us-east-1:123456789012:event-bus/test-bus',
316+
);
317+
318+
// Verify the original CloudEvent structure is intact in Detail
319+
const detailObject = JSON.parse(entry.Detail);
320+
expect(detailObject.id).toBe('custom-event-123');
321+
expect(detailObject.source).toBe('nhs.notify.digital-letters');
322+
expect(detailObject.type).toBe('letter.created');
323+
expect(detailObject.subject).toBe('Patient/12345');
324+
expect(detailObject.specversion).toBe('1.0');
325+
});
326+
});

0 commit comments

Comments
 (0)