Skip to content

Commit 02c41d4

Browse files
stevebuxnhsd-david-wass
authored andcommitted
CCM-12937 Letter updates transformer lambda
1 parent 5bc4e0e commit 02c41d4

File tree

15 files changed

+467
-0
lines changed

15 files changed

+467
-0
lines changed

infrastructure/terraform/components/api/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ No requirements.
4545
| <a name="module_kms"></a> [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-kms.zip | n/a |
4646
| <a name="module_letter_status_update"></a> [letter\_status\_update](#module\_letter\_status\_update) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a |
4747
| <a name="module_letter_status_updates_queue"></a> [letter\_status\_updates\_queue](#module\_letter\_status\_updates\_queue) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a |
48+
| <a name="module_letter_stream_forwarder"></a> [letter\_stream\_forwarder](#module\_letter\_stream\_forwarder) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-lambda.zip | n/a |
4849
| <a name="module_letter_updates_transformer"></a> [letter\_updates\_transformer](#module\_letter\_updates\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-lambda.zip | n/a |
4950
| <a name="module_logging_bucket"></a> [logging\_bucket](#module\_logging\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-s3bucket.zip | n/a |
5051
| <a name="module_patch_letter"></a> [patch\_letter](#module\_patch\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-lambda.zip | n/a |

infrastructure/terraform/components/api/ddb_table_letters.tf

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
resource "aws_dynamodb_table" "letters" {
22
name = "${local.csi}-letters"
33
billing_mode = "PAY_PER_REQUEST"
4+
stream_enabled = true
5+
stream_view_type = "NEW_AND_OLD_IMAGES"
46

57
hash_key = "supplierId"
68
range_key = "id"
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
module "letter_stream_forwarder" {
2+
source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-lambda.zip"
3+
4+
function_name = "letter-stream-forwarder"
5+
description = "Kinesis stream forwarder for DDB letter status updates"
6+
7+
aws_account_id = var.aws_account_id
8+
component = var.component
9+
environment = var.environment
10+
project = var.project
11+
region = var.region
12+
group = var.group
13+
14+
log_retention_in_days = var.log_retention_in_days
15+
kms_key_arn = module.kms.key_arn
16+
17+
iam_policy_document = {
18+
body = data.aws_iam_policy_document.letter_stream_forwarder_lambda.json
19+
}
20+
21+
function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
22+
function_code_base_path = local.aws_lambda_functions_dir_path
23+
function_code_dir = "letter-stream-forwarder/dist"
24+
function_include_common = true
25+
handler_function_name = "handler"
26+
runtime = "nodejs22.x"
27+
memory = 128
28+
timeout = 5
29+
log_level = var.log_level
30+
31+
force_lambda_code_deploy = var.force_lambda_code_deploy
32+
enable_lambda_insights = false
33+
34+
send_to_firehose = true
35+
log_destination_arn = local.destination_arn
36+
log_subscription_role_arn = local.acct.log_subscription_role_arn
37+
38+
lambda_env_vars = merge(local.common_lambda_env_vars, {
39+
LETTER_CHANGE_STREAM_ARN = "${aws_kinesis_stream.letter_change_stream.arn}"
40+
})
41+
}
42+
43+
data "aws_iam_policy_document" "letter_stream_forwarder_lambda" {
44+
45+
statement {
46+
sid = "AllowDynamoDBStream"
47+
effect = "Allow"
48+
49+
actions = [
50+
"dynamodb:GetRecords",
51+
"dynamodb:GetShardIterator",
52+
"dynamodb:DescribeStream",
53+
"dynamodb:ListStreams",
54+
]
55+
56+
resources = [
57+
"${aws_dynamodb_table.letters.arn}/stream/*"
58+
]
59+
}
60+
61+
statement {
62+
sid = "AllowKinesisPut"
63+
effect = "Allow"
64+
65+
actions = [
66+
"kinesis:DescribeStream",
67+
"kinesis:PutRecord",
68+
]
69+
70+
resources = [
71+
aws_kinesis_stream.letter_change_stream.arn
72+
]
73+
}
74+
}

internal/events/src/events/__tests__/mi-events.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe("MI event validations", () => {
2626
datacontenttype: "application/json",
2727
dataschema:
2828
"https://notify.nhs.uk/cloudevents/schemas/supplier-api/mi.SUBMITTED.1.0.0.schema.json",
29+
dataschemaversion: "1.0.0",
2930
subject: "mi/mi-test-001",
3031
data: expect.objectContaining({
3132
id: "mi-test-001",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
coverage
2+
node_modules
3+
dist
4+
.reports
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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"dependencies": {
3+
"@aws-sdk/client-kinesis": "^3.0.0",
4+
"aws-lambda": "^1.0.7"
5+
},
6+
"devDependencies": {
7+
"@types/aws-lambda": "^8.10.119",
8+
"typescript": "^5.0.0"
9+
},
10+
"main": "src/index.ts",
11+
"name": "letter-stream-forwarder",
12+
"private": true,
13+
"scripts": {
14+
"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",
15+
"lint": "eslint .",
16+
"lint:fix": "eslint . --fix",
17+
"test:unit": "jest",
18+
"typecheck": "tsc --noEmit"
19+
},
20+
"version": "0.1.0"
21+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { KinesisClient } from "@aws-sdk/client-kinesis";
2+
import * as pino from "pino";
3+
import { mockDeep } from "jest-mock-extended";
4+
import { DynamoDBStreamEvent, Context } from "aws-lambda";
5+
import { Deps } from "../deps";
6+
import { EnvVars } from "../env";
7+
import { createHandler } from "../letter-stream-forwarder";
8+
9+
describe("letter-stream-forwarder Lambda", () => {
10+
11+
const mockedDeps: jest.Mocked<Deps> = {
12+
kinesisClient: { send: jest.fn()} as unknown as KinesisClient,
13+
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
14+
env: {
15+
LETTER_CHANGE_STREAM_ARN: "test-stream.arn",
16+
} as unknown as EnvVars
17+
} as Deps;
18+
19+
beforeEach(() => {
20+
jest.clearAllMocks();
21+
});
22+
23+
24+
it("forwards status changes to Kinesis", async () => {
25+
const event: DynamoDBStreamEvent = {
26+
Records: [
27+
{
28+
eventName: "MODIFY",
29+
dynamodb: {
30+
Keys: { id: { S: "123" } },
31+
OldImage: buildValidLetter(),
32+
NewImage: {...buildValidLetter(), status: { S: "ACCEPTED" } },
33+
},
34+
},
35+
],
36+
};
37+
38+
const handler = createHandler(mockedDeps);
39+
await handler(event, mockDeep<Context>(), jest.fn());
40+
41+
expect(mockedDeps.kinesisClient.send).toHaveBeenCalledWith(
42+
expect.objectContaining({
43+
input: expect.objectContaining({
44+
StreamARN: "test-stream.arn",
45+
PartitionKey: "123",
46+
}),
47+
})
48+
);
49+
});
50+
51+
52+
it("does not forward invalid status changes", async () => {
53+
const event: DynamoDBStreamEvent = {
54+
Records: [
55+
{
56+
eventName: "MODIFY",
57+
dynamodb: {
58+
Keys: { id: { S: "123" } },
59+
OldImage: {...buildValidLetter(), status: { S: "CANCELLED" } },
60+
NewImage: {...buildValidLetter(), status: { S: "PRINTED" } },
61+
},
62+
},
63+
],
64+
};
65+
66+
const handler = createHandler(mockedDeps);
67+
await handler(event, mockDeep<Context>(), jest.fn());
68+
69+
expect(mockedDeps.kinesisClient.send).not.toHaveBeenCalled();
70+
});
71+
72+
it("forwards to Kinesis if a reason code is added", async () => {
73+
const event: DynamoDBStreamEvent = {
74+
Records: [
75+
{
76+
eventName: "MODIFY",
77+
dynamodb: {
78+
Keys: { id: { S: "123" } },
79+
OldImage: buildValidLetter(),
80+
NewImage: {...buildValidLetter(), reasonCode: {S: "r1"} },
81+
},
82+
},
83+
],
84+
};
85+
86+
const handler = createHandler(mockedDeps);
87+
await handler(event, mockDeep<Context>(), jest.fn());
88+
89+
expect(mockedDeps.kinesisClient.send).toHaveBeenCalledWith(
90+
expect.objectContaining({
91+
input: expect.objectContaining({
92+
StreamARN: "test-stream.arn",
93+
PartitionKey: "123",
94+
}),
95+
})
96+
);
97+
});
98+
99+
100+
it("forwards to Kinesis if a reason code is changed", async () => {
101+
const event: DynamoDBStreamEvent = {
102+
Records: [
103+
{
104+
eventName: "MODIFY",
105+
dynamodb: {
106+
Keys: { id: { S: "123" } },
107+
OldImage: {...buildValidLetter(), reasonCode: {S: "r1"} },
108+
NewImage: {...buildValidLetter(), reasonCode: {S: "r2"} },
109+
},
110+
},
111+
],
112+
};
113+
114+
const handler = createHandler(mockedDeps);
115+
await handler(event, mockDeep<Context>(), jest.fn());
116+
117+
expect(mockedDeps.kinesisClient.send).toHaveBeenCalledWith(
118+
expect.objectContaining({
119+
input: expect.objectContaining({
120+
StreamARN: "test-stream.arn",
121+
PartitionKey: "123",
122+
}),
123+
})
124+
);
125+
});
126+
127+
it("does not forward if neither status nor reason code changed", async () => {
128+
const event: DynamoDBStreamEvent = {
129+
Records: [
130+
{
131+
eventName: "MODIFY",
132+
dynamodb: {
133+
Keys: { id: { S: "123" } },
134+
OldImage: buildValidLetter(),
135+
NewImage: buildValidLetter(),
136+
},
137+
},
138+
],
139+
};
140+
141+
const handler = createHandler(mockedDeps);
142+
await handler(event, mockDeep<Context>(), jest.fn());
143+
144+
expect(mockedDeps.kinesisClient.send).not.toHaveBeenCalled();
145+
});
146+
147+
it("does not forward non-MODIFY events", async () => {
148+
const event: DynamoDBStreamEvent = {
149+
Records: [
150+
{
151+
eventName: "INSERT",
152+
dynamodb: {
153+
Keys: { id: { S: "123" } },
154+
NewImage: buildValidLetter(),
155+
},
156+
},
157+
],
158+
};
159+
160+
const handler = createHandler(mockedDeps);
161+
await handler(event, mockDeep<Context>(), jest.fn());
162+
163+
expect(mockedDeps.kinesisClient.send).not.toHaveBeenCalled();
164+
});
165+
166+
167+
it("does not forward invalid letter data", async () => {
168+
const event: DynamoDBStreamEvent = {
169+
Records: [
170+
{
171+
eventName: "MODIFY",
172+
dynamodb: {
173+
Keys: { id: { S: "123" } },
174+
OldImage: buildInvalidLetter(),
175+
NewImage: {...buildInvalidLetter(), status: { S: "ACCEPTED" } },
176+
},
177+
}
178+
],
179+
};
180+
181+
const handler = createHandler(mockedDeps);
182+
await expect(handler(event, mockDeep<Context>(), jest.fn())).rejects.toThrow();
183+
184+
expect(mockedDeps.kinesisClient.send).not.toHaveBeenCalled();
185+
});
186+
187+
function buildValidLetter() {
188+
return {
189+
id: {S: "123"},
190+
status: {S: "PENDING"},
191+
specificationId: {S: "spec1"},
192+
groupId: {S: "group1"},
193+
};
194+
}
195+
196+
function buildInvalidLetter() {
197+
return {
198+
id: {S: "123"},
199+
status: {S: "PENDING"},
200+
};
201+
}
202+
});

0 commit comments

Comments
 (0)