Skip to content

Commit 02c123c

Browse files
committed
Implement supplier-config-ingress lambda
1 parent d4457f8 commit 02c123c

12 files changed

Lines changed: 738 additions & 265 deletions

File tree

internal/datastore/src/__test__/supplier-config-repository.test.ts

Lines changed: 328 additions & 215 deletions
Large diffs are not rendered by default.

internal/datastore/src/supplier-config-repository.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
DynamoDBDocumentClient,
33
GetCommand,
44
QueryCommand,
5+
UpdateCommand,
56
} from "@aws-sdk/lib-dynamodb";
67
import {
78
$LetterVariant,
@@ -17,11 +18,14 @@ import {
1718
SupplierPack,
1819
VolumeGroup,
1920
} from "@nhsdigital/nhs-notify-event-schemas-supplier-config";
21+
import { SupplierConfigEntity } from "./types";
2022

2123
export type SupplierConfigRepositoryConfig = {
2224
supplierConfigTableName: string;
2325
};
2426

27+
const reservedWords = new Set(["name", "type", "status"]);
28+
2529
export class SupplierConfigRepository {
2630
constructor(
2731
readonly ddbClient: DynamoDBDocumentClient,
@@ -141,4 +145,52 @@ export class SupplierConfigRepository {
141145
}
142146
return $PackSpecification.parse(result.Item);
143147
}
148+
149+
async upsertSupplierConfig(
150+
entity: SupplierConfigEntity,
151+
supplierConfig: { id: string },
152+
): Promise<"UPDATED" | "CREATED"> {
153+
const { id, ...fieldsToUpdate } = supplierConfig;
154+
const updateExpression =
155+
SupplierConfigRepository.buildUpdateExpression(fieldsToUpdate);
156+
157+
const result = await this.ddbClient.send(
158+
new UpdateCommand({
159+
TableName: this.config.supplierConfigTableName,
160+
Key: { pk: `ENTITY#${entity}`, sk: `ID#${id}` },
161+
...updateExpression,
162+
ReturnValues: "ALL_OLD",
163+
}),
164+
);
165+
return result.Attributes?.pk ? "UPDATED" : "CREATED";
166+
}
167+
168+
static escapeReservedWord(key: string) {
169+
return reservedWords.has(key) ? `#${key}` : key;
170+
}
171+
172+
static buildUpdateExpression(fieldsToUpdate: Record<string, any>) {
173+
const expressionAttributeNames = Object.fromEntries(
174+
Object.keys(fieldsToUpdate)
175+
.filter((key) => reservedWords.has(key))
176+
.map((key) => [`#${key}`, key]),
177+
);
178+
179+
const expressionAttributeValues = Object.fromEntries(
180+
Object.entries(fieldsToUpdate).map(([key, value]) => [`:${key}`, value]),
181+
);
182+
183+
const updateExpression = `set ${Object.keys(fieldsToUpdate)
184+
.map(
185+
(key) =>
186+
`${SupplierConfigRepository.escapeReservedWord(key)} = :${key}`,
187+
)
188+
.join(", ")}`;
189+
190+
return {
191+
ExpressionAttributeNames: expressionAttributeNames,
192+
ExpressionAttributeValues: expressionAttributeValues,
193+
UpdateExpression: updateExpression,
194+
};
195+
}
144196
}

internal/datastore/src/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,14 @@ export const $DailyAllocation = z
158158
});
159159

160160
export type DailyAllocation = z.infer<typeof $DailyAllocation>;
161+
162+
export const $SupplierConfigEntity = z.enum([
163+
"letter-variant",
164+
"volume-group",
165+
"supplier-allocation",
166+
"supplier",
167+
"pack-specification",
168+
"supplier-pack",
169+
]);
170+
171+
export type SupplierConfigEntity = z.infer<typeof $SupplierConfigEntity>;

lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@ import {
77
$LetterStatusChangeEvent,
88
LetterStatusChangeEvent,
99
} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
10-
import {
11-
SupplierConfigRepository,
12-
SupplierQuotasRepository,
13-
} from "@internal/datastore";
1410
import createSupplierAllocatorHandler from "../allocate-handler";
1511
import * as supplierConfig from "../../services/supplier-config";
1612
import * as supplierQuotas from "../../services/supplier-quotas";
@@ -187,42 +183,20 @@ function setupDefaultMocks() {
187183
describe("createSupplierAllocatorHandler", () => {
188184
let mockSqsClient: jest.Mocked<SQSClient>;
189185
let mockedDeps: jest.Mocked<Deps>;
190-
let mockedSupplierConfigRepo: jest.Mocked<SupplierConfigRepository>;
191-
let mockedSupplierQuotasRepo: jest.Mocked<SupplierQuotasRepository>;
192186
beforeEach(() => {
193187
mockSqsClient = {
194188
send: jest.fn(),
195189
} as unknown as jest.Mocked<SQSClient>;
196190

197-
mockedSupplierConfigRepo = {
198-
ddbClient: {} as any,
199-
config: {} as any,
200-
getLetterVariant: jest.fn(),
201-
getVolumeGroup: jest.fn(),
202-
getSupplierAllocationsForVolumeGroup: jest.fn(),
203-
getSuppliersDetails: jest.fn(),
204-
getSupplierPacksForPackSpecification: jest.fn(),
205-
getPackSpecification: jest.fn(),
206-
};
207-
208-
mockedSupplierQuotasRepo = {
209-
ddbClient: {} as any,
210-
config: {} as any,
211-
getOverallAllocation: jest.fn(),
212-
updateOverallAllocation: jest.fn(),
213-
getDailyAllocation: jest.fn(),
214-
updateDailyAllocation: jest.fn(),
215-
};
216-
217191
mockedDeps = {
218192
logger: { error: jest.fn(), info: jest.fn() } as unknown as pino.Logger,
219193
env: {
220194
SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable",
221195
SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable",
222196
},
223197
sqsClient: mockSqsClient,
224-
supplierConfigRepo: mockedSupplierConfigRepo,
225-
supplierQuotasRepo: mockedSupplierQuotasRepo,
198+
supplierConfigRepo: {} as unknown as Deps["supplierConfigRepo"],
199+
supplierQuotasRepo: {} as unknown as Deps["supplierQuotasRepo"],
226200
};
227201
jest.clearAllMocks();
228202
});

lambdas/supplier-config-ingress/jest.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ const baseJestConfig = {
99
},
1010
],
1111
},
12+
transformIgnorePatterns: [
13+
"node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)",
14+
],
1215

1316
// Automatically clear mock calls, instances, contexts and results before every test
1417
clearMocks: true,

lambdas/supplier-config-ingress/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
{
22
"dependencies": {
3+
"@aws-sdk/client-dynamodb": "^3.858.0",
4+
"@aws-sdk/lib-dynamodb": "^3.1008.0",
5+
"@internal/datastore": "*",
6+
"@internal/helpers": "^0.1.0",
7+
"@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.1.0",
38
"@types/aws-lambda": "^8.10.148",
4-
"esbuild": "^0.27.2"
9+
"esbuild": "^0.27.2",
10+
"pino": "^9.7.0",
11+
"zod": "^4.1.11"
512
},
613
"name": "nhs-notify-supplier-api-supplier-config-ingress",
714
"private": true,
Lines changed: 136 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,144 @@
1-
import type { SQSEvent } from "aws-lambda";
2-
import { supplierConfigHandler } from "..";
1+
import type { SQSEvent, SQSRecord } from "aws-lambda";
2+
import createSupplierConfigIngressHandler from "../handler/supplier-config-ingress-handler";
3+
import { Deps } from "../config/deps";
4+
5+
function createSqsRecord(
6+
messageId: string,
7+
type: string,
8+
data: Record<string, unknown>,
9+
): SQSRecord {
10+
const snsMessage = {
11+
Message: JSON.stringify({ type, data }),
12+
};
13+
return {
14+
messageId,
15+
body: JSON.stringify(snsMessage),
16+
} as unknown as SQSRecord;
17+
}
18+
19+
function createValidSupplierConfig() {
20+
return {
21+
id: "supplier-123",
22+
name: "Supplier supplier-123",
23+
channelType: "LETTER",
24+
dailyCapacity: 2000,
25+
status: "PROD",
26+
};
27+
}
328

429
describe("supplierConfigHandler", () => {
5-
it("returns an empty batchItemFailures list", async () => {
30+
let mockDeps: Deps;
31+
let handler: ReturnType<typeof createSupplierConfigIngressHandler>;
32+
33+
beforeEach(() => {
34+
mockDeps = {
35+
logger: {
36+
error: jest.fn(),
37+
info: jest.fn(),
38+
} as unknown as Deps["logger"],
39+
supplierConfigRepo: {
40+
upsertSupplierConfig: jest.fn().mockResolvedValue("CREATED"),
41+
} as unknown as Deps["supplierConfigRepo"],
42+
env: { SUPPLIER_CONFIG_TABLE_NAME: "test-table" },
43+
};
44+
handler = createSupplierConfigIngressHandler(mockDeps);
45+
});
46+
47+
it("returns an empty batchItemFailures list when there are no records", async () => {
648
const event = { Records: [] } as unknown as SQSEvent;
749

8-
const result = await supplierConfigHandler(event);
50+
const result = await handler(event);
951

1052
expect(result).toEqual({ batchItemFailures: [] });
1153
});
54+
55+
it("upserts supplier config from an SNS message in an SQS record", async () => {
56+
const data = createValidSupplierConfig();
57+
const record = createSqsRecord(
58+
"msg-1",
59+
"uk.nhs.notify.supplier-config.supplier",
60+
data,
61+
);
62+
const event = { Records: [record] } as unknown as SQSEvent;
63+
64+
const result = await handler(event);
65+
66+
expect(result).toEqual({ batchItemFailures: [] });
67+
expect(
68+
mockDeps.supplierConfigRepo.upsertSupplierConfig,
69+
).toHaveBeenCalledWith("supplier", data);
70+
});
71+
72+
it("reports failed records in batchItemFailures", async () => {
73+
(
74+
mockDeps.supplierConfigRepo.upsertSupplierConfig as jest.Mock
75+
).mockRejectedValue(new Error("DynamoDB error"));
76+
const record = createSqsRecord(
77+
"msg-fail",
78+
"uk.nhs.notify.supplier-config.supplier",
79+
{ id: "supplier-123" },
80+
);
81+
const event = { Records: [record] } as unknown as SQSEvent;
82+
83+
const result = await handler(event);
84+
85+
expect(result).toEqual({
86+
batchItemFailures: [{ itemIdentifier: "msg-fail" }],
87+
});
88+
});
89+
90+
it("rejects a type field containing no dots", async () => {
91+
const data = createValidSupplierConfig();
92+
const record = createSqsRecord("msg-1", "look-no-dots", data);
93+
const event = { Records: [record] } as unknown as SQSEvent;
94+
95+
const result = await handler(event);
96+
97+
expect(result).toEqual({
98+
batchItemFailures: [{ itemIdentifier: "msg-1" }],
99+
});
100+
expect(
101+
mockDeps.supplierConfigRepo.upsertSupplierConfig,
102+
).not.toHaveBeenCalled();
103+
});
104+
105+
it("rejects a type field not matching the name of any entity", async () => {
106+
const data = createValidSupplierConfig();
107+
const record = createSqsRecord(
108+
"msg-1",
109+
"uk.nhs.notify.supplier-config.suppler",
110+
data,
111+
);
112+
const event = { Records: [record] } as unknown as SQSEvent;
113+
114+
const result = await handler(event);
115+
116+
expect(result).toEqual({
117+
batchItemFailures: [{ itemIdentifier: "msg-1" }],
118+
});
119+
expect(
120+
mockDeps.supplierConfigRepo.upsertSupplierConfig,
121+
).not.toHaveBeenCalled();
122+
});
123+
124+
it("rejects an entity not matching the appropriate schema", async () => {
125+
const data = createValidSupplierConfig();
126+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
127+
const { dailyCapacity, ...invalidData } = data;
128+
const record = createSqsRecord(
129+
"msg-1",
130+
"uk.nhs.notify.supplier-config.supplier",
131+
invalidData,
132+
);
133+
const event = { Records: [record] } as unknown as SQSEvent;
134+
135+
const result = await handler(event);
136+
137+
expect(result).toEqual({
138+
batchItemFailures: [{ itemIdentifier: "msg-1" }],
139+
});
140+
expect(
141+
mockDeps.supplierConfigRepo.upsertSupplierConfig,
142+
).not.toHaveBeenCalled();
143+
});
12144
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2+
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
3+
import { Logger } from "pino";
4+
import { createLogger } from "@internal/helpers";
5+
import { SupplierConfigRepository } from "@internal/datastore";
6+
import { EnvVars, envVars } from "./env";
7+
8+
export type Deps = {
9+
supplierConfigRepo: SupplierConfigRepository;
10+
logger: Logger;
11+
env: EnvVars;
12+
};
13+
14+
function createDocumentClient(): DynamoDBDocumentClient {
15+
const ddbClient = new DynamoDBClient({});
16+
return DynamoDBDocumentClient.from(ddbClient);
17+
}
18+
19+
function createSupplierConfigRepository(
20+
// eslint-disable-next-line @typescript-eslint/no-shadow
21+
envVars: EnvVars,
22+
): SupplierConfigRepository {
23+
const config = {
24+
supplierConfigTableName: envVars.SUPPLIER_CONFIG_TABLE_NAME,
25+
};
26+
27+
return new SupplierConfigRepository(createDocumentClient(), config);
28+
}
29+
30+
export function createDependenciesContainer(): Deps {
31+
const log = createLogger({ logLevel: envVars.PINO_LOG_LEVEL });
32+
33+
return {
34+
supplierConfigRepo: createSupplierConfigRepository(envVars),
35+
logger: log,
36+
env: envVars,
37+
};
38+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { z } from "zod";
2+
3+
const EnvVarsSchema = z.object({
4+
SUPPLIER_CONFIG_TABLE_NAME: z.string(),
5+
PINO_LOG_LEVEL: z.coerce.string().optional(),
6+
});
7+
8+
export type EnvVars = z.infer<typeof EnvVarsSchema>;
9+
10+
export const envVars = EnvVarsSchema.parse(process.env);

0 commit comments

Comments
 (0)