Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actions/acceptance-tests/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ runs:
run: |
npm ci

- name: "Generate dependencies"
shell: bash
run: |
npm run generate-dependencies

- name: Run test - ${{ inputs.testType }}
shell: bash
run: |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@ resource "aws_cloudwatch_event_rule" "mesh_inbox_message_downloaded" {
"type" : [
"uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1"
],
"dataschemaversion" : [{
"prefix" : "1."
}]
}
})
}
Expand Down
4 changes: 2 additions & 2 deletions lambdas/ttl-create-lambda/package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"dependencies": {
"@aws-sdk/lib-dynamodb": "^3.908.0",
"utils": "^0.0.1",
"zod": "^4.1.12"
"digital-letters-events": "^0.0.1",
"utils": "^0.0.1"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { createHandler } from 'apis/sqs-trigger-lambda';
import type { SQSEvent } from 'aws-lambda';
import { $TtlItemBusEvent, TtlItemBusEvent } from 'utils';
import {
ItemEnqueued,
MESHInboxMessageDownloaded,
} from 'digital-letters-events';
import itemEnqueuedValidator from 'digital-letters-events/ItemEnqueued.js';
import { randomUUID } from 'node:crypto';

jest.mock('node:crypto', () => ({
Expand All @@ -18,35 +22,45 @@ describe('createHandler', () => {
let logger: any;
let handler: any;

const validItem: TtlItemBusEvent = {
detail: {
profileversion: '1.0.0',
profilepublished: '2025-10',
id: '550e8400-e29b-41d4-a716-446655440001',
specversion: '1.0',
source:
'/nhs/england/notify/production/primary/data-plane/digital-letters',
subject:
'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959',
type: 'uk.nhs.notify.digital.letters.sent.v1',
time: '2023-06-20T12:00:00Z',
recordedtime: '2023-06-20T12:00:00.250Z',
severitynumber: 2,
traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
datacontenttype: 'application/json',
dataschema:
'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json',
dataschemaversion: '1.0',
severitytext: 'INFO',
data: {
messageUri: 'https://example.com/ttl/resource',
'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000',
messageReference: 'ref1',
senderId: 'sender1',
},
const messageDownloadedEvent: MESHInboxMessageDownloaded = {
id: '550e8400-e29b-41d4-a716-446655440001',
specversion: '1.0',
source:
'/nhs/england/notify/production/primary/data-plane/digitalletters/mesh',
subject:
'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959',
type: 'uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1',
time: '2023-06-20T12:00:00Z',
recordedtime: '2023-06-20T12:00:00.250Z',
severitynumber: 2,
traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
datacontenttype: 'application/json',
dataschema:
'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json',
severitytext: 'INFO',
data: {
messageUri: 'https://example.com/ttl/resource',
messageReference: 'ref1',
senderId: 'sender1',
},
};

const eventBusEvent = {
detail: messageDownloadedEvent,
};

const itemEnqueuedEvent: ItemEnqueued = {
...messageDownloadedEvent,
id: '550e8400-e29b-41d4-a716-446655440001',
source:
'/nhs/england/notify/production/primary/data-plane/digitalletters/queue',
type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1',
time: '2023-06-20T12:00:00.250Z',
recordedtime: '2023-06-20T12:00:00.250Z',
dataschema:
'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-enqueued-data.schema.json',
};

beforeEach(() => {
createTtl = { send: jest.fn() };
eventPublisher = { sendEvents: jest.fn().mockResolvedValue([]) };
Expand All @@ -55,27 +69,24 @@ describe('createHandler', () => {
});

it('processes a valid SQS event and returns success', async () => {
jest
.spyOn($TtlItemBusEvent, 'safeParse')
.mockReturnValue({ success: true, data: validItem });
createTtl.send.mockResolvedValue('sent');
const event: SQSEvent = {
Records: [{ body: JSON.stringify(validItem), messageId: 'msg1' }],
Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }],
} as any;

const res = await handler(event);

expect(res.batchItemFailures).toEqual([]);
expect(createTtl.send).toHaveBeenCalledWith(validItem.detail);
expect(eventPublisher.sendEvents).toHaveBeenCalledWith([
{
...validItem.detail,
id: '550e8400-e29b-41d4-a716-446655440001',
time: '2023-06-20T12:00:00.250Z',
recordedtime: '2023-06-20T12:00:00.250Z',
type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1',
},
]);
expect(createTtl.send).toHaveBeenCalledWith(messageDownloadedEvent);
expect(eventPublisher.sendEvents).toHaveBeenCalledWith(
[itemEnqueuedEvent],
itemEnqueuedValidator,
);

const publishedEvent = eventPublisher.sendEvents.mock.lastCall?.[0];
expect(publishedEvent).toHaveLength(1);
expect(itemEnqueuedValidator(publishedEvent?.[0])).toBeTruthy();

expect(logger.info).toHaveBeenCalledWith({
description: 'Processed SQS Event.',
failed: 0,
Expand All @@ -85,10 +96,6 @@ describe('createHandler', () => {
});

it('handles parse failure and logs error', async () => {
const zodError = { errors: [] } as any;
jest
.spyOn($TtlItemBusEvent, 'safeParse')
.mockReturnValue({ success: false, error: zodError });
const event: SQSEvent = {
Records: [{ body: '{}', messageId: 'msg2' }],
} as any;
Expand All @@ -110,12 +117,9 @@ describe('createHandler', () => {
});

it('handles createTtl.send failure', async () => {
jest
.spyOn($TtlItemBusEvent, 'safeParse')
.mockReturnValue({ success: true, data: validItem });
createTtl.send.mockResolvedValue('failed');
const event: SQSEvent = {
Records: [{ body: JSON.stringify(validItem), messageId: 'msg3' }],
Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg3' }],
} as any;

const res = await handler(event);
Expand All @@ -130,11 +134,9 @@ describe('createHandler', () => {
});

it('handles thrown error and logs', async () => {
jest.spyOn($TtlItemBusEvent, 'safeParse').mockImplementation(() => {
throw new Error('bad json');
});
createTtl.send.mockRejectedValue(new Error('TTL service error'));
const event: SQSEvent = {
Records: [{ body: '{}', messageId: 'msg4' }],
Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg4' }],
} as any;

const res = await handler(event);
Expand Down Expand Up @@ -181,45 +183,23 @@ describe('createHandler', () => {
});

it('processes multiple successful events and sends them as a batch', async () => {
jest
.spyOn($TtlItemBusEvent, 'safeParse')
.mockReturnValue({ success: true, data: validItem });
createTtl.send.mockResolvedValue('sent');
const sqsEvent: SQSEvent = {
Records: [
{ body: JSON.stringify(validItem), messageId: 'msg1' },
{ body: JSON.stringify(validItem), messageId: 'msg2' },
{ body: JSON.stringify(validItem), messageId: 'msg3' },
{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' },
{ body: JSON.stringify(eventBusEvent), messageId: 'msg2' },
{ body: JSON.stringify(eventBusEvent), messageId: 'msg3' },
],
} as any;

const res = await handler(sqsEvent);

expect(res.batchItemFailures).toEqual([]);
expect(createTtl.send).toHaveBeenCalledTimes(3);
expect(eventPublisher.sendEvents).toHaveBeenCalledWith([
{
...validItem.detail,
id: '550e8400-e29b-41d4-a716-446655440001',
time: '2023-06-20T12:00:00.250Z',
recordedtime: '2023-06-20T12:00:00.250Z',
type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1',
},
{
...validItem.detail,
id: '550e8400-e29b-41d4-a716-446655440001',
time: '2023-06-20T12:00:00.250Z',
recordedtime: '2023-06-20T12:00:00.250Z',
type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1',
},
{
...validItem.detail,
id: '550e8400-e29b-41d4-a716-446655440001',
time: '2023-06-20T12:00:00.250Z',
recordedtime: '2023-06-20T12:00:00.250Z',
type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1',
},
]);
expect(eventPublisher.sendEvents).toHaveBeenCalledWith(
[itemEnqueuedEvent, itemEnqueuedEvent, itemEnqueuedEvent],
itemEnqueuedValidator,
);
expect(logger.info).toHaveBeenCalledWith({
description: 'Processed SQS Event.',
failed: 0,
Expand All @@ -229,39 +209,24 @@ describe('createHandler', () => {
});

it('handles partial event publishing failures and logs warning', async () => {
jest
.spyOn($TtlItemBusEvent, 'safeParse')
.mockReturnValue({ success: true, data: validItem });
createTtl.send.mockResolvedValue('sent');
const failedEvents = [validItem];
const failedEvents = [messageDownloadedEvent];
eventPublisher.sendEvents.mockResolvedValue(failedEvents);

const event: SQSEvent = {
Records: [
{ body: JSON.stringify(validItem), messageId: 'msg1' },
{ body: JSON.stringify(validItem), messageId: 'msg2' },
{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' },
{ body: JSON.stringify(eventBusEvent), messageId: 'msg2' },
],
} as any;

const res = await handler(event);

expect(res.batchItemFailures).toEqual([]);
expect(eventPublisher.sendEvents).toHaveBeenCalledWith([
{
...validItem.detail,
id: '550e8400-e29b-41d4-a716-446655440001',
time: '2023-06-20T12:00:00.250Z',
recordedtime: '2023-06-20T12:00:00.250Z',
type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1',
},
{
...validItem.detail,
id: '550e8400-e29b-41d4-a716-446655440001',
time: '2023-06-20T12:00:00.250Z',
recordedtime: '2023-06-20T12:00:00.250Z',
type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1',
},
]);
expect(eventPublisher.sendEvents).toHaveBeenCalledWith(
[itemEnqueuedEvent, itemEnqueuedEvent],
itemEnqueuedValidator,
);
expect(logger.warn).toHaveBeenCalledWith({
description: 'Some events failed to publish',
failedCount: 1,
Expand All @@ -270,29 +235,21 @@ describe('createHandler', () => {
});

it('handles event publishing exception and logs warning', async () => {
jest
.spyOn($TtlItemBusEvent, 'safeParse')
.mockReturnValue({ success: true, data: validItem });
createTtl.send.mockResolvedValue('sent');
const publishError = new Error('EventBridge error');
eventPublisher.sendEvents.mockRejectedValue(publishError);

const event: SQSEvent = {
Records: [{ body: JSON.stringify(validItem), messageId: 'msg1' }],
Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }],
} as any;

const res = await handler(event);

expect(res.batchItemFailures).toEqual([]);
expect(eventPublisher.sendEvents).toHaveBeenCalledWith([
{
...validItem.detail,
id: '550e8400-e29b-41d4-a716-446655440001',
time: '2023-06-20T12:00:00.250Z',
recordedtime: '2023-06-20T12:00:00.250Z',
type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1',
},
]);
expect(eventPublisher.sendEvents).toHaveBeenCalledWith(
[itemEnqueuedEvent],
itemEnqueuedValidator,
);
expect(logger.warn).toHaveBeenCalledWith({
err: publishError,
description: 'Failed to send events to EventBridge',
Expand All @@ -301,13 +258,10 @@ describe('createHandler', () => {
});

it('does not call eventPublisher when no successful events', async () => {
jest
.spyOn($TtlItemBusEvent, 'safeParse')
.mockReturnValue({ success: true, data: validItem });
createTtl.send.mockResolvedValue('failed');

const event: SQSEvent = {
Records: [{ body: JSON.stringify(validItem), messageId: 'msg1' }],
Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }],
} as any;

const res = await handler(event);
Expand All @@ -323,20 +277,15 @@ describe('createHandler', () => {
});

it('handles mixed success and failure scenarios', async () => {
jest
.spyOn($TtlItemBusEvent, 'safeParse')
.mockReturnValueOnce({ success: true, data: validItem })
.mockReturnValueOnce({ success: false, error: { errors: [] } as any })
.mockReturnValueOnce({ success: true, data: validItem });
createTtl.send
.mockResolvedValueOnce('sent')
.mockResolvedValueOnce('failed');

const event: SQSEvent = {
Records: [
{ body: JSON.stringify(validItem), messageId: 'msg1' },
{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' },
{ body: '{}', messageId: 'msg2' },
{ body: JSON.stringify(validItem), messageId: 'msg3' },
{ body: JSON.stringify(eventBusEvent), messageId: 'msg3' },
],
} as any;

Expand All @@ -346,15 +295,10 @@ describe('createHandler', () => {
{ itemIdentifier: 'msg2' },
{ itemIdentifier: 'msg3' },
]);
expect(eventPublisher.sendEvents).toHaveBeenCalledWith([
{
...validItem.detail,
id: '550e8400-e29b-41d4-a716-446655440001',
time: '2023-06-20T12:00:00.250Z',
recordedtime: '2023-06-20T12:00:00.250Z',
type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1',
},
]);
expect(eventPublisher.sendEvents).toHaveBeenCalledWith(
[itemEnqueuedEvent],
itemEnqueuedValidator,
);
expect(logger.info).toHaveBeenCalledWith({
description: 'Processed SQS Event.',
failed: 2,
Expand Down
Loading