Skip to content

Commit 39c023b

Browse files
CM-12952 - MI Updates Transformer
1 parent de5fdc3 commit 39c023b

File tree

11 files changed

+846
-0
lines changed

11 files changed

+846
-0
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { Config } from 'jest';
2+
3+
export const baseJestConfig: Config = {
4+
preset: 'ts-jest',
5+
6+
// Automatically clear mock calls, instances, contexts and results before every test
7+
clearMocks: true,
8+
9+
// Indicates whether the coverage information should be collected while executing the test
10+
collectCoverage: true,
11+
12+
// The directory where Jest should output its coverage files
13+
coverageDirectory: './.reports/unit/coverage',
14+
15+
// Indicates which provider should be used to instrument code for coverage
16+
coverageProvider: 'babel',
17+
18+
coverageThreshold: {
19+
global: {
20+
branches: 100,
21+
functions: 100,
22+
lines: 100,
23+
statements: -10,
24+
},
25+
},
26+
27+
coveragePathIgnorePatterns: ['/__tests__/'],
28+
transform: { '^.+\\.ts$': 'ts-jest' },
29+
testPathIgnorePatterns: ['.build'],
30+
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
31+
32+
// Use this configuration option to add custom reporters to Jest
33+
reporters: [
34+
'default',
35+
[
36+
'jest-html-reporter',
37+
{
38+
pageTitle: 'Test Report',
39+
outputPath: './.reports/unit/test-report.html',
40+
includeFailureMsg: true,
41+
},
42+
],
43+
],
44+
45+
// The test environment that will be used for testing
46+
testEnvironment: 'jsdom',
47+
};
48+
49+
const utilsJestConfig = {
50+
...baseJestConfig,
51+
52+
testEnvironment: 'node',
53+
54+
coveragePathIgnorePatterns: [
55+
...(baseJestConfig.coveragePathIgnorePatterns ?? []),
56+
'zod-validators.ts',
57+
],
58+
};
59+
60+
export default utilsJestConfig;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"dependencies": {
3+
"@nhsdigital/nhs-notify-event-schemas-supplier-api": "*",
4+
"esbuild": "^0.24.0"
5+
},
6+
"devDependencies": {
7+
"@tsconfig/node22": "^22.0.2",
8+
"@types/aws-lambda": "^8.10.148",
9+
"@types/jest": "^30.0.0",
10+
"jest": "^30.2.0",
11+
"jest-mock-extended": "^4.0.0",
12+
"typescript": "^5.8.3"
13+
},
14+
"name": "nhs-notify-supplier-api-mi-updates-transformer",
15+
"private": true,
16+
"scripts": {
17+
"lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --entry-names=[name] --outdir=dist src/index.ts",
18+
"lint": "eslint .",
19+
"lint:fix": "eslint . --fix",
20+
"test:unit": "jest",
21+
"typecheck": "tsc --noEmit"
22+
},
23+
"version": "0.0.1"
24+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { SNSClient } from '@aws-sdk/client-sns';
2+
import * as pino from 'pino';
3+
import { createHandler } from '../mi-updates-transformer';
4+
import { KinesisStreamEvent, Context, KinesisStreamRecordPayload } from 'aws-lambda';
5+
import { mockDeep } from 'jest-mock-extended';
6+
import { Deps } from '../deps';
7+
import { EnvVars } from '../env';
8+
import { MI } from '@internal/datastore';
9+
import { mapMIToCloudEvent } from '../mappers/mi-mapper';
10+
11+
// Make crypto return consistent values, since we're calling it in both prod and test code and comparing the values
12+
const realCrypto = jest.requireActual('crypto');
13+
const randomBytes: Record<string, any> = {'8': realCrypto.randomBytes(8), '16': realCrypto.randomBytes(16)}
14+
jest.mock('crypto', () => ({
15+
randomUUID: () => '4616b2d9-b7a5-45aa-8523-fa7419626b69',
16+
randomBytes: (size: number) => randomBytes[String(size)]
17+
}));
18+
19+
describe('mi-updates-transformer Lambda', () => {
20+
21+
const mockedDeps: jest.Mocked<Deps> = {
22+
snsClient: { send: jest.fn()} as unknown as SNSClient,
23+
logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
24+
env: {
25+
EVENT_PUB_SNS_TOPIC_ARN: 'arn:aws:sns:region:account:topic',
26+
} as unknown as EnvVars
27+
} as Deps;
28+
29+
beforeEach(() => {
30+
jest.useFakeTimers();
31+
});
32+
33+
afterEach(() => {
34+
jest.useRealTimers();
35+
})
36+
37+
it('processes Kinesis events and publishes them to SNS', async () => {
38+
39+
const handler = createHandler(mockedDeps);
40+
const miEvents = generateMIEvents(1);
41+
const expectedEntries = [expect.objectContaining({Message: JSON.stringify(mapMIToCloudEvent(miEvents[0]))})];
42+
43+
await handler(generateKinesisEvent(miEvents), mockDeep<Context>(), jest.fn());
44+
45+
expect(mockedDeps.snsClient.send).toHaveBeenCalledWith(expect.objectContaining({
46+
input: expect.objectContaining({
47+
TopicArn: 'arn:aws:sns:region:account:topic',
48+
PublishBatchRequestEntries: expectedEntries
49+
})
50+
}));
51+
});
52+
53+
it ('batches mutiple records into a single call to SNS', async () => {
54+
55+
const handler = createHandler(mockedDeps);
56+
const miEvents = generateMIEvents(10);
57+
const expectedEntries = miEvents.map(miEvent =>
58+
expect.objectContaining({Message: JSON.stringify(mapMIToCloudEvent(miEvent))}));
59+
60+
await handler(generateKinesisEvent(miEvents), mockDeep<Context>(), jest.fn());
61+
62+
expect(mockedDeps.snsClient.send).toHaveBeenCalledWith(expect.objectContaining({
63+
input: expect.objectContaining({
64+
TopicArn: 'arn:aws:sns:region:account:topic',
65+
PublishBatchRequestEntries: expectedEntries
66+
})
67+
}));
68+
});
69+
70+
71+
it('splits more than 10 records into multiple SNS calls', async () => {
72+
73+
const handler = createHandler(mockedDeps);
74+
const miEvents = generateMIEvents(21);
75+
const expectedEntries = [
76+
miEvents.slice(0, 10).map(miEvent =>
77+
expect.objectContaining({Message: JSON.stringify(mapMIToCloudEvent(miEvent))})),
78+
miEvents.slice(10, 20).map(miEvent =>
79+
expect.objectContaining({Message: JSON.stringify(mapMIToCloudEvent(miEvent))})),
80+
miEvents.slice(20).map(miEvent =>
81+
expect.objectContaining({Message: JSON.stringify(mapMIToCloudEvent(miEvent))})),
82+
];
83+
84+
await handler(generateKinesisEvent(miEvents), mockDeep<Context>(), jest.fn());
85+
86+
expect(mockedDeps.snsClient.send).toHaveBeenNthCalledWith(1,
87+
expect.objectContaining({
88+
input: expect.objectContaining({
89+
TopicArn: 'arn:aws:sns:region:account:topic',
90+
PublishBatchRequestEntries: expectedEntries[0]
91+
})}));
92+
expect(mockedDeps.snsClient.send).toHaveBeenNthCalledWith(2,
93+
expect.objectContaining({
94+
input: expect.objectContaining({
95+
TopicArn: 'arn:aws:sns:region:account:topic',
96+
PublishBatchRequestEntries: expectedEntries[1]
97+
})
98+
})
99+
);
100+
101+
expect(mockedDeps.snsClient.send).toHaveBeenNthCalledWith(3,
102+
expect.objectContaining({
103+
input: expect.objectContaining({
104+
TopicArn: 'arn:aws:sns:region:account:topic',
105+
PublishBatchRequestEntries: expectedEntries[2]
106+
})
107+
})
108+
);
109+
});
110+
111+
function generateKinesisEvent(miEvents: Object[]): KinesisStreamEvent {
112+
const records = miEvents
113+
.map(mi => Buffer.from(JSON.stringify(mi), 'utf-8').toString('base64'))
114+
.map(data => ({ kinesis: { data }} as unknown as KinesisStreamRecordPayload));
115+
return { Records: records } as unknown as KinesisStreamEvent;
116+
}
117+
function generateMIEvents(numMIEvents: number): MI[] {
118+
return Array.from(Array(numMIEvents).keys())
119+
.map(i => ({
120+
id: String(i + 1),
121+
lineItem: 'lineItem' + (i + 1),
122+
timestamp: new Date().toISOString(),
123+
quantity: 100 + i,
124+
supplierId: 'supplier' + (i + 1),
125+
createdAt: new Date().toISOString(),
126+
updatedAt: new Date().toISOString(),
127+
ttl: Math.floor(Date.now() / 1000) + 3600,
128+
specificationId: 'spec1',
129+
groupId: 'group1',
130+
stockRemaining: 500 - i,
131+
}));
132+
}
133+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import pino from 'pino';
2+
import { envVars, EnvVars } from "./env";
3+
import { SNSClient } from "@aws-sdk/client-sns";
4+
5+
export type Deps = {
6+
snsClient: SNSClient;
7+
logger: pino.Logger;
8+
env: EnvVars;
9+
};
10+
11+
function createSNSClient(): SNSClient {
12+
return new SNSClient({});
13+
}
14+
15+
16+
export function createDependenciesContainer(): Deps {
17+
const log = pino();
18+
19+
return {
20+
snsClient: createSNSClient(),
21+
logger: log,
22+
env: envVars
23+
};
24+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {z} from 'zod';
2+
3+
const EnvVarsSchema = z.object({
4+
EVENT_PUB_SNS_TOPIC_ARN: z.string(),
5+
});
6+
7+
export type EnvVars = z.infer<typeof EnvVarsSchema>;
8+
9+
export const envVars = EnvVarsSchema.parse(process.env);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { createHandler } from "./mi-updates-transformer";
2+
import { createDependenciesContainer } from "./deps";
3+
4+
const container = createDependenciesContainer();
5+
6+
export const handler = createHandler(container);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { $MISubmittedEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src";
2+
import { mapMIToCloudEvent } from "../mi-mapper";
3+
import { MI } from "@internal/datastore";
4+
5+
describe('mi-mapper', () => {
6+
it('maps an MI to an MI event', async() => {
7+
const mi: MI = {
8+
id: 'id1',
9+
lineItem: 'lineItem1',
10+
timestamp: '2025-11-24T15:55:18Z',
11+
quantity: 100,
12+
supplierId: 'supplier1',
13+
createdAt: '2025-11-24T15:55:18Z',
14+
updatedAt: '2025-11-24T15:55:18Z',
15+
ttl: 1735687518,
16+
specificationId: 'spec1',
17+
groupId: 'group1',
18+
stockRemaining: 500
19+
};
20+
jest.useFakeTimers().setSystemTime(new Date('2025-11-24T15:55:18Z'));
21+
const event = mapMIToCloudEvent(mi);
22+
console.log("Mapped Event: ", event);
23+
24+
// Check it conforms to the MI event schema - parse will throw an error if not
25+
$MISubmittedEvent.parse(event);
26+
expect(event.type).toBe('uk.nhs.notify.supplier-api.mi.SUBMITTED.v1');
27+
expect(event.dataschema).toBe('https://notify.nhs.uk/cloudevents/schemas/supplier-api/mi.SUBMITTED.1.0.0.schema.json');
28+
expect(event.subject).toBe('mi/id1');
29+
expect(event.time).toBe('2025-11-24T15:55:18.000Z');
30+
expect(event.recordedtime).toBe('2025-11-24T15:55:18.000Z');
31+
expect(event.data).toEqual({
32+
id: 'id1',
33+
lineItem: 'lineItem1',
34+
timestamp: '2025-11-24T15:55:18Z',
35+
quantity: 100,
36+
specificationId: 'spec1',
37+
groupId: 'group1',
38+
stockRemaining: 500,
39+
supplierId: 'supplier1',
40+
createdAt: '2025-11-24T15:55:18Z',
41+
updatedAt: '2025-11-24T15:55:18Z'
42+
});
43+
44+
jest.useRealTimers();
45+
})
46+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { MI } from '@internal/datastore';
2+
import { MISubmittedEvent } from '@nhsdigital/nhs-notify-event-schemas-supplier-api/src';
3+
import { randomUUID, randomBytes } from 'crypto';
4+
5+
export function mapMIToCloudEvent(mi: MI): MISubmittedEvent {
6+
const now = new Date().toISOString();
7+
const eventId = randomUUID();
8+
return {
9+
specversion: '1.0',
10+
id: eventId,
11+
type: `uk.nhs.notify.supplier-api.mi.SUBMITTED.v1`,
12+
dataschema: `https://notify.nhs.uk/cloudevents/schemas/supplier-api/mi.SUBMITTED.1.0.0.schema.json`,
13+
source: '/data-plane/supplier-api/mi',
14+
subject: 'mi/' + mi.id,
15+
16+
data: {
17+
id: mi.id as MISubmittedEvent['data']['id'],
18+
lineItem: mi.lineItem,
19+
timestamp: mi.timestamp,
20+
quantity: mi.quantity,
21+
supplierId: mi.supplierId,
22+
createdAt: mi.createdAt,
23+
updatedAt: mi.updatedAt,
24+
specificationId: mi.specificationId,
25+
groupId: mi.groupId,
26+
stockRemaining: mi.stockRemaining
27+
},
28+
time: now,
29+
traceparent: `00-${randomBytes(16).toString('hex')}-${randomBytes(8).toString('hex')}-01`,
30+
recordedtime: now,
31+
severitynumber: 2,
32+
severitytext: 'INFO',
33+
};
34+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
2+
import { Handler, KinesisStreamEvent } from 'aws-lambda';
3+
import { mapMIToCloudEvent } from './mappers/mi-mapper';
4+
import { PublishBatchCommand, PublishBatchRequestEntry } from '@aws-sdk/client-sns';
5+
import { MISubmittedEvent } from '@nhsdigital/nhs-notify-event-schemas-supplier-api/src';
6+
import { Deps } from './deps';
7+
// SNS PublishBatchCommand supports up to 10 messages per batch
8+
const BATCH_SIZE = 10;
9+
10+
export function createHandler(deps: Deps): Handler<KinesisStreamEvent> {
11+
return async(streamEvent: KinesisStreamEvent) => {
12+
deps.logger.info({description: 'Received event', streamEvent});
13+
14+
const cloudEvents: MISubmittedEvent[] = streamEvent.Records
15+
.map((record) => {
16+
// Kinesis data is base64 encoded
17+
const payload = Buffer.from(record.kinesis.data, 'base64').toString('utf-8');
18+
return JSON.parse(payload);
19+
})
20+
.map(mapMIToCloudEvent);
21+
22+
23+
for (let batch of generateBatches(cloudEvents)) {
24+
await deps.snsClient.send(new PublishBatchCommand({
25+
TopicArn: deps.env.EVENT_PUB_SNS_TOPIC_ARN,
26+
PublishBatchRequestEntries: batch.map(buildMessage),
27+
}));
28+
}
29+
}
30+
31+
function* generateBatches(events: MISubmittedEvent[]) {
32+
for (let i = 0; i < events.length; i += BATCH_SIZE) {
33+
yield events.slice(i, i + BATCH_SIZE);
34+
}
35+
}
36+
37+
function buildMessage(event: MISubmittedEvent): PublishBatchRequestEntry {
38+
return {
39+
Id: event.id,
40+
Message: JSON.stringify(event),
41+
}
42+
}
43+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "@tsconfig/node22/tsconfig.json",
3+
"include": [
4+
"src/**/*",
5+
"jest.config.ts"
6+
]
7+
}

0 commit comments

Comments
 (0)