From e02b34dfc422c2a3c583e2cc497843ab83c110e2 Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:53:37 +0000 Subject: [PATCH 01/12] CCM-12896: Update ttl-create-lambda to use new types/validators --- lambdas/ttl-create-lambda/package.json | 4 +- .../__tests__/apis/sqs-trigger-lambda.test.ts | 194 ++++++------------ .../src/__tests__/app/create-ttl.test.ts | 12 +- .../__tests__/infra/ttl-repository.test.ts | 12 +- .../src/apis/sqs-trigger-lambda.ts | 34 +-- .../ttl-create-lambda/src/app/create-ttl.ts | 5 +- lambdas/ttl-create-lambda/src/container.ts | 5 +- .../src/infra/ttl-repository.ts | 14 +- package-lock.json | 8 +- .../event-publisher/event-publisher.test.ts | 147 ++++++------- .../src/event-publisher/event-publisher.ts | 48 +++-- 11 files changed, 207 insertions(+), 276 deletions(-) diff --git a/lambdas/ttl-create-lambda/package.json b/lambdas/ttl-create-lambda/package.json index 1f26c58d..d0031751 100644 --- a/lambdas/ttl-create-lambda/package.json +++ b/lambdas/ttl-create-lambda/package.json @@ -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", diff --git a/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts index 939e086f..4ec053e3 100644 --- a/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -1,6 +1,9 @@ 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 { randomUUID } from 'node:crypto'; jest.mock('node:crypto', () => ({ @@ -18,35 +21,43 @@ 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', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', + 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([]) }; @@ -55,27 +66,16 @@ 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]); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 0, @@ -85,10 +85,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; @@ -110,12 +106,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); @@ -130,11 +123,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); @@ -181,15 +172,12 @@ 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; @@ -198,27 +186,9 @@ describe('createHandler', () => { 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', - }, + itemEnqueuedEvent, + itemEnqueuedEvent, + itemEnqueuedEvent, ]); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', @@ -229,17 +199,14 @@ 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; @@ -247,20 +214,8 @@ describe('createHandler', () => { 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', - }, + itemEnqueuedEvent, + itemEnqueuedEvent, ]); expect(logger.warn).toHaveBeenCalledWith({ description: 'Some events failed to publish', @@ -270,29 +225,18 @@ 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]); expect(logger.warn).toHaveBeenCalledWith({ err: publishError, description: 'Failed to send events to EventBridge', @@ -301,13 +245,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); @@ -323,20 +264,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; @@ -346,15 +282,7 @@ 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]); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 2, diff --git a/lambdas/ttl-create-lambda/src/__tests__/app/create-ttl.test.ts b/lambdas/ttl-create-lambda/src/__tests__/app/create-ttl.test.ts index 6f8be6f3..3998ddad 100644 --- a/lambdas/ttl-create-lambda/src/__tests__/app/create-ttl.test.ts +++ b/lambdas/ttl-create-lambda/src/__tests__/app/create-ttl.test.ts @@ -1,31 +1,27 @@ import { CreateTtl } from 'app/create-ttl'; import { TtlRepository } from 'infra/ttl-repository'; -import { TtlItemEvent } from 'utils'; +import { MESHInboxMessageDownloaded } from 'digital-letters-events'; describe('CreateTtl', () => { let repo: jest.Mocked; let logger: any; let createTtl: CreateTtl; - const item: TtlItemEvent = { - profileversion: '1.0.0', - profilepublished: '2025-10', + const item: MESHInboxMessageDownloaded = { 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.queue.item.enqueued.v1', + 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/digital-letter-base-data.schema.json', - dataschemaversion: '1.0', + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json', severitytext: 'INFO', data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', messageReference: 'ref1', senderId: 'sender1', messageUri: 'https://example.com/ttl/resource', diff --git a/lambdas/ttl-create-lambda/src/__tests__/infra/ttl-repository.test.ts b/lambdas/ttl-create-lambda/src/__tests__/infra/ttl-repository.test.ts index 05c7599e..dc80be6d 100644 --- a/lambdas/ttl-create-lambda/src/__tests__/infra/ttl-repository.test.ts +++ b/lambdas/ttl-create-lambda/src/__tests__/infra/ttl-repository.test.ts @@ -1,6 +1,6 @@ import { PutCommand } from '@aws-sdk/lib-dynamodb'; import { TtlRepository } from 'infra/ttl-repository'; -import { TtlItemEvent } from 'utils'; +import { MESHInboxMessageDownloaded } from 'digital-letters-events'; jest.useFakeTimers(); @@ -15,26 +15,22 @@ describe('TtlRepository', () => { let repo: TtlRepository; const tableName = 'table'; const ttlWaitTimeHours = 24; - const item: TtlItemEvent = { - profileversion: '1.0.0', - profilepublished: '2025-10', + const item: MESHInboxMessageDownloaded = { 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.queue.item.enqueued.v1', + 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/digital-letter-base-data.schema.json', - dataschemaversion: '1.0', + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json', severitytext: 'INFO', data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', messageReference: 'ref1', senderId: 'sender1', messageUri: 'https://example.com/ttl/resource', diff --git a/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts b/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts index 4b8124a1..144e5762 100644 --- a/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts +++ b/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts @@ -5,16 +5,21 @@ import type { } from 'aws-lambda'; import { randomUUID } from 'node:crypto'; import type { CreateTtl, CreateTtlOutcome } from 'app/create-ttl'; -import { $TtlItemBusEvent, EventPublisher, Logger, TtlItemEvent } from 'utils'; +import { EventPublisher, Logger } from 'utils'; +import eventValidator from 'digital-letters-events/MESHInboxMessageDownloaded.js'; +import { + ItemEnqueued, + MESHInboxMessageDownloaded, +} from 'digital-letters-events'; interface ProcessingResult { result: CreateTtlOutcome; - item?: TtlItemEvent; + item?: MESHInboxMessageDownloaded; } interface CreateHandlerDependencies { createTtl: CreateTtl; - eventPublisher: EventPublisher; + eventPublisher: EventPublisher; logger: Logger; } @@ -25,34 +30,33 @@ export const createHandler = ({ }: CreateHandlerDependencies) => async function handler(sqsEvent: SQSEvent): Promise { const batchItemFailures: SQSBatchItemFailure[] = []; - const successfulEvents: TtlItemEvent[] = []; const promises = sqsEvent.Records.map( async ({ body, messageId }): Promise => { try { - const { - data: item, - error: parseError, - success: parseSuccess, - } = $TtlItemBusEvent.safeParse(JSON.parse(body)); + const sqsEventBody = JSON.parse(body); + const sqsEventDetail = sqsEventBody.detail; - if (!parseSuccess) { + const isEventValid = eventValidator(sqsEventDetail); + if (!isEventValid) { logger.error({ - err: parseError, + err: eventValidator.errors, description: 'Error parsing ttl queue entry', }); batchItemFailures.push({ itemIdentifier: messageId }); return { result: 'failed' }; } + const messageDownloadedEvent: MESHInboxMessageDownloaded = + sqsEventDetail; - const result = await createTtl.send(item.detail); + const result = await createTtl.send(messageDownloadedEvent); if (result === 'failed') { batchItemFailures.push({ itemIdentifier: messageId }); return { result: 'failed' }; } - return { result, item: item.detail }; + return { result, item: messageDownloadedEvent }; } catch (error) { logger.error({ err: error, @@ -74,6 +78,8 @@ export const createHandler = ({ failed: 0, }; + const successfulEvents: MESHInboxMessageDownloaded[] = []; + for (const result of results) { if (result.status === 'fulfilled') { const { item, result: outcome } = result.value; @@ -97,6 +103,8 @@ export const createHandler = ({ time: new Date().toISOString(), recordedtime: new Date().toISOString(), type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-enqueued-data.schema.json', })), ); if (failedEvents.length > 0) { diff --git a/lambdas/ttl-create-lambda/src/app/create-ttl.ts b/lambdas/ttl-create-lambda/src/app/create-ttl.ts index 08072192..c2d6db38 100644 --- a/lambdas/ttl-create-lambda/src/app/create-ttl.ts +++ b/lambdas/ttl-create-lambda/src/app/create-ttl.ts @@ -1,5 +1,6 @@ -import { Logger, TtlItemEvent } from 'utils'; +import { Logger } from 'utils'; import { TtlRepository } from 'infra/ttl-repository'; +import { MESHInboxMessageDownloaded } from 'digital-letters-events'; export type CreateTtlOutcome = 'sent' | 'failed'; @@ -9,7 +10,7 @@ export class CreateTtl { private readonly logger: Logger, ) {} - async send(item: TtlItemEvent): Promise { + async send(item: MESHInboxMessageDownloaded): Promise { try { await this.ttlDatabaseRepository.insertTtlRecord(item); } catch (error) { diff --git a/lambdas/ttl-create-lambda/src/container.ts b/lambdas/ttl-create-lambda/src/container.ts index 3f5f506a..82a46722 100644 --- a/lambdas/ttl-create-lambda/src/container.ts +++ b/lambdas/ttl-create-lambda/src/container.ts @@ -8,6 +8,8 @@ import { import { loadConfig } from 'infra/config'; import { TtlRepository } from 'infra/ttl-repository'; import { CreateTtl } from 'app/create-ttl'; +import { ItemEnqueued } from 'digital-letters-events'; +import eventValidator from 'digital-letters-events/ItemEnqueued.js'; export const createContainer = () => { const { @@ -28,12 +30,13 @@ export const createContainer = () => { const createTtl = new CreateTtl(requestTtlRepository, logger); - const eventPublisher = new EventPublisher({ + const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, dlqUrl: eventPublisherDlqUrl, logger, sqsClient, eventBridgeClient, + validateEvent: eventValidator, }); return { diff --git a/lambdas/ttl-create-lambda/src/infra/ttl-repository.ts b/lambdas/ttl-create-lambda/src/infra/ttl-repository.ts index 5b49d41f..efcfa327 100644 --- a/lambdas/ttl-create-lambda/src/infra/ttl-repository.ts +++ b/lambdas/ttl-create-lambda/src/infra/ttl-repository.ts @@ -1,5 +1,6 @@ import { PutCommand, PutCommandOutput } from '@aws-sdk/lib-dynamodb'; -import { Logger, TtlItemEvent } from 'utils'; +import { MESHInboxMessageDownloaded } from 'digital-letters-events'; +import { Logger } from 'utils'; interface IDynamoCaller { send: (updateCommand: PutCommand) => Promise; @@ -18,7 +19,7 @@ export class TtlRepository { this.ttlWaitTimeSeconds = ttlWaitTimeHours * 60 * 60; } - public async insertTtlRecord(item: TtlItemEvent) { + public async insertTtlRecord(item: MESHInboxMessageDownloaded) { const ttlTime = Math.round(Date.now() / 1000) + this.ttlWaitTimeSeconds; this.logger.info({ @@ -38,7 +39,10 @@ export class TtlRepository { } } - private async putTtlRecord(ttlItemEvent: TtlItemEvent, ttlTime: number) { + private async putTtlRecord( + event: MESHInboxMessageDownloaded, + ttlTime: number, + ) { // GSI PK utilising write sharding YYYY-MM-DD# const ttlGsiPk = `${ new Date(ttlTime * 1000).toISOString().split('T')[0] @@ -48,11 +52,11 @@ export class TtlRepository { new PutCommand({ TableName: this.tableName, Item: { - PK: ttlItemEvent.data.messageUri, + PK: event.data.messageUri, SK: 'TTL', ttl: ttlTime, dateOfExpiry: ttlGsiPk, - event: ttlItemEvent, + event, }, }), ); diff --git a/package-lock.json b/package-lock.json index d8dc228c..430b04db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -297,8 +297,8 @@ "version": "0.0.1", "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", @@ -17364,7 +17364,9 @@ } }, "node_modules/zod": { - "version": "4.1.12", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts b/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts index 9e1b5000..49f844dd 100644 --- a/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts +++ b/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts @@ -6,7 +6,6 @@ import { } from '@aws-sdk/client-eventbridge'; import { randomInt, randomUUID } from 'node:crypto'; import { mockClient } from 'aws-sdk-client-mock'; -import { CloudEvent } from 'types'; import { Logger } from 'logger'; import { EventPublisher, EventPublisherDependencies } from 'event-publisher'; @@ -20,58 +19,30 @@ const mockLogger: Logger = { debug: jest.fn(), } as any; -const testConfig: EventPublisherDependencies = { +type TestEvent = { id: string; source: string; type: string }; + +const testConfig: EventPublisherDependencies = { eventBusArn: 'arn:aws:events:us-east-1:123456789012:event-bus/test-bus', dlqUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/test-dlq', logger: mockLogger, sqsClient: sqsMock as unknown as SQSClient, eventBridgeClient: eventBridgeMock as unknown as EventBridgeClient, + validateEvent: () => true, }; -const validCloudEvent: CloudEvent = { - profileversion: '1.0.0', - profilepublished: '2025-10', +const event: TestEvent = { 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: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', - messageReference: 'ref1', - senderId: 'sender1', - }, }; -const validCloudEvent2: CloudEvent = { - ...validCloudEvent, +const event2: TestEvent = { id: '550e8400-e29b-41d4-a716-446655440002', - data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174001', - messageReference: 'ref1', - senderId: 'sender1', - }, source: '/nhs/england/notify/development/primary/data-plane/digital-letters', type: 'uk.nhs.notify.digital.letters.sent.v2', }; -const invalidCloudEvent = { - type: 'data', - id: 'missing-source', -}; - -const validEvents = [validCloudEvent, validCloudEvent2]; -const invalidEvents = [invalidCloudEvent as unknown as CloudEvent]; +const events = [event, event2]; describe('Event Publishing', () => { beforeEach(() => { @@ -96,7 +67,7 @@ describe('Event Publishing', () => { }); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(validEvents); + const result = await publisher.sendEvents(events); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(1); @@ -106,16 +77,16 @@ describe('Event Publishing', () => { expect(eventBridgeCall.args[0].input).toEqual({ Entries: [ { - Source: validCloudEvent.source, - DetailType: validCloudEvent.type, - Detail: JSON.stringify(validCloudEvent), + Source: event.source, + DetailType: event.type, + Detail: JSON.stringify(event), EventBusName: 'arn:aws:events:us-east-1:123456789012:event-bus/test-bus', }, { - Source: validCloudEvent2.source, - DetailType: validCloudEvent2.type, - Detail: JSON.stringify(validCloudEvent2), + Source: event2.source, + DetailType: event2.type, + Detail: JSON.stringify(event2), EventBusName: 'arn:aws:events:us-east-1:123456789012:event-bus/test-bus', }, @@ -130,8 +101,11 @@ describe('Event Publishing', () => { ], }); - const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(invalidEvents); + const publisher = new EventPublisher({ + ...testConfig, + validateEvent: () => false, + }); + const result = await publisher.sendEvents(events); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(0); @@ -142,11 +116,11 @@ describe('Event Publishing', () => { expect(sqsInput.QueueUrl).toBe( 'https://sqs.us-east-1.amazonaws.com/123456789012/test-dlq', ); - expect(sqsInput.Entries).toHaveLength(1); - expect(sqsInput.Entries[0].MessageBody).toBe( - JSON.stringify(invalidCloudEvent), - ); + expect(sqsInput.Entries).toHaveLength(2); + expect(sqsInput.Entries[0].MessageBody).toBe(JSON.stringify(events[0])); expect(sqsInput.Entries[0].Id).toBeDefined(); + expect(sqsInput.Entries[1].MessageBody).toBe(JSON.stringify(events[1])); + expect(sqsInput.Entries[1].Id).toBeDefined(); }); test('should send failed EventBridge events to DLQ', async () => { @@ -164,7 +138,7 @@ describe('Event Publishing', () => { }); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(validEvents); + const result = await publisher.sendEvents(events); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(1); @@ -179,9 +153,7 @@ describe('Event Publishing', () => { const sqsCall = sqsMock.calls()[0]; const sqsInput = sqsCall.args[0].input as any; expect(sqsInput.Entries).toHaveLength(1); - expect(sqsInput.Entries[0].MessageBody).toBe( - JSON.stringify(validCloudEvent), - ); + expect(sqsInput.Entries[0].MessageBody).toBe(JSON.stringify(event)); }); test('should handle EventBridge send error and send all events to DLQ', async () => { @@ -195,7 +167,7 @@ describe('Event Publishing', () => { }); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(validEvents); + const result = await publisher.sendEvents(events); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(1); @@ -217,10 +189,13 @@ describe('Event Publishing', () => { }); }); - const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(invalidEvents); + const publisher = new EventPublisher({ + ...testConfig, + validateEvent: () => false, + }); + const result = await publisher.sendEvents(events); - expect(result).toEqual(invalidEvents); + expect(result).toEqual([event]); expect(eventBridgeMock.calls()).toHaveLength(0); expect(sqsMock.calls()).toHaveLength(1); }); @@ -228,10 +203,13 @@ describe('Event Publishing', () => { test('should handle DLQ send error and return all events as failed', async () => { sqsMock.on(SendMessageBatchCommand).rejects(new Error('DLQ error')); - const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(invalidEvents); + const publisher = new EventPublisher({ + ...testConfig, + validateEvent: () => false, + }); + const result = await publisher.sendEvents(events); - expect(result).toEqual(invalidEvents); + expect(result).toEqual(events); expect(eventBridgeMock.calls()).toHaveLength(0); expect(sqsMock.calls()).toHaveLength(1); }); @@ -240,7 +218,7 @@ describe('Event Publishing', () => { const largeEventArray = Array.from({ length: 25 }) .fill(null) .map(() => ({ - ...validCloudEvent, + ...event, id: randomUUID(), })); @@ -270,11 +248,14 @@ describe('Event Publishing', () => { const largeEventArray = Array.from({ length: 25 }) .fill(null) .map(() => ({ - ...(invalidCloudEvent as unknown as CloudEvent), + ...event, id: randomUUID(), })); - const publisher = new EventPublisher(testConfig); + const publisher = new EventPublisher({ + ...testConfig, + validateEvent: () => false, + }); const result = await publisher.sendEvents(largeEventArray); expect(result).toEqual(largeEventArray); @@ -293,27 +274,27 @@ describe('Event Publishing', () => { test('should handle multiple event outcomes in one batch', async () => { const valid = Array.from({ length: 11 }, (_, i) => ({ - ...validCloudEvent, + ...event, id: `11111111-1111-1111-1111-${i.toString().padStart(12, '0')}`, })); const invalid = Array.from({ length: 12 }, (_, i) => ({ - ...(invalidCloudEvent as unknown as CloudEvent), + ...event, id: `22222222-2222-2222-2222-${i.toString().padStart(12, '0')}`, })); const invalidAndDlqError = Array.from({ length: 13 }, (_, i) => ({ - ...(invalidCloudEvent as unknown as CloudEvent), + ...event, id: `33333333-3333-3333-3333-${i.toString().padStart(12, '0')}`, })); const eventBridgeError = Array.from({ length: 14 }, (_, i) => ({ - ...validCloudEvent, + ...event, id: `44444444-4444-4444-4444-${i.toString().padStart(12, '0')}`, })); const eventBridgeAndDlqError = Array.from({ length: 15 }, (_, i) => ({ - ...validCloudEvent, + ...event, id: `55555555-5555-5555-5555-${i.toString().padStart(12, '0')}`, })); @@ -390,7 +371,14 @@ describe('Event Publishing', () => { }); }); - const publisher = new EventPublisher(testConfig); + const publisher = new EventPublisher({ + ...testConfig, + validateEvent: (e) => + !( + e.id.includes('22222222-2222-2222-2222') || + e.id.includes('33333333-3333-3333-3333') + ), + }); const result = await publisher.sendEvents(allEvents); expect(result).toHaveLength( @@ -420,9 +408,9 @@ describe('Event Publishing', () => { // Verify invalid events are are sent to the DLQ with correct reason expect(sqsMockEntries).toEqual( expect.arrayContaining( - [...invalid, ...invalidAndDlqError].map((event) => + [...invalid, ...invalidAndDlqError].map((e) => expect.objectContaining({ - MessageBody: JSON.stringify(event), + MessageBody: JSON.stringify(e), MessageAttributes: { DlqReason: { DataType: 'String', @@ -437,9 +425,9 @@ describe('Event Publishing', () => { // Verify EventBridge failure events are sent to the DLQ with correct reason expect(sqsMockEntries).toEqual( expect.arrayContaining( - [...eventBridgeError, ...eventBridgeAndDlqError].map((event) => + [...eventBridgeError, ...eventBridgeAndDlqError].map((e) => expect.objectContaining({ - MessageBody: JSON.stringify(event), + MessageBody: JSON.stringify(e), MessageAttributes: { DlqReason: { DataType: 'String', @@ -465,11 +453,10 @@ describe('Event Publishing', () => { // Verify valid events are sent to the event bridge expect(eventBridgeMockEntries).toEqual( expect.arrayContaining( - [...valid, ...eventBridgeError, ...eventBridgeAndDlqError].map( - (event) => - expect.objectContaining({ - Detail: JSON.stringify(event), - }), + [...valid, ...eventBridgeError, ...eventBridgeAndDlqError].map((e) => + expect.objectContaining({ + Detail: JSON.stringify(e), + }), ), ), ); @@ -523,11 +510,11 @@ describe('EventPublisher Class', () => { const publisher = new EventPublisher(testConfig); // First call - const result1 = await publisher.sendEvents([validCloudEvent]); + const result1 = await publisher.sendEvents([event]); expect(result1).toEqual([]); // Second call with same publisher instance - const result2 = await publisher.sendEvents([validCloudEvent2]); + const result2 = await publisher.sendEvents([event2]); expect(result2).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(2); diff --git a/utils/utils/src/event-publisher/event-publisher.ts b/utils/utils/src/event-publisher/event-publisher.ts index 5d1d9664..0d4f3dd2 100644 --- a/utils/utils/src/event-publisher/event-publisher.ts +++ b/utils/utils/src/event-publisher/event-publisher.ts @@ -4,31 +4,37 @@ import { } from '@aws-sdk/client-eventbridge'; import { SQSClient, SendMessageBatchCommand } from '@aws-sdk/client-sqs'; import { randomUUID } from 'node:crypto'; -import { $CloudEvent, CloudEvent } from '../types/cloud-event'; import { Logger } from '../logger'; type DlqReason = 'INVALID_EVENT' | 'EVENTBRIDGE_FAILURE'; const MAX_BATCH_SIZE = 10; -export interface EventPublisherDependencies { +export interface EventPublisherDependencies { eventBusArn: string; dlqUrl: string; logger: Logger; sqsClient: SQSClient; eventBridgeClient: EventBridgeClient; + validateEvent: EventValidationFunction; } -export class EventPublisher { +type PublishableEvent = { id: string; source: string; type: string }; + +type EventValidationFunction = { (event: T): boolean; errors?: any[] }; + +export class EventPublisher { private readonly eventBridge: EventBridgeClient; private readonly sqs: SQSClient; - private readonly config: EventPublisherDependencies; + private readonly config: EventPublisherDependencies; private readonly logger: Logger; - constructor(config: EventPublisherDependencies) { + private readonly validateEvent: EventValidationFunction; + + constructor(config: EventPublisherDependencies) { if (!config.eventBusArn) { throw new Error('eventBusArn has not been specified'); } @@ -44,15 +50,19 @@ export class EventPublisher { if (!config.eventBridgeClient) { throw new Error('eventBridgeClient has not been provided'); } + if (!config.validateEvent) { + throw new Error('validateEvent has not been provided'); + } this.config = config; this.logger = config.logger; this.eventBridge = config.eventBridgeClient; this.sqs = config.sqsClient; + this.validateEvent = config.validateEvent; } - private async sendToEventBridge(events: CloudEvent[]): Promise { - const failedEvents: CloudEvent[] = []; + private async sendToEventBridge(events: T[]): Promise { + const failedEvents: T[] = []; this.logger.info({ description: `Sending ${events.length} events to EventBridge`, eventBusArn: this.config.eventBusArn, @@ -112,11 +122,8 @@ export class EventPublisher { return failedEvents; } - private async sendToDLQ( - events: CloudEvent[], - reason: DlqReason, - ): Promise { - const failedDlqs: CloudEvent[] = []; + private async sendToDLQ(events: T[], reason: DlqReason): Promise { + const failedDlqs: T[] = []; this.logger.warn({ description: 'Sending failed events to DLQ', @@ -127,7 +134,7 @@ export class EventPublisher { for (let i = 0; i < events.length; i += MAX_BATCH_SIZE) { const batch = events.slice(i, i + MAX_BATCH_SIZE); - const idToEventMap = new Map(); + const idToEventMap = new Map(); const entries = batch.map((event) => { const id = randomUUID(); @@ -188,26 +195,25 @@ export class EventPublisher { return failedDlqs; } - public async sendEvents(events: CloudEvent[]): Promise { + public async sendEvents(events: T[]): Promise { if (events.length === 0) { this.logger.info({ description: 'No events to send' }); return []; } - const validEvents: CloudEvent[] = []; - const invalidEvents: CloudEvent[] = []; + const validEvents: T[] = []; + const invalidEvents: T[] = []; for (const event of events) { - // NOTE: CCM-12896 created to apply specific event validation. - const { error, success } = $CloudEvent.safeParse(event); - if (success) { + const isEventValid = this.validateEvent(event); + if (isEventValid) { validEvents.push(event); } else { invalidEvents.push(event); this.logger.info({ description: 'Error parsing event', - error, + error: this.validateEvent.errors, }); } } @@ -219,7 +225,7 @@ export class EventPublisher { totalEventCount: events.length, }); - const totalFailedEvents: CloudEvent[] = []; + const totalFailedEvents: T[] = []; if (invalidEvents.length > 0) { const failedDlqSends = await this.sendToDLQ( From 15bf8c6e4df241c247b30fc3589cd80353f1614f Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:54:26 +0000 Subject: [PATCH 02/12] CCM-12896: Update ttl-handle-expiry-lambda to use new types/validators --- lambdas/ttl-handle-expiry-lambda/package.json | 1 + .../apis/dynamodb-stream-handler.test.ts | 22 ++++--------- .../src/__tests__/container.test.ts | 6 ++-- .../src/apis/dynamodb-stream-handler.ts | 33 +++++++++---------- .../ttl-handle-expiry-lambda/src/container.ts | 5 ++- package-lock.json | 1 + 6 files changed, 31 insertions(+), 37 deletions(-) diff --git a/lambdas/ttl-handle-expiry-lambda/package.json b/lambdas/ttl-handle-expiry-lambda/package.json index ffb56f77..b20f07c1 100644 --- a/lambdas/ttl-handle-expiry-lambda/package.json +++ b/lambdas/ttl-handle-expiry-lambda/package.json @@ -3,6 +3,7 @@ "@aws-sdk/client-sqs": "^3.914.0", "@aws-sdk/util-dynamodb": "^3.928.0", "aws-lambda": "^1.0.7", + "digital-letters-events": "^0.0.1", "utils": "^0.0.1" }, "devDependencies": { diff --git a/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts b/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts index ced339a3..83c19719 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts @@ -3,9 +3,10 @@ import { EventPublisher, Logger } from 'utils'; import { mock } from 'jest-mock-extended'; import { createHandler } from 'apis/dynamodb-stream-handler'; import { Dlq } from 'app/dlq'; +import { ItemDequeued } from 'digital-letters-events'; const logger = mock(); -const eventPublisher = mock(); +const eventPublisher = mock>(); const dlq = mock(); const futureTimestamp = Date.now() + 1_000_000; @@ -29,12 +30,10 @@ const mockEvent: DynamoDBStreamEvent = { dateOfExpiry: { S: 'dateOfExpiry' }, event: { M: { - profileversion: { S: '1.0.0' }, - profilepublished: { S: '2025-10' }, id: { S: '550e8400-e29b-41d4-a716-446655440001' }, specversion: { S: '1.0' }, source: { - S: '/nhs/england/notify/production/primary/data-plane/digital-letters', + S: '/nhs/england/notify/production/primary/data-plane/digitalletters/mesh', }, subject: { S: 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', @@ -50,16 +49,12 @@ const mockEvent: DynamoDBStreamEvent = { }, datacontenttype: { S: 'application/json' }, dataschema: { - S: 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', + S: 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json', }, - dataschemaversion: { S: '1.0' }, severitytext: { S: 'INFO' }, data: { M: { messageUri: { S: 'https://example.com/ttl/resource' }, - 'digital-letter-id': { - S: '123e4567-e89b-12d3-a456-426614174000', - }, messageReference: { S: 'ref1' }, senderId: { S: 'sender1' }, }, @@ -110,21 +105,16 @@ describe('createHandler', () => { expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ expect.objectContaining({ - profileversion: '1.0.0', - profilepublished: '2025-10', specversion: '1.0', source: - '/nhs/england/notify/production/primary/data-plane/digital-letters', + '/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.queue.item.dequeued.v1', datacontenttype: 'application/json', dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json', data: expect.objectContaining({ - 'digital-letter-id': expect.stringMatching( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, - ), messageReference: 'ref1', messageUri: 'https://example.com/ttl/resource', senderId: 'sender1', diff --git a/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts b/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts index 0c624a8a..5e4e7b75 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts @@ -1,6 +1,7 @@ import { EventPublisher } from 'utils'; import { loadConfig } from 'infra/config'; import { createContainer } from 'container'; +import eventValidator from 'digital-letters-events/ItemDequeued.js'; jest.mock('utils', () => ({ EventPublisher: jest.fn(), @@ -14,9 +15,7 @@ jest.mock('infra/config', () => ({ })); const mockLoadConfig = loadConfig as jest.MockedFunction; -const mockEventPublisher = EventPublisher as jest.MockedClass< - typeof EventPublisher ->; +const mockEventPublisher = jest.mocked(EventPublisher); describe('createContainer', () => { beforeEach(() => { @@ -49,6 +48,7 @@ describe('createContainer', () => { logger: expect.any(Object), sqsClient: expect.any(Object), eventBridgeClient: expect.any(Object), + validateEvent: eventValidator, }); expect(container).toEqual({ diff --git a/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts b/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts index 55d4551f..3d457681 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts @@ -1,21 +1,21 @@ +import { unmarshall } from '@aws-sdk/util-dynamodb'; +import { Dlq } from 'app/dlq'; import type { DynamoDBBatchItemFailure, DynamoDBRecord, DynamoDBStreamEvent, } from 'aws-lambda'; -import { unmarshall } from '@aws-sdk/util-dynamodb'; -import { - $TtlDynamodbRecord, - $TtlItemEvent, - EventPublisher, - Logger, -} from 'utils'; +import type { + ItemDequeued, + MESHInboxMessageDownloaded, +} from 'digital-letters-events'; +import eventValidator from 'digital-letters-events/MESHInboxMessageDownloaded.js'; import { randomUUID } from 'node:crypto'; -import { Dlq } from 'app/dlq'; +import { $TtlDynamodbRecord, EventPublisher, Logger } from 'utils'; export type CreateHandlerDependencies = { dlq: Dlq; - eventPublisher: EventPublisher; + eventPublisher: EventPublisher; logger: Logger; }; @@ -63,15 +63,10 @@ export const createHandler = ({ return; } - const { - data: itemEvent, - error: eventParseError, - success: eventParseSuccess, - } = $TtlItemEvent.safeParse(item.event); - - if (!eventParseSuccess) { + const isEventValid = eventValidator(item.event); + if (!isEventValid) { logger.warn({ - err: eventParseError, + err: eventValidator.errors, description: 'Error parsing ttl item event', }); @@ -80,6 +75,8 @@ export const createHandler = ({ return; } + const itemEvent: MESHInboxMessageDownloaded = item.event as any; + if (item.withdrawn) { logger.info({ description: 'ItemDequeued event not sent as item withdrawn', @@ -95,6 +92,8 @@ export const createHandler = ({ time: new Date().toISOString(), recordedtime: new Date().toISOString(), type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json', }, ]); } diff --git a/lambdas/ttl-handle-expiry-lambda/src/container.ts b/lambdas/ttl-handle-expiry-lambda/src/container.ts index 3b3e8c65..80e739a1 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/container.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/container.ts @@ -2,17 +2,20 @@ import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils'; import { CreateHandlerDependencies } from 'apis/dynamodb-stream-handler'; import { loadConfig } from 'infra/config'; import { Dlq } from 'app/dlq'; +import { ItemDequeued } from 'digital-letters-events'; +import eventValidator from 'digital-letters-events/ItemDequeued.js'; export const createContainer = (): CreateHandlerDependencies => { const { dlqUrl, eventPublisherDlqUrl, eventPublisherEventBusArn } = loadConfig(); - const eventPublisher = new EventPublisher({ + const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, dlqUrl: eventPublisherDlqUrl, logger, sqsClient, eventBridgeClient, + validateEvent: eventValidator, }); const dlq = new Dlq({ diff --git a/package-lock.json b/package-lock.json index 430b04db..69451d97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -349,6 +349,7 @@ "@aws-sdk/client-sqs": "^3.914.0", "@aws-sdk/util-dynamodb": "^3.928.0", "aws-lambda": "^1.0.7", + "digital-letters-events": "^0.0.1", "utils": "^0.0.1" }, "devDependencies": { From d7a5d19e6b043630e6d74bb7ba5e845c555e1be4 Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:56:52 +0000 Subject: [PATCH 03/12] CCM-12896: Update playwright tests to use new types/validators --- package-lock.json | 1 + .../create-ttl.component.spec.ts | 6 +----- tests/playwright/helpers/event-bus-helpers.ts | 5 ++++- tests/playwright/package.json | 11 ++++++----- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 69451d97..89293d35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17749,6 +17749,7 @@ "@aws-sdk/lib-dynamodb": "3.844.0", "@faker-js/faker": "^9.6.0", "@playwright/test": "^1.51.1", + "digital-letters-events": "^0.0.1", "utils": "^0.0.1", "uuid": "^8.3.2" }, diff --git a/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts b/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts index bb8b850f..afcc2cc0 100644 --- a/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts @@ -11,8 +11,6 @@ test.describe('Digital Letters - Create TTL', () => { await eventPublisher.sendEvents([ { - profileversion: '1.0.0', - profilepublished: '2025-10', id: letterId, specversion: '1.0', source: @@ -26,12 +24,10 @@ test.describe('Digital Letters - Create TTL', () => { 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', + '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, - 'digital-letter-id': letterId, messageReference: 'ref1', senderId: 'sender1', }, diff --git a/tests/playwright/helpers/event-bus-helpers.ts b/tests/playwright/helpers/event-bus-helpers.ts index 6edfa99e..6d7b7c74 100644 --- a/tests/playwright/helpers/event-bus-helpers.ts +++ b/tests/playwright/helpers/event-bus-helpers.ts @@ -1,12 +1,15 @@ import { EVENT_BUS_ARN, EVENT_BUS_DLQ_URL } from 'constants/backend-constants'; import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils'; +import type { MESHInboxMessageDownloaded } from 'digital-letters-events'; +import eventValidator from 'digital-letters-events/MESHInboxMessageDownloaded.js'; -const eventPublisher = new EventPublisher({ +const eventPublisher = new EventPublisher({ eventBusArn: EVENT_BUS_ARN, dlqUrl: EVENT_BUS_DLQ_URL, logger, sqsClient, eventBridgeClient, + validateEvent: eventValidator, }); export default eventPublisher; diff --git a/tests/playwright/package.json b/tests/playwright/package.json index fa34790d..e6517d8a 100644 --- a/tests/playwright/package.json +++ b/tests/playwright/package.json @@ -1,16 +1,20 @@ { "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.844.0", + "@aws-sdk/client-dynamodb": "^3.844.0", "@aws-sdk/client-lambda": "^3.844.0", "@aws-sdk/client-s3": "^3.844.0", "@aws-sdk/client-sqs": "^3.844.0", - "@aws-sdk/client-dynamodb": "^3.844.0", "@aws-sdk/lib-dynamodb": "3.844.0", "@faker-js/faker": "^9.6.0", "@playwright/test": "^1.51.1", + "digital-letters-events": "^0.0.1", "utils": "^0.0.1", "uuid": "^8.3.2" }, + "devDependencies": { + "@types/uuid": "^10.0.0" + }, "name": "nhs-notify-digital-letters-integration-tests", "private": true, "scripts": { @@ -20,8 +24,5 @@ "test:unit": "echo \"Unit tests not required\"", "typecheck": "tsc --noEmit" }, - "version": "0.0.1", - "devDependencies": { - "@types/uuid": "^10.0.0" - } + "version": "0.0.1" } From 8f3d867beef1f458bd5c49364a7fa6bd7373e89a Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:57:38 +0000 Subject: [PATCH 04/12] CCM-12896: Remove manually-created event types --- .../src/__tests__/types/cloud-event.test.ts | 94 ------------ .../__tests__/types/ttl-item-event.test.ts | 89 ----------- utils/utils/src/types/cloud-event.ts | 143 ------------------ utils/utils/src/types/index.ts | 2 - utils/utils/src/types/ttl-item-event.ts | 24 --- 5 files changed, 352 deletions(-) delete mode 100644 utils/utils/src/__tests__/types/cloud-event.test.ts delete mode 100644 utils/utils/src/__tests__/types/ttl-item-event.test.ts delete mode 100644 utils/utils/src/types/cloud-event.ts delete mode 100644 utils/utils/src/types/ttl-item-event.ts diff --git a/utils/utils/src/__tests__/types/cloud-event.test.ts b/utils/utils/src/__tests__/types/cloud-event.test.ts deleted file mode 100644 index 531d56ae..00000000 --- a/utils/utils/src/__tests__/types/cloud-event.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { $CloudEvent, validateCloudEvent } from '../../types/cloud-event'; - -describe('$CloudEvent', () => { - const valid = { - 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.example.with.multiple.words.v1', - time: '2024-07-10T14:30:00Z', - recordedtime: '2024-07-10T14:30: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', - severitytext: 'INFO', - data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', - messageReference: 'ref1', - senderId: 'sender1', - }, - }; - - it('parses a valid CloudEvent', () => { - expect($CloudEvent.parse(valid)).toEqual(valid); - }); - - it('fails for missing required fields', () => { - expect(() => $CloudEvent.parse({})).toThrow(); - }); - - it('fails for invalid source pattern', () => { - const invalid = { ...valid, source: 'invalid-source' }; - expect(() => $CloudEvent.parse(invalid)).toThrow(); - }); - - it('fails for invalid subject pattern', () => { - const invalid = { ...valid, subject: 'invalid-subject' }; - expect(() => $CloudEvent.parse(invalid)).toThrow(); - }); - - it('fails for invalid type pattern', () => { - const invalid = { ...valid, type: 'invalid.type' }; - expect(() => $CloudEvent.parse(invalid)).toThrow(); - }); - - it('fails for missing digital-letter-id in data', () => { - const invalid = { ...valid, data: {} }; - expect(() => $CloudEvent.parse(invalid)).toThrow(); - }); -}); - -describe('validateCloudEvent', () => { - const valid = { - profileversion: '1.0.0', - profilepublished: '2025-10', - id: '550e8400-e29b-41d4-a716-446655440002', - 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.example.with.multiple.words.v1', - time: '2024-07-10T14:30:00Z', - recordedtime: '2024-07-10T14:30: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', - severitytext: 'INFO', - data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', - messageReference: 'ref1', - senderId: 'sender1', - }, - }; - - it('returns success for valid CloudEvent', () => { - const result = validateCloudEvent(valid); - expect(result.success).toBe(true); - expect(result.data).toEqual(valid); - }); - - it('returns failure for invalid CloudEvent', () => { - const result = validateCloudEvent({}); - expect(result.success).toBe(false); - }); -}); diff --git a/utils/utils/src/__tests__/types/ttl-item-event.test.ts b/utils/utils/src/__tests__/types/ttl-item-event.test.ts deleted file mode 100644 index e541f952..00000000 --- a/utils/utils/src/__tests__/types/ttl-item-event.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - $TtlItemEvent, - validateTtlItemEvent, -} from '../../types/ttl-item-event'; - -describe('$TtlItemEvent', () => { - const valid = { - 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.ttl.v1', - time: '2024-07-10T14:30:00Z', - recordedtime: '2024-07-10T14:30: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', - severitytext: 'INFO', - data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', - messageReference: 'ref1', - senderId: 'sender1', - messageUri: 'https://example.com/ttl/resource', - }, - }; - - it('parses a valid object', () => { - expect($TtlItemEvent.parse(valid)).toEqual(valid); - }); - - it('fails for missing required fields', () => { - expect(() => $TtlItemEvent.parse({})).toThrow(); - }); - - it('fails for invalid data types', () => { - const invalid = { ...valid, id: 123, time: 'now' }; - expect(() => $TtlItemEvent.parse(invalid)).toThrow(); - }); - - it('fails for missing data.uri', () => { - const invalid = { ...valid, data: {} }; - expect(() => $TtlItemEvent.parse(invalid)).toThrow(); - }); -}); - -describe('validateTtlItemEvent', () => { - const valid = { - profileversion: '1.0.0', - profilepublished: '2025-10', - id: '550e8400-e29b-41d4-a716-446655440002', - 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.ttl.v1', - time: '2024-07-10T14:30:00Z', - recordedtime: '2024-07-10T14:30: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', - severitytext: 'INFO', - data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', - messageReference: 'ref1', - senderId: 'sender1', - messageUri: 'https://example.com/ttl/resource', - }, - }; - - it('returns success for valid TTL item event', () => { - const result = validateTtlItemEvent(valid); - expect(result.success).toBe(true); - expect(result.data).toEqual(valid); - }); - - it('returns failure for invalid TTL item event', () => { - const result = validateTtlItemEvent({}); - expect(result.success).toBe(false); - }); -}); diff --git a/utils/utils/src/types/cloud-event.ts b/utils/utils/src/types/cloud-event.ts deleted file mode 100644 index 49f308af..00000000 --- a/utils/utils/src/types/cloud-event.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Zod schema for Digital Letters CloudEvent - -import { z } from 'zod'; - -export const $CloudEventData = z - .object({ - 'digital-letter-id': z - .string() - .regex( - /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, - ) - .describe('The unique identifier for the digital letter.'), - messageReference: z - .string() - .describe('The message reference from the sender.'), - senderId: z.string().describe('The identifier of the message sender.'), - }) - .catchall(z.any()); - -export type Data = z.infer; - -const $CloudEventBase = z.object({ - profileversion: z - .literal('1.0.0') - .describe('NHS Notify CloudEvents profile semantic version'), - profilepublished: z - .literal('2025-10') - .describe('NHS Notify CloudEvents profile publication date'), - specversion: z.literal('1.0').describe('CloudEvents specification version'), - id: z - .string() - .regex( - /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, - ) - .describe('Unique identifier for this event instance (UUID)'), - time: z.iso - .datetime() - .describe('Timestamp when the event occurred (RFC 3339)'), - recordedtime: z.iso - .datetime() - .describe('Timestamp when the event was recorded/persisted'), - severitynumber: z - .number() - .min(0) - .max(5) - .describe( - 'Numeric severity (TRACE=0, DEBUG=1, INFO=2, WARN=3, ERROR=4, FATAL=5)', - ), - traceparent: z - .string() - .regex(/^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/) - .describe('W3C Trace Context traceparent header value'), - datacontenttype: z - .literal('application/json') - .optional() - .describe('Media type for the data field'), - dataschemaversion: z - .string() - .optional() - .describe('Version of the data schema'), - severitytext: z - .enum(['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL']) - .optional() - .describe('Log severity level name'), - tracestate: z - .string() - .optional() - .describe('Optional W3C Trace Context tracestate header value'), - partitionkey: z - .string() - .min(1) - .max(64) - .regex(/^[a-z0-9-]+$/) - .optional() - .describe('Partition / ordering key'), - sequence: z - .string() - .regex(/^\d{20}$/) - .optional() - .describe('Zero-padded 20 digit numeric sequence'), - sampledrate: z - .number() - .int() - .min(1) - .optional() - .describe( - 'Sampling factor: number of similar occurrences this event represents', - ), - dataclassification: z - .enum(['public', 'internal', 'confidential', 'restricted']) - .optional() - .describe('Data sensitivity classification'), - dataregulation: z - .enum(['GDPR', 'HIPAA', 'PCI-DSS', 'ISO-27001', 'NIST-800-53', 'CCPA']) - .optional() - .describe('Regulatory regime tag'), - datacategory: z - .enum(['non-sensitive', 'standard', 'sensitive', 'special-category']) - .optional() - .describe('Data category classification'), -}); - -export const $CloudEvent = $CloudEventBase.extend({ - source: z - .string() - .regex( - /^\/nhs\/england\/notify\/(production|staging|development|uat)\/(primary|secondary|dev-\d+)\/data-plane\/digital-letters$/, - 'Source must match the digital letters pattern', - ) - .describe('Event source for digital letters domain'), - - subject: z - .string() - .regex( - /^customer\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\/recipient\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, - 'Subject must be in the format customer/{uuid}/recipient/{uuid}', - ) - .describe( - 'Path in the form customer/{id}/recipient/{id} where each {id} is a UUID', - ), - - type: z - .string() - .regex( - /^uk\.nhs\.notify\.digital\.letters\.[a-z0-9]+(?:\.[a-z0-9]+)*\.v\d+$/, - 'Type must follow the digital letters event type pattern', - ) - .describe('Concrete versioned event type string'), - - dataschema: z - .literal( - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', - ) - .describe('Canonical URI of the event data schema'), - - data: $CloudEventData.describe('Digital letters payload'), -}); - -export type CloudEvent = z.infer; - -export const validateCloudEvent = (data: unknown) => { - return $CloudEvent.safeParse(data); -}; diff --git a/utils/utils/src/types/index.ts b/utils/utils/src/types/index.ts index 738acf67..6ec81a93 100644 --- a/utils/utils/src/types/index.ts +++ b/utils/utils/src/types/index.ts @@ -1,4 +1,2 @@ -export * from './cloud-event'; export * from './ttl-dynamodb-record'; -export * from './ttl-item-event'; export * from './sender'; diff --git a/utils/utils/src/types/ttl-item-event.ts b/utils/utils/src/types/ttl-item-event.ts deleted file mode 100644 index 344eb90f..00000000 --- a/utils/utils/src/types/ttl-item-event.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from 'zod'; -import { $CloudEvent, $CloudEventData } from './cloud-event'; - -export const $TtlItemData = $CloudEventData.extend({ - messageUri: z.string().min(1).describe('URI of the TTL item resource'), -}); - -export type TtlItemEventData = z.infer; - -export const $TtlItemEvent = $CloudEvent.extend({ - data: $TtlItemData, -}); - -export const $TtlItemBusEvent = z.object({ - detail: $TtlItemEvent, -}); - -export type TtlItemEvent = z.infer; - -export type TtlItemBusEvent = z.infer; - -export const validateTtlItemEvent = (data: unknown) => { - return $TtlItemEvent.safeParse(data); -}; From ef503182a6f1d688496d9537ad5ab6662898edd8 Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:25:00 +0000 Subject: [PATCH 05/12] CCM-12896: Make acceptance tests generate dependencies --- .github/actions/acceptance-tests/action.yaml | 5 +++++ .../create-ttl.component.spec.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml index 9113e037..f734055c 100644 --- a/.github/actions/acceptance-tests/action.yaml +++ b/.github/actions/acceptance-tests/action.yaml @@ -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: | diff --git a/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts b/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts index afcc2cc0..80fbf918 100644 --- a/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts @@ -14,7 +14,7 @@ test.describe('Digital Letters - Create TTL', () => { id: letterId, specversion: '1.0', source: - '/nhs/england/notify/production/primary/data-plane/digital-letters', + '/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', From 05e3d66b4f90f2b90555546644525d0e306c8db8 Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:27:01 +0000 Subject: [PATCH 06/12] CCM-12896: Remove dataschemaversion from eventbridge rule --- .../dl/cloudwatch_event_rule_mesh_inbox_message_downloaded.tf | 3 --- 1 file changed, 3 deletions(-) diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_mesh_inbox_message_downloaded.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_mesh_inbox_message_downloaded.tf index 4652576e..57e80e60 100644 --- a/infrastructure/terraform/components/dl/cloudwatch_event_rule_mesh_inbox_message_downloaded.tf +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_mesh_inbox_message_downloaded.tf @@ -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." - }] } }) } From 39224406713c4c83a4d2add641b1b38964fed13c Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:35:27 +0000 Subject: [PATCH 07/12] CCM-12896: Add config for VSCode jest plugin --- project.code-workspace | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/project.code-workspace b/project.code-workspace index 87670476..6aada36c 100644 --- a/project.code-workspace +++ b/project.code-workspace @@ -77,7 +77,18 @@ ".github/copilot-instructions.md": true, ".github/instructions": true }, - "terminal.integrated.scrollback": 10000 + "terminal.integrated.scrollback": 10000, + "jest.virtualFolders": [ + + { "name": "key-generation", "rootPath": "lambdas/key-generation" }, + { "name": "mesh-poll", "rootPath": "lambdas/mesh-poll" }, + { "name": "refresh-apim-access-token", "rootPath": "lambdas/refresh-apim-access-token" }, + { "name": "ttl-create-lambda", "rootPath": "lambdas/ttl-create-lambda/" }, + { "name": "ttl-handle-expiry-lambda", "rootPath": "lambdas/ttl-handle-expiry-lambda" }, + { "name": "ttl-poll-lambda", "rootPath": "lambdas/ttl-poll-lambda" }, + { "name": "sender-management", "rootPath": "utils/sender-management" }, + { "name": "utils", "rootPath": "utils/utils" }, + ], }, "extensions": { "recommendations": [ @@ -106,6 +117,7 @@ "ms-vscode.hexeditor", "ms-vscode.live-server", "ms-vsliveshare.vsliveshare", + "orta.vscode-jest", "redhat.vscode-xml", "streetsidesoftware.code-spell-checker-british-english", "takumii.markdowntable", From 05812626c88a25a26fb0ef33afd4e0a0eed65228 Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:23:11 +0000 Subject: [PATCH 08/12] CCM-12896: Make eventpublisher method generic instead of class --- .../__tests__/apis/sqs-trigger-lambda.test.ts | 33 ++++++---- .../src/apis/sqs-trigger-lambda.ts | 12 ++-- lambdas/ttl-create-lambda/src/container.ts | 5 +- .../apis/dynamodb-stream-handler.test.ts | 62 ++++++++++--------- .../src/__tests__/container.test.ts | 2 - .../src/apis/dynamodb-stream-handler.ts | 34 +++++----- .../ttl-handle-expiry-lambda/src/container.ts | 5 +- .../create-ttl.component.spec.ts | 52 +++++++++------- tests/playwright/helpers/event-bus-helpers.ts | 5 +- .../event-publisher/event-publisher.test.ts | 55 +++++++--------- .../src/event-publisher/event-publisher.ts | 33 +++++----- 11 files changed, 151 insertions(+), 147 deletions(-) diff --git a/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts index 4ec053e3..b0a9c5d9 100644 --- a/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -4,6 +4,7 @@ import { ItemEnqueued, MESHInboxMessageDownloaded, } from 'digital-letters-events'; +import itemEnqueuedValidator from 'digital-letters-events/ItemEnqueued.js'; import { randomUUID } from 'node:crypto'; jest.mock('node:crypto', () => ({ @@ -75,7 +76,10 @@ describe('createHandler', () => { expect(res.batchItemFailures).toEqual([]); expect(createTtl.send).toHaveBeenCalledWith(messageDownloadedEvent); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([itemEnqueuedEvent]); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [itemEnqueuedEvent], + itemEnqueuedValidator, + ); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 0, @@ -185,11 +189,10 @@ describe('createHandler', () => { expect(res.batchItemFailures).toEqual([]); expect(createTtl.send).toHaveBeenCalledTimes(3); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - itemEnqueuedEvent, - itemEnqueuedEvent, - itemEnqueuedEvent, - ]); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [itemEnqueuedEvent, itemEnqueuedEvent, itemEnqueuedEvent], + itemEnqueuedValidator, + ); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 0, @@ -213,10 +216,10 @@ describe('createHandler', () => { const res = await handler(event); expect(res.batchItemFailures).toEqual([]); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - itemEnqueuedEvent, - itemEnqueuedEvent, - ]); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [itemEnqueuedEvent, itemEnqueuedEvent], + itemEnqueuedValidator, + ); expect(logger.warn).toHaveBeenCalledWith({ description: 'Some events failed to publish', failedCount: 1, @@ -236,7 +239,10 @@ describe('createHandler', () => { const res = await handler(event); expect(res.batchItemFailures).toEqual([]); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([itemEnqueuedEvent]); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [itemEnqueuedEvent], + itemEnqueuedValidator, + ); expect(logger.warn).toHaveBeenCalledWith({ err: publishError, description: 'Failed to send events to EventBridge', @@ -282,7 +288,10 @@ describe('createHandler', () => { { itemIdentifier: 'msg2' }, { itemIdentifier: 'msg3' }, ]); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([itemEnqueuedEvent]); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [itemEnqueuedEvent], + itemEnqueuedValidator, + ); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 2, diff --git a/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts b/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts index 144e5762..9e0dac4b 100644 --- a/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts +++ b/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts @@ -6,7 +6,8 @@ import type { import { randomUUID } from 'node:crypto'; import type { CreateTtl, CreateTtlOutcome } from 'app/create-ttl'; import { EventPublisher, Logger } from 'utils'; -import eventValidator from 'digital-letters-events/MESHInboxMessageDownloaded.js'; +import itemEnqueuedValidator from 'digital-letters-events/ItemEnqueued.js'; +import messageDownloadedValidator from 'digital-letters-events/MESHInboxMessageDownloaded.js'; import { ItemEnqueued, MESHInboxMessageDownloaded, @@ -19,7 +20,7 @@ interface ProcessingResult { interface CreateHandlerDependencies { createTtl: CreateTtl; - eventPublisher: EventPublisher; + eventPublisher: EventPublisher; logger: Logger; } @@ -37,10 +38,10 @@ export const createHandler = ({ const sqsEventBody = JSON.parse(body); const sqsEventDetail = sqsEventBody.detail; - const isEventValid = eventValidator(sqsEventDetail); + const isEventValid = messageDownloadedValidator(sqsEventDetail); if (!isEventValid) { logger.error({ - err: eventValidator.errors, + err: messageDownloadedValidator.errors, description: 'Error parsing ttl queue entry', }); batchItemFailures.push({ itemIdentifier: messageId }); @@ -96,7 +97,7 @@ export const createHandler = ({ if (successfulEvents.length > 0) { try { - const failedEvents = await eventPublisher.sendEvents( + const failedEvents = await eventPublisher.sendEvents( successfulEvents.map((event) => ({ ...event, id: randomUUID(), @@ -106,6 +107,7 @@ export const createHandler = ({ dataschema: 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-enqueued-data.schema.json', })), + itemEnqueuedValidator, ); if (failedEvents.length > 0) { logger.warn({ diff --git a/lambdas/ttl-create-lambda/src/container.ts b/lambdas/ttl-create-lambda/src/container.ts index 82a46722..3f5f506a 100644 --- a/lambdas/ttl-create-lambda/src/container.ts +++ b/lambdas/ttl-create-lambda/src/container.ts @@ -8,8 +8,6 @@ import { import { loadConfig } from 'infra/config'; import { TtlRepository } from 'infra/ttl-repository'; import { CreateTtl } from 'app/create-ttl'; -import { ItemEnqueued } from 'digital-letters-events'; -import eventValidator from 'digital-letters-events/ItemEnqueued.js'; export const createContainer = () => { const { @@ -30,13 +28,12 @@ export const createContainer = () => { const createTtl = new CreateTtl(requestTtlRepository, logger); - const eventPublisher = new EventPublisher({ + const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, dlqUrl: eventPublisherDlqUrl, logger, sqsClient, eventBridgeClient, - validateEvent: eventValidator, }); return { diff --git a/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts b/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts index 83c19719..ab353c4d 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts @@ -3,10 +3,10 @@ import { EventPublisher, Logger } from 'utils'; import { mock } from 'jest-mock-extended'; import { createHandler } from 'apis/dynamodb-stream-handler'; import { Dlq } from 'app/dlq'; -import { ItemDequeued } from 'digital-letters-events'; +import itemDequeuedValidator from 'digital-letters-events/ItemDequeued.js'; const logger = mock(); -const eventPublisher = mock>(); +const eventPublisher = mock(); const dlq = mock(); const futureTimestamp = Date.now() + 1_000_000; @@ -103,24 +103,27 @@ describe('createHandler', () => { expect(eventPublisher.sendEvents).toHaveBeenCalledTimes(1); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - expect.objectContaining({ - 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.queue.item.dequeued.v1', - datacontenttype: 'application/json', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json', - data: expect.objectContaining({ - messageReference: 'ref1', - messageUri: 'https://example.com/ttl/resource', - senderId: 'sender1', + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + expect.objectContaining({ + 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.queue.item.dequeued.v1', + datacontenttype: 'application/json', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json', + data: expect.objectContaining({ + messageReference: 'ref1', + messageUri: 'https://example.com/ttl/resource', + senderId: 'sender1', + }), }), - }), - ]); + ], + itemDequeuedValidator, + ); expect(result).toEqual({}); }); @@ -377,16 +380,19 @@ describe('createHandler', () => { const result = await handler(mockNotWithdrawnEvent); expect(eventPublisher.sendEvents).toHaveBeenCalledTimes(1); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - expect.objectContaining({ - type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', - data: expect.objectContaining({ - messageReference: 'ref1', - messageUri: 'https://example.com/ttl/resource', - senderId: 'sender1', + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + expect.objectContaining({ + type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', + data: expect.objectContaining({ + messageReference: 'ref1', + messageUri: 'https://example.com/ttl/resource', + senderId: 'sender1', + }), }), - }), - ]); + ], + itemDequeuedValidator, + ); expect(result).toEqual({}); }); }); diff --git a/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts b/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts index 5e4e7b75..5e814ba3 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts @@ -1,7 +1,6 @@ import { EventPublisher } from 'utils'; import { loadConfig } from 'infra/config'; import { createContainer } from 'container'; -import eventValidator from 'digital-letters-events/ItemDequeued.js'; jest.mock('utils', () => ({ EventPublisher: jest.fn(), @@ -48,7 +47,6 @@ describe('createContainer', () => { logger: expect.any(Object), sqsClient: expect.any(Object), eventBridgeClient: expect.any(Object), - validateEvent: eventValidator, }); expect(container).toEqual({ diff --git a/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts b/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts index 3d457681..a48e74c5 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts @@ -9,13 +9,14 @@ import type { ItemDequeued, MESHInboxMessageDownloaded, } from 'digital-letters-events'; -import eventValidator from 'digital-letters-events/MESHInboxMessageDownloaded.js'; +import itemDequeuedValidator from 'digital-letters-events/ItemDequeued.js'; +import messageDownloadedValidator from 'digital-letters-events/MESHInboxMessageDownloaded.js'; import { randomUUID } from 'node:crypto'; import { $TtlDynamodbRecord, EventPublisher, Logger } from 'utils'; export type CreateHandlerDependencies = { dlq: Dlq; - eventPublisher: EventPublisher; + eventPublisher: EventPublisher; logger: Logger; }; @@ -63,10 +64,10 @@ export const createHandler = ({ return; } - const isEventValid = eventValidator(item.event); + const isEventValid = messageDownloadedValidator(item.event); if (!isEventValid) { logger.warn({ - err: eventValidator.errors, + err: messageDownloadedValidator.errors, description: 'Error parsing ttl item event', }); @@ -85,17 +86,20 @@ export const createHandler = ({ senderId: itemEvent.data.senderId, }); } else { - await eventPublisher.sendEvents([ - { - ...itemEvent, - id: randomUUID(), - time: new Date().toISOString(), - recordedtime: new Date().toISOString(), - type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json', - }, - ]); + await eventPublisher.sendEvents( + [ + { + ...itemEvent, + id: randomUUID(), + time: new Date().toISOString(), + recordedtime: new Date().toISOString(), + type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json', + }, + ], + itemDequeuedValidator, + ); } } catch (error) { logger.warn({ diff --git a/lambdas/ttl-handle-expiry-lambda/src/container.ts b/lambdas/ttl-handle-expiry-lambda/src/container.ts index 80e739a1..3b3e8c65 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/container.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/container.ts @@ -2,20 +2,17 @@ import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils'; import { CreateHandlerDependencies } from 'apis/dynamodb-stream-handler'; import { loadConfig } from 'infra/config'; import { Dlq } from 'app/dlq'; -import { ItemDequeued } from 'digital-letters-events'; -import eventValidator from 'digital-letters-events/ItemDequeued.js'; export const createContainer = (): CreateHandlerDependencies => { const { dlqUrl, eventPublisherDlqUrl, eventPublisherEventBusArn } = loadConfig(); - const eventPublisher = new EventPublisher({ + const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, dlqUrl: eventPublisherDlqUrl, logger, sqsClient, eventBridgeClient, - validateEvent: eventValidator, }); const dlq = new Dlq({ diff --git a/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts b/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts index 80fbf918..7ab4eba3 100644 --- a/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts @@ -1,4 +1,6 @@ import { expect, test } from '@playwright/test'; +import { MESHInboxMessageDownloaded } from 'digital-letters-events'; +import messageDownloadedValidator from 'digital-letters-events/MESHInboxMessageDownloaded.js'; import getTtl from 'helpers/dynamodb-helpers'; import eventPublisher from 'helpers/event-bus-helpers'; import expectToPassEventually from 'helpers/expectations'; @@ -9,30 +11,34 @@ test.describe('Digital Letters - Create TTL', () => { const letterId = uuidv4(); const messageUri = `https://example.com/ttl/resource/${letterId}`; - await eventPublisher.sendEvents([ - { - id: letterId, - 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, - messageReference: 'ref1', - senderId: 'sender1', + await eventPublisher.sendEvents( + [ + { + id: letterId, + 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, + messageReference: 'ref1', + senderId: 'sender1', + }, }, - }, - ]); + ], + messageDownloadedValidator, + ); await expectToPassEventually(async () => { const ttl = await getTtl(messageUri); diff --git a/tests/playwright/helpers/event-bus-helpers.ts b/tests/playwright/helpers/event-bus-helpers.ts index 6d7b7c74..6edfa99e 100644 --- a/tests/playwright/helpers/event-bus-helpers.ts +++ b/tests/playwright/helpers/event-bus-helpers.ts @@ -1,15 +1,12 @@ import { EVENT_BUS_ARN, EVENT_BUS_DLQ_URL } from 'constants/backend-constants'; import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils'; -import type { MESHInboxMessageDownloaded } from 'digital-letters-events'; -import eventValidator from 'digital-letters-events/MESHInboxMessageDownloaded.js'; -const eventPublisher = new EventPublisher({ +const eventPublisher = new EventPublisher({ eventBusArn: EVENT_BUS_ARN, dlqUrl: EVENT_BUS_DLQ_URL, logger, sqsClient, eventBridgeClient, - validateEvent: eventValidator, }); export default eventPublisher; diff --git a/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts b/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts index 49f844dd..cb6f44e6 100644 --- a/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts +++ b/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts @@ -21,13 +21,12 @@ const mockLogger: Logger = { type TestEvent = { id: string; source: string; type: string }; -const testConfig: EventPublisherDependencies = { +const testConfig: EventPublisherDependencies = { eventBusArn: 'arn:aws:events:us-east-1:123456789012:event-bus/test-bus', dlqUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/test-dlq', logger: mockLogger, sqsClient: sqsMock as unknown as SQSClient, eventBridgeClient: eventBridgeMock as unknown as EventBridgeClient, - validateEvent: () => true, }; const event: TestEvent = { @@ -53,7 +52,7 @@ describe('Event Publishing', () => { test('should return empty array when no events provided', async () => { const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents([]); + const result = await publisher.sendEvents([], () => true); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(0); @@ -67,7 +66,7 @@ describe('Event Publishing', () => { }); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(events); + const result = await publisher.sendEvents(events, () => true); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(1); @@ -101,11 +100,8 @@ describe('Event Publishing', () => { ], }); - const publisher = new EventPublisher({ - ...testConfig, - validateEvent: () => false, - }); - const result = await publisher.sendEvents(events); + const publisher = new EventPublisher(testConfig); + const result = await publisher.sendEvents(events, () => false); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(0); @@ -138,7 +134,7 @@ describe('Event Publishing', () => { }); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(events); + const result = await publisher.sendEvents(events, () => true); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(1); @@ -167,7 +163,7 @@ describe('Event Publishing', () => { }); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(events); + const result = await publisher.sendEvents(events, () => true); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(1); @@ -189,11 +185,8 @@ describe('Event Publishing', () => { }); }); - const publisher = new EventPublisher({ - ...testConfig, - validateEvent: () => false, - }); - const result = await publisher.sendEvents(events); + const publisher = new EventPublisher(testConfig); + const result = await publisher.sendEvents(events, () => false); expect(result).toEqual([event]); expect(eventBridgeMock.calls()).toHaveLength(0); @@ -203,11 +196,8 @@ describe('Event Publishing', () => { test('should handle DLQ send error and return all events as failed', async () => { sqsMock.on(SendMessageBatchCommand).rejects(new Error('DLQ error')); - const publisher = new EventPublisher({ - ...testConfig, - validateEvent: () => false, - }); - const result = await publisher.sendEvents(events); + const publisher = new EventPublisher(testConfig); + const result = await publisher.sendEvents(events, () => false); expect(result).toEqual(events); expect(eventBridgeMock.calls()).toHaveLength(0); @@ -228,7 +218,7 @@ describe('Event Publishing', () => { }); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(largeEventArray); + const result = await publisher.sendEvents(largeEventArray, () => true); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(3); @@ -252,11 +242,8 @@ describe('Event Publishing', () => { id: randomUUID(), })); - const publisher = new EventPublisher({ - ...testConfig, - validateEvent: () => false, - }); - const result = await publisher.sendEvents(largeEventArray); + const publisher = new EventPublisher(testConfig); + const result = await publisher.sendEvents(largeEventArray, () => false); expect(result).toEqual(largeEventArray); expect(sqsMock.calls()).toHaveLength(3); @@ -371,15 +358,15 @@ describe('Event Publishing', () => { }); }); - const publisher = new EventPublisher({ - ...testConfig, - validateEvent: (e) => + const publisher = new EventPublisher(testConfig); + const result = await publisher.sendEvents( + allEvents, + (e) => !( e.id.includes('22222222-2222-2222-2222') || e.id.includes('33333333-3333-3333-3333') ), - }); - const result = await publisher.sendEvents(allEvents); + ); expect(result).toHaveLength( invalidAndDlqError.length + eventBridgeAndDlqError.length, @@ -510,11 +497,11 @@ describe('EventPublisher Class', () => { const publisher = new EventPublisher(testConfig); // First call - const result1 = await publisher.sendEvents([event]); + const result1 = await publisher.sendEvents([event], () => true); expect(result1).toEqual([]); // Second call with same publisher instance - const result2 = await publisher.sendEvents([event2]); + const result2 = await publisher.sendEvents([event2], () => true); expect(result2).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(2); diff --git a/utils/utils/src/event-publisher/event-publisher.ts b/utils/utils/src/event-publisher/event-publisher.ts index 0d4f3dd2..e23ac722 100644 --- a/utils/utils/src/event-publisher/event-publisher.ts +++ b/utils/utils/src/event-publisher/event-publisher.ts @@ -10,31 +10,28 @@ type DlqReason = 'INVALID_EVENT' | 'EVENTBRIDGE_FAILURE'; const MAX_BATCH_SIZE = 10; -export interface EventPublisherDependencies { +export interface EventPublisherDependencies { eventBusArn: string; dlqUrl: string; logger: Logger; sqsClient: SQSClient; eventBridgeClient: EventBridgeClient; - validateEvent: EventValidationFunction; } type PublishableEvent = { id: string; source: string; type: string }; type EventValidationFunction = { (event: T): boolean; errors?: any[] }; -export class EventPublisher { +export class EventPublisher { private readonly eventBridge: EventBridgeClient; private readonly sqs: SQSClient; - private readonly config: EventPublisherDependencies; + private readonly config: EventPublisherDependencies; private readonly logger: Logger; - private readonly validateEvent: EventValidationFunction; - - constructor(config: EventPublisherDependencies) { + constructor(config: EventPublisherDependencies) { if (!config.eventBusArn) { throw new Error('eventBusArn has not been specified'); } @@ -50,18 +47,16 @@ export class EventPublisher { if (!config.eventBridgeClient) { throw new Error('eventBridgeClient has not been provided'); } - if (!config.validateEvent) { - throw new Error('validateEvent has not been provided'); - } this.config = config; this.logger = config.logger; this.eventBridge = config.eventBridgeClient; this.sqs = config.sqsClient; - this.validateEvent = config.validateEvent; } - private async sendToEventBridge(events: T[]): Promise { + private async sendToEventBridge( + events: T[], + ): Promise { const failedEvents: T[] = []; this.logger.info({ description: `Sending ${events.length} events to EventBridge`, @@ -122,7 +117,10 @@ export class EventPublisher { return failedEvents; } - private async sendToDLQ(events: T[], reason: DlqReason): Promise { + private async sendToDLQ( + events: T[], + reason: DlqReason, + ): Promise { const failedDlqs: T[] = []; this.logger.warn({ @@ -195,7 +193,10 @@ export class EventPublisher { return failedDlqs; } - public async sendEvents(events: T[]): Promise { + public async sendEvents( + events: T[], + eventValidator: EventValidationFunction, + ): Promise { if (events.length === 0) { this.logger.info({ description: 'No events to send' }); return []; @@ -205,7 +206,7 @@ export class EventPublisher { const invalidEvents: T[] = []; for (const event of events) { - const isEventValid = this.validateEvent(event); + const isEventValid = eventValidator(event); if (isEventValid) { validEvents.push(event); } else { @@ -213,7 +214,7 @@ export class EventPublisher { this.logger.info({ description: 'Error parsing event', - error: this.validateEvent.errors, + error: eventValidator.errors, }); } } From 96ea6ab98e91ceec5a2ff707ab912118e953eaad Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:34:46 +0000 Subject: [PATCH 09/12] CCM-12896: Also transform source field and make test validate events produced --- .../src/__tests__/apis/sqs-trigger-lambda.test.ts | 9 ++++++++- lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts | 1 + .../src/__tests__/apis/dynamodb-stream-handler.test.ts | 6 +++++- .../src/apis/dynamodb-stream-handler.ts | 1 + 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts index b0a9c5d9..cc87b11b 100644 --- a/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -52,9 +52,11 @@ describe('createHandler', () => { 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', - type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', dataschema: 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-enqueued-data.schema.json', }; @@ -80,6 +82,11 @@ describe('createHandler', () => { [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, diff --git a/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts b/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts index 9e0dac4b..d18219c7 100644 --- a/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts +++ b/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts @@ -106,6 +106,7 @@ export const createHandler = ({ type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', dataschema: 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-enqueued-data.schema.json', + source: event.source.replace(/\/mesh$/, '/queue'), })), itemEnqueuedValidator, ); diff --git a/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts b/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts index ab353c4d..f69f1df6 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts @@ -108,7 +108,7 @@ describe('createHandler', () => { expect.objectContaining({ specversion: '1.0', source: - '/nhs/england/notify/production/primary/data-plane/digitalletters/mesh', + '/nhs/england/notify/production/primary/data-plane/digitalletters/queue', subject: 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', @@ -125,6 +125,10 @@ describe('createHandler', () => { itemDequeuedValidator, ); + const publishedEvent = eventPublisher.sendEvents.mock.lastCall?.[0]; + expect(publishedEvent).toHaveLength(1); + expect(itemDequeuedValidator(publishedEvent?.[0])).toBeTruthy(); + expect(result).toEqual({}); }); diff --git a/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts b/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts index a48e74c5..62a16924 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts @@ -96,6 +96,7 @@ export const createHandler = ({ type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', dataschema: 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json', + source: itemEvent.source.replace(/\/mesh$/, '/queue'), }, ], itemDequeuedValidator, From 0daf3e15509c2a20ad72f2accf67f23edd8159f2 Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:48:50 +0000 Subject: [PATCH 10/12] CCM-12896: Extend create TTL component test to check ItemEnqueued event is published --- .../create-ttl.component.spec.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts b/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts index ec401e04..c98d6cb9 100644 --- a/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts @@ -1,13 +1,15 @@ import { expect, test } from '@playwright/test'; +import { ENV } from 'constants/backend-constants'; import { MESHInboxMessageDownloaded } from 'digital-letters-events'; import messageDownloadedValidator from 'digital-letters-events/MESHInboxMessageDownloaded.js'; +import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; import { getTtl } from 'helpers/dynamodb-helpers'; import eventPublisher from 'helpers/event-bus-helpers'; import expectToPassEventually from 'helpers/expectations'; import { v4 as uuidv4 } from 'uuid'; test.describe('Digital Letters - Create TTL', () => { - test('should create TTL following downloaded message event', async () => { + test('should create TTL and publish item enqueued event following message downloaded event', async () => { const letterId = uuidv4(); const messageUri = `https://example.com/ttl/resource/${letterId}`; @@ -40,10 +42,25 @@ test.describe('Digital Letters - Create TTL', () => { messageDownloadedValidator, ); + // Verify TTL created await expectToPassEventually(async () => { const ttl = await getTtl(messageUri); expect(ttl.length).toBe(1); }); + + // Verify item enqueued event published + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + `/aws/vendedlogs/events/event-bus/nhs-${ENV}-dl`, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.queue.item.enqueued.v1"', + `$.details.event_detail = "*\\"messageUri\\":\\"${messageUri}\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }); }); }); From 537b0d45f5933f64167f2ecd5ac576e60af6ae31 Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:59:20 +0000 Subject: [PATCH 11/12] CCM-12896: Make handle-ttl component test use valid events --- .../handle-ttl.component.spec.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/playwright/digital-letters-component-tests/handle-ttl.component.spec.ts b/tests/playwright/digital-letters-component-tests/handle-ttl.component.spec.ts index 332ef045..b70a6417 100644 --- a/tests/playwright/digital-letters-component-tests/handle-ttl.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/handle-ttl.component.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { ENV } from 'constants/backend-constants'; +import { MESHInboxMessageDownloaded } from 'digital-letters-events'; import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; import { deleteTtl, putTtl } from 'helpers/dynamodb-helpers'; import expectToPassEventually from 'helpers/expectations'; @@ -13,11 +14,11 @@ test.describe('Digital Letters - Handle TTL', () => { await purgeQueue(handleTtlDlqName); }); - const baseEvent = { - profileversion: '1.0.0', - profilepublished: '2025-10', + const baseEvent: MESHInboxMessageDownloaded = { + id: 'sample-id', specversion: '1.0', - source: '/nhs/england/notify/production/primary/data-plane/digital-letters', + 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', @@ -27,12 +28,12 @@ test.describe('Digital Letters - Handle TTL', () => { 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', + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json', severitytext: 'INFO', data: { messageReference: 'ref1', senderId: 'sender1', + messageUri: 'https://example.com/ttl/resource/sample', }, }; @@ -46,9 +47,8 @@ test.describe('Digital Letters - Handle TTL', () => { data: { ...baseEvent.data, messageUri, - 'digital-letter-id': letterId, }, - }; + } satisfies MESHInboxMessageDownloaded; const ttlItem = { PK: messageUri, @@ -88,9 +88,8 @@ test.describe('Digital Letters - Handle TTL', () => { data: { ...baseEvent.data, messageUri, - 'digital-letter-id': letterId, }, - }; + } satisfies MESHInboxMessageDownloaded; const ttlItem = { PK: messageUri, From e965adb47a98f637061822b3d9173cd7cf98ef83 Mon Sep 17 00:00:00 2001 From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:47:28 +0000 Subject: [PATCH 12/12] CCM-12896: Add a type guard to handle-expiry's event validation --- .../src/apis/dynamodb-stream-handler.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts b/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts index 62a16924..d8f42d5e 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts @@ -20,6 +20,10 @@ export type CreateHandlerDependencies = { logger: Logger; }; +const eventValidator = messageDownloadedValidator as ( + d: unknown, +) => d is MESHInboxMessageDownloaded; + export const createHandler = ({ dlq, eventPublisher, @@ -64,8 +68,10 @@ export const createHandler = ({ return; } - const isEventValid = messageDownloadedValidator(item.event); - if (!isEventValid) { + let itemEvent: MESHInboxMessageDownloaded; + if (eventValidator(item.event)) { + itemEvent = item.event; + } else { logger.warn({ err: messageDownloadedValidator.errors, description: 'Error parsing ttl item event', @@ -76,8 +82,6 @@ export const createHandler = ({ return; } - const itemEvent: MESHInboxMessageDownloaded = item.event as any; - if (item.withdrawn) { logger.info({ description: 'ItemDequeued event not sent as item withdrawn',