Skip to content

Commit 78cd159

Browse files
create allocate lambda
1 parent f288f54 commit 78cd159

File tree

15 files changed

+1004
-53
lines changed

15 files changed

+1004
-53
lines changed

infrastructure/terraform/components/api/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ No requirements.
4242

4343
| Name | Source | Version |
4444
|------|--------|---------|
45+
| <a name="module_allocate_letter"></a> [allocate\_letter](#module\_allocate\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
4546
| <a name="module_authorizer_lambda"></a> [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
4647
| <a name="module_domain_truststore"></a> [domain\_truststore](#module\_domain\_truststore) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-s3bucket.zip | n/a |
4748
| <a name="module_eventpub"></a> [eventpub](#module\_eventpub) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.26/terraform-eventpub.zip | n/a |
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
module "allocate_letter" {
2+
source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip"
3+
4+
function_name = "allocate_letter"
5+
description = "Allocate a letter to a supplier"
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.allocate_letter_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 = "allocate-letter/dist"
24+
function_include_common = true
25+
handler_function_name = "allocateLetterHandler"
26+
runtime = "nodejs22.x"
27+
memory = 512
28+
timeout = 29
29+
log_level = var.log_level
30+
31+
force_lambda_code_deploy = var.force_lambda_code_deploy
32+
enable_lambda_insights = false
33+
34+
log_destination_arn = local.destination_arn
35+
log_subscription_role_arn = local.acct.log_subscription_role_arn
36+
37+
lambda_env_vars = merge(local.common_lambda_env_vars, {
38+
VARIANT_MAP = jsonencode(var.letter_variant_map)
39+
ALLOCATED_LETTERS_QUEUE_URL = module.sqs_allocated_letters.sqs_queue_url
40+
})
41+
}
42+
43+
data "aws_iam_policy_document" "allocate_letter_lambda" {
44+
statement {
45+
sid = "KMSPermissions"
46+
effect = "Allow"
47+
48+
actions = [
49+
"kms:Decrypt",
50+
"kms:GenerateDataKey",
51+
]
52+
53+
resources = [
54+
module.kms.key_arn,
55+
]
56+
}
57+
58+
statement {
59+
sid = "AllowSQSRead"
60+
effect = "Allow"
61+
62+
actions = [
63+
"sqs:ReceiveMessage",
64+
"sqs:DeleteMessage",
65+
"sqs:GetQueueAttributes"
66+
]
67+
68+
resources = [
69+
module.sqs_letter_updates.sqs_queue_arn
70+
]
71+
}
72+
73+
statement {
74+
sid = "AllowSQSWrite"
75+
effect = "Allow"
76+
77+
actions = [
78+
"sqs:SendMessage"
79+
]
80+
81+
resources = [
82+
module.sqs_allocated_letters.sqs_queue_arn
83+
]
84+
}
85+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist

lambdas/allocate-letter/.gitignore

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: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
export const baseJestConfig = {
2+
preset: "ts-jest",
3+
extensionsToTreatAsEsm: [".ts"],
4+
transform: {
5+
"^.+\\.ts$": [
6+
"ts-jest",
7+
{
8+
useESM: true,
9+
},
10+
],
11+
},
12+
13+
// Automatically clear mock calls, instances, contexts and results before every test
14+
clearMocks: true,
15+
16+
// Indicates whether the coverage information should be collected while executing the test
17+
collectCoverage: true,
18+
19+
// The directory where Jest should output its coverage files
20+
coverageDirectory: "./.reports/unit/coverage",
21+
22+
// Indicates which provider should be used to instrument code for coverage
23+
coverageProvider: "babel",
24+
25+
coverageThreshold: {
26+
global: {
27+
branches: 100,
28+
functions: 100,
29+
lines: 100,
30+
statements: -10,
31+
},
32+
},
33+
34+
coveragePathIgnorePatterns: ["/__tests__/"],
35+
testPathIgnorePatterns: [".build"],
36+
testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],
37+
38+
// Use this configuration option to add custom reporters to Jest
39+
reporters: [
40+
"default",
41+
[
42+
"jest-html-reporter",
43+
{
44+
pageTitle: "Test Report",
45+
outputPath: "./.reports/unit/test-report.html",
46+
includeFailureMsg: true,
47+
},
48+
],
49+
],
50+
51+
// The test environment that will be used for testing
52+
testEnvironment: "jsdom",
53+
};
54+
55+
const utilsJestConfig = {
56+
...baseJestConfig,
57+
58+
testEnvironment: "node",
59+
60+
coveragePathIgnorePatterns: [
61+
...(baseJestConfig.coveragePathIgnorePatterns ?? []),
62+
"zod-validators.ts",
63+
],
64+
};
65+
66+
export default utilsJestConfig;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"dependencies": {
3+
"@aws-sdk/client-dynamodb": "^3.858.0",
4+
"@aws-sdk/lib-dynamodb": "^3.858.0",
5+
"@internal/datastore": "*",
6+
"@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^2.0.1",
7+
"@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5",
8+
"@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.8",
9+
"@types/aws-lambda": "^8.10.148",
10+
"aws-lambda": "^1.0.7",
11+
"esbuild": "^0.27.2",
12+
"pino": "^9.7.0",
13+
"zod": "^4.1.11"
14+
},
15+
"devDependencies": {
16+
"@tsconfig/node22": "^22.0.2",
17+
"@types/aws-lambda": "^8.10.148",
18+
"@types/jest": "^30.0.0",
19+
"jest": "^30.2.0",
20+
"jest-mock-extended": "^4.0.0",
21+
"ts-jest": "^29.4.0",
22+
"typescript": "^5.8.3"
23+
},
24+
"name": "nhs-notify-supplier-api-allocate-letter",
25+
"private": true,
26+
"scripts": {
27+
"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",
28+
"lint": "eslint .",
29+
"lint:fix": "eslint . --fix",
30+
"test:unit": "jest",
31+
"typecheck": "tsc --noEmit"
32+
},
33+
"version": "0.0.1"
34+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { Deps } from "lambdas/allocate-letter/src/config/deps";
2+
3+
describe("createDependenciesContainer", () => {
4+
const env = {
5+
LETTERS_TABLE_NAME: "LettersTable",
6+
LETTER_TTL_HOURS: 12_960,
7+
};
8+
9+
beforeEach(() => {
10+
jest.clearAllMocks();
11+
jest.resetModules();
12+
13+
// pino
14+
jest.mock("pino", () => ({
15+
__esModule: true,
16+
default: jest.fn(() => ({
17+
info: jest.fn(),
18+
error: jest.fn(),
19+
warn: jest.fn(),
20+
debug: jest.fn(),
21+
})),
22+
}));
23+
24+
// Repo client
25+
jest.mock("@internal/datastore", () => ({
26+
LetterRepository: jest.fn(),
27+
}));
28+
29+
// Env
30+
jest.mock("../env", () => ({ envVars: env }));
31+
});
32+
33+
test("constructs deps and wires repository config correctly", async () => {
34+
// get current mock instances
35+
const pinoMock = jest.requireMock("pino");
36+
const { LetterRepository } = jest.requireMock("@internal/datastore");
37+
38+
// eslint-disable-next-line @typescript-eslint/no-require-imports
39+
const { createDependenciesContainer } = require("../deps");
40+
const deps: Deps = createDependenciesContainer();
41+
42+
expect(pinoMock.default).toHaveBeenCalledTimes(1);
43+
44+
expect(LetterRepository).toHaveBeenCalledTimes(1);
45+
const letterRepoCtorArgs = LetterRepository.mock.calls[0];
46+
expect(letterRepoCtorArgs[2]).toEqual({
47+
lettersTableName: "LettersTable",
48+
lettersTtlHours: 12_960,
49+
});
50+
51+
expect(deps.env).toEqual(env);
52+
});
53+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { ZodError } from "zod";
2+
/* eslint-disable @typescript-eslint/no-require-imports */
3+
/* Allow require imports to enable re-import of modules */
4+
5+
describe("lambdaEnv", () => {
6+
const OLD_ENV = process.env;
7+
8+
beforeEach(() => {
9+
jest.resetModules(); // Clears cached modules
10+
process.env = { ...OLD_ENV }; // Clone original env
11+
});
12+
13+
afterAll(() => {
14+
process.env = OLD_ENV; // Restore
15+
});
16+
17+
it("should load all environment variables successfully", () => {
18+
process.env.LETTERS_TABLE_NAME = "letters-table";
19+
process.env.LETTER_TTL_HOURS = "12960";
20+
process.env.VARIANT_MAP = `{
21+
"lv1": {
22+
"supplierId": "supplier1",
23+
"specId": "spec1"
24+
}
25+
}`;
26+
27+
const { envVars } = require("../env");
28+
29+
expect(envVars).toEqual({
30+
LETTERS_TABLE_NAME: "letters-table",
31+
LETTER_TTL_HOURS: 12_960,
32+
VARIANT_MAP: {
33+
lv1: {
34+
supplierId: "supplier1",
35+
specId: "spec1",
36+
},
37+
},
38+
});
39+
});
40+
41+
it("should throw if a required env var is missing", () => {
42+
process.env.LETTERS_TABLE_NAME = "table";
43+
process.env.LETTER_TTL_HOURS = "12960";
44+
process.env.VARIANT_MAP = undefined;
45+
46+
expect(() => require("../env")).toThrow(ZodError);
47+
});
48+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2+
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
3+
import { SQSClient } from "@aws-sdk/client-sqs";
4+
import pino from "pino";
5+
import { LetterRepository } from "@internal/datastore";
6+
import { EnvVars, envVars } from "./env";
7+
8+
export type Deps = {
9+
letterRepo: LetterRepository;
10+
logger: pino.Logger;
11+
env: EnvVars;
12+
sqsClient: SQSClient;
13+
};
14+
15+
function createDocumentClient(): DynamoDBDocumentClient {
16+
const ddbClient = new DynamoDBClient({});
17+
return DynamoDBDocumentClient.from(ddbClient);
18+
}
19+
20+
function createLetterRepository(
21+
log: pino.Logger,
22+
// eslint-disable-next-line @typescript-eslint/no-shadow
23+
envVars: EnvVars,
24+
): LetterRepository {
25+
const config = {
26+
lettersTableName: envVars.LETTERS_TABLE_NAME,
27+
lettersTtlHours: envVars.LETTER_TTL_HOURS,
28+
};
29+
30+
return new LetterRepository(createDocumentClient(), log, config);
31+
}
32+
33+
export function createDependenciesContainer(): Deps {
34+
const log = pino();
35+
36+
return {
37+
letterRepo: createLetterRepository(log, envVars),
38+
logger: log,
39+
env: envVars,
40+
sqsClient: new SQSClient({}),
41+
};
42+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { z } from "zod";
2+
3+
const LetterVariantSchema = z.record(
4+
z.string(),
5+
z.object({
6+
supplierId: z.string(),
7+
specId: z.string(),
8+
}),
9+
);
10+
export type LetterVariant = z.infer<typeof LetterVariantSchema>;
11+
12+
const EnvVarsSchema = z.object({
13+
LETTERS_TABLE_NAME: z.string(),
14+
LETTER_TTL_HOURS: z.coerce.number().int(),
15+
VARIANT_MAP: z.string().transform((str, _) => {
16+
const parsed = JSON.parse(str);
17+
return LetterVariantSchema.parse(parsed);
18+
}),
19+
});
20+
21+
export type EnvVars = z.infer<typeof EnvVarsSchema>;
22+
23+
export const envVars = EnvVarsSchema.parse(process.env);

0 commit comments

Comments
 (0)