diff --git a/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf new file mode 100644 index 00000000..8d089a19 --- /dev/null +++ b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf @@ -0,0 +1,59 @@ +resource "aws_dynamodb_table" "supplier-configuration" { + name = "${local.csi}-supplier-config" + billing_mode = "PAY_PER_REQUEST" + + hash_key = "PK" + range_key = "SK" + + ttl { + attribute_name = "ttl" + enabled = true + } + + attribute { + name = "PK" + type = "S" + } + + attribute { + name = "SK" + type = "S" + } + + attribute { + name = "entityType" + type = "S" + } + + attribute { + name = "volumeGroup" + type = "S" + } + + // The type-index GSI allows us to query for all supplier configurations of a given type (e.g. all letter supplier configurations) + global_secondary_index { + name = "EntityTypeIndex" + hash_key = "entityType" + range_key = "SK" + projection_type = "ALL" + } + + global_secondary_index { + name = "volumeGroup-index" + hash_key = "PK" + range_key = "volumeGroup" + projection_type = "ALL" + } + + point_in_time_recovery { + enabled = true + } + + tags = merge( + local.default_tags, + { + NHSE-Enable-Dynamo-Backup-Acct = "True" + } + ) + +} diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index 46c7aca7..8edd8fa8 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -20,15 +20,16 @@ locals { destination_arn = "arn:aws:logs:${var.region}:${var.shared_infra_account_id}:destination:nhs-main-obs-firehose-logs" common_lambda_env_vars = { - LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name, - MI_TABLE_NAME = aws_dynamodb_table.mi.name, - LETTER_TTL_HOURS = 12960, # 18 months * 30 days * 24 hours - MI_TTL_HOURS = 2160 # 90 days * 24 hours - SUPPLIER_ID_HEADER = "nhsd-supplier-id", - APIM_CORRELATION_HEADER = "nhsd-correlation-id", - DOWNLOAD_URL_TTL_SECONDS = 60 - SNS_TOPIC_ARN = "${module.eventsub.sns_topic.arn}", - EVENT_SOURCE = "/data-plane/supplier-api/${var.group}/${var.environment}/letters" + LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name, + MI_TABLE_NAME = aws_dynamodb_table.mi.name, + LETTER_TTL_HOURS = 12960, # 18 months * 30 days * 24 hours + MI_TTL_HOURS = 2160 # 90 days * 24 hours + SUPPLIER_ID_HEADER = "nhsd-supplier-id", + APIM_CORRELATION_HEADER = "nhsd-correlation-id", + DOWNLOAD_URL_TTL_SECONDS = 60 + SNS_TOPIC_ARN = "${module.eventsub.sns_topic.arn}", + EVENT_SOURCE = "/data-plane/supplier-api/${var.group}/${var.environment}/letters" + SUPPLIER_CONFIG_TABLE_NAME = aws_dynamodb_table.supplier-configuration.name } core_pdf_bucket_arn = "arn:aws:s3:::comms-${var.core_account_id}-eu-west-2-${var.core_environment}-api-stg-pdf-pipeline" diff --git a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf index 76cfb22a..b568307c 100644 --- a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf +++ b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf @@ -82,4 +82,20 @@ data "aws_iam_policy_document" "supplier_allocator_lambda" { module.sqs_letter_updates.sqs_queue_arn ] } + + statement { + sid = "AllowDynamoDBAccess" + effect = "Allow" + + actions = [ + "dynamodb:GetItem", + "dynamodb:Query" + ] + + resources = [ + aws_dynamodb_table.supplier-configuration.arn, + "${aws_dynamodb_table.supplier-configuration.arn}/index/volumeGroup-index" + + ] + } } diff --git a/internal/datastore/jest.config.ts b/internal/datastore/jest.config.ts index 1fb73e6d..da6c0a86 100644 --- a/internal/datastore/jest.config.ts +++ b/internal/datastore/jest.config.ts @@ -31,6 +31,9 @@ export const baseJestConfig: Config = { coveragePathIgnorePatterns: ["/__tests__/"], transform: { "^.+\\.ts$": "ts-jest" }, + transformIgnorePatterns: [ + "node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)", + ], testPathIgnorePatterns: [".build"], testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], diff --git a/internal/datastore/package.json b/internal/datastore/package.json index 23531375..f3e56167 100644 --- a/internal/datastore/package.json +++ b/internal/datastore/package.json @@ -3,6 +3,7 @@ "@aws-sdk/client-dynamodb": "^3.984.0", "@aws-sdk/lib-dynamodb": "^3.984.0", "@internal/helpers": "*", + "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.0.1", "pino": "^10.3.0", "zod": "^4.1.11", "zod-mermaid": "^1.0.9" diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index f382add6..8f5e224b 100644 --- a/internal/datastore/src/__test__/db.ts +++ b/internal/datastore/src/__test__/db.ts @@ -36,6 +36,7 @@ export async function setupDynamoDBContainer() { lettersTtlHours: 1, letterQueueTtlHours: 1, miTtlHours: 1, + supplierConfigTableName: "supplier-config", }; return { @@ -145,6 +146,31 @@ const createLetterQueueTableCommand = new CreateTableCommand({ { AttributeName: "queueTimestamp", AttributeType: "S" }, ], }); +const createSupplierConfigTableCommand = new CreateTableCommand({ + TableName: "supplier-config", + BillingMode: "PAY_PER_REQUEST", + KeySchema: [ + { AttributeName: "PK", KeyType: "HASH" }, // Partition key + { AttributeName: "SK", KeyType: "RANGE" }, // Sort key + ], + GlobalSecondaryIndexes: [ + { + IndexName: "volumeGroup-index", + KeySchema: [ + { AttributeName: "PK", KeyType: "HASH" }, // Partition key for GSI + { AttributeName: "volumeGroup", KeyType: "RANGE" }, // Sort key for GSI + ], + Projection: { + ProjectionType: "ALL", + }, + }, + ], + AttributeDefinitions: [ + { AttributeName: "PK", AttributeType: "S" }, + { AttributeName: "SK", AttributeType: "S" }, + { AttributeName: "volumeGroup", AttributeType: "S" }, + ], +}); export async function createTables(context: DBContext) { const { ddbClient } = context; @@ -155,6 +181,7 @@ export async function createTables(context: DBContext) { await ddbClient.send(createMITableCommand); await ddbClient.send(createSupplierTableCommand); await ddbClient.send(createLetterQueueTableCommand); + await ddbClient.send(createSupplierConfigTableCommand); } export async function deleteTables(context: DBContext) { @@ -165,6 +192,7 @@ export async function deleteTables(context: DBContext) { "management-info", "suppliers", "letter-queue", + "supplier-config", ]) { await ddbClient.send( new DeleteTableCommand({ diff --git a/internal/datastore/src/__test__/supplier-config-repository.test.ts b/internal/datastore/src/__test__/supplier-config-repository.test.ts new file mode 100644 index 00000000..9fde74f9 --- /dev/null +++ b/internal/datastore/src/__test__/supplier-config-repository.test.ts @@ -0,0 +1,266 @@ +import { PutCommand } from "@aws-sdk/lib-dynamodb"; +import { + DBContext, + createTables, + deleteTables, + setupDynamoDBContainer, +} from "./db"; +import { SupplierConfigRepository } from "../supplier-config-repository"; + +function createLetterVariantItem(variantId: string) { + return { + PK: "LETTER_VARIANT", + SK: variantId, + id: variantId, + name: `Variant ${variantId}`, + description: `Description for variant ${variantId}`, + type: "STANDARD", + status: "PROD", + volumeGroupId: `group-${variantId}`, + packSpecificationIds: [`pack-spec-${variantId}`], + }; +} + +function createVolumeGroupItem(groupId: string, status = "PROD") { + const startDate = new Date(Date.now() - 24 * 1000 * 60 * 60) + .toISOString() + .split("T")[0]; // Started a day ago to ensure it's active based on start date. Tests can override this if needed. + const endDate = new Date(Date.now() + 24 * 1000 * 60 * 60) + .toISOString() + .split("T")[0]; // Ends in a day to ensure it's active based on end date. Tests can override this if needed. + return { + PK: "VOLUME_GROUP", + SK: groupId, + id: groupId, + name: `Volume Group ${groupId}`, + description: `Description for volume group ${groupId}`, + status, + startDate, + endDate, + }; +} + +function createSupplierAllocationItem( + allocationId: string, + groupId: string, + supplier: string, +) { + return { + PK: `SUPPLIER_ALLOCATION`, + SK: allocationId, + id: allocationId, + status: "PROD", + volumeGroup: groupId, + supplier, + allocationPercentage: 50, + }; +} + +function createSupplierItem(supplierId: string) { + return { + PK: "SUPPLIER", + SK: supplierId, + id: supplierId, + name: `Supplier ${supplierId}`, + channelType: "LETTER", + dailyCapacity: 1000, + status: "PROD", + }; +} + +jest.setTimeout(30_000); + +describe("SupplierConfigRepository", () => { + let dbContext: DBContext; + let repository: SupplierConfigRepository; + + // Database tests can take longer, especially with setup and teardown + beforeAll(async () => { + dbContext = await setupDynamoDBContainer(); + }); + + beforeEach(async () => { + await createTables(dbContext); + repository = new SupplierConfigRepository( + dbContext.docClient, + dbContext.config, + ); + }); + + afterEach(async () => { + await deleteTables(dbContext); + jest.useRealTimers(); + }); + + afterAll(async () => { + await dbContext.container.stop(); + }); + + test("getLetterVariant returns correct details for existing variant", async () => { + const variantId = "variant-123"; + await dbContext.docClient.send( + new PutCommand({ + TableName: dbContext.config.supplierConfigTableName, + Item: createLetterVariantItem(variantId), + }), + ); + + const result = await repository.getLetterVariant(variantId); + + expect(result.id).toBe(variantId); + expect(result.name).toBe(`Variant ${variantId}`); + expect(result.description).toBe(`Description for variant ${variantId}`); + expect(result.type).toBe("STANDARD"); + expect(result.status).toBe("PROD"); + expect(result.volumeGroupId).toBe(`group-${variantId}`); + expect(result.packSpecificationIds).toEqual([`pack-spec-${variantId}`]); + }); + + test("getLetterVariant throws error for non-existent variant", async () => { + const variantId = "non-existent-variant"; + + await expect(repository.getLetterVariant(variantId)).rejects.toThrow( + `No letter variant details found for id ${variantId}`, + ); + }); + + test("getVolumeGroup returns correct details for existing group", async () => { + const groupId = "group-123"; + await dbContext.docClient.send( + new PutCommand({ + TableName: dbContext.config.supplierConfigTableName, + Item: createVolumeGroupItem(groupId), + }), + ); + + const result = await repository.getVolumeGroup(groupId); + + expect(result.id).toBe(groupId); + expect(result.name).toBe(`Volume Group ${groupId}`); + expect(result.description).toBe(`Description for volume group ${groupId}`); + expect(result.status).toBe("PROD"); + expect(new Date(result.startDate).getTime()).toBeLessThan(Date.now()); + expect(new Date(result.endDate!).getTime()).toBeGreaterThan(Date.now()); + }); + + test("getVolumeGroup throws error for non-existent group", async () => { + const groupId = "non-existent-group"; + + await expect(repository.getVolumeGroup(groupId)).rejects.toThrow( + `No volume group details found for id ${groupId}`, + ); + }); + + test("getSupplierAllocationsForVolumeGroup returns correct allocations", async () => { + const allocationId = "allocation-123"; + const groupId = "group-123"; + const supplierId = "supplier-123"; + + await dbContext.docClient.send( + new PutCommand({ + TableName: dbContext.config.supplierConfigTableName, + Item: createSupplierAllocationItem(allocationId, groupId, supplierId), + }), + ); + + const result = + await repository.getSupplierAllocationsForVolumeGroup(groupId); + + expect(result).toEqual([ + { + id: allocationId, + status: "PROD", + volumeGroup: groupId, + supplier: supplierId, + allocationPercentage: 50, + }, + ]); + }); + + test("getSupplierAllocationsForVolumeGroup returns multiple allocations", async () => { + const allocationId1 = "allocation-123"; + const allocationId2 = "allocation-456"; + const groupId = "group-123"; + const supplierId1 = "supplier-123"; + const supplierId2 = "supplier-456"; + + await dbContext.docClient.send( + new PutCommand({ + TableName: dbContext.config.supplierConfigTableName, + Item: createSupplierAllocationItem(allocationId1, groupId, supplierId1), + }), + ); + + await dbContext.docClient.send( + new PutCommand({ + TableName: dbContext.config.supplierConfigTableName, + Item: createSupplierAllocationItem(allocationId2, groupId, supplierId2), + }), + ); + + const result = + await repository.getSupplierAllocationsForVolumeGroup(groupId); + + // order of allocations should not matter, just that both are present + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + { + id: allocationId1, + status: "PROD", + volumeGroup: groupId, + supplier: supplierId1, + allocationPercentage: 50, + }, + { + id: allocationId2, + status: "PROD", + volumeGroup: groupId, + supplier: supplierId2, + allocationPercentage: 50, + }, + ]), + ); + }); + + test("getSupplierAllocationsForVolumeGroup throws error for non-existent group", async () => { + const groupId = "non-existent-group"; + + await expect( + repository.getSupplierAllocationsForVolumeGroup(groupId), + ).rejects.toThrow( + `No active supplier allocations found for volume group id ${groupId}`, + ); + }); + + test("getSuppliersDetails returns correct supplier details", async () => { + const supplierId = "supplier-123"; + + await dbContext.docClient.send( + new PutCommand({ + TableName: dbContext.config.supplierConfigTableName, + Item: createSupplierItem(supplierId), + }), + ); + + const result = await repository.getSuppliersDetails([supplierId]); + + expect(result).toEqual([ + { + id: supplierId, + name: `Supplier ${supplierId}`, + channelType: "LETTER", + dailyCapacity: 1000, + status: "PROD", + }, + ]); + }); + + test("getSuppliersDetails throws error for non-existent supplier", async () => { + const supplierId = "non-existent-supplier"; + + await expect(repository.getSuppliersDetails([supplierId])).rejects.toThrow( + `Supplier with id ${supplierId} not found`, + ); + }); +}); diff --git a/internal/datastore/src/config.ts b/internal/datastore/src/config.ts index 4066101a..f0795402 100644 --- a/internal/datastore/src/config.ts +++ b/internal/datastore/src/config.ts @@ -8,4 +8,5 @@ export type DatastoreConfig = { lettersTtlHours: number; letterQueueTtlHours: number; miTtlHours: number; + supplierConfigTableName: string; }; diff --git a/internal/datastore/src/index.ts b/internal/datastore/src/index.ts index 7ee912c2..6c32e4bc 100644 --- a/internal/datastore/src/index.ts +++ b/internal/datastore/src/index.ts @@ -3,5 +3,6 @@ export * from "./errors"; export * from "./mi-repository"; export * from "./letter-repository"; export * from "./supplier-repository"; +export * from "./supplier-config-repository"; export { default as LetterQueueRepository } from "./letter-queue-repository"; export { default as DBHealthcheck } from "./healthcheck"; diff --git a/internal/datastore/src/supplier-config-repository.ts b/internal/datastore/src/supplier-config-repository.ts new file mode 100644 index 00000000..1f82bb0c --- /dev/null +++ b/internal/datastore/src/supplier-config-repository.ts @@ -0,0 +1,100 @@ +import { + DynamoDBDocumentClient, + GetCommand, + QueryCommand, +} from "@aws-sdk/lib-dynamodb"; +import { + $LetterVariant, + $Supplier, + $SupplierAllocation, + $VolumeGroup, + LetterVariant, + Supplier, + SupplierAllocation, + VolumeGroup, +} from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; + +export type SupplierConfigRepositoryConfig = { + supplierConfigTableName: string; +}; + +export class SupplierConfigRepository { + constructor( + readonly ddbClient: DynamoDBDocumentClient, + readonly config: SupplierConfigRepositoryConfig, + ) {} + + async getLetterVariant(variantId: string): Promise { + const result = await this.ddbClient.send( + new GetCommand({ + TableName: this.config.supplierConfigTableName, + Key: { PK: "LETTER_VARIANT", SK: variantId }, + }), + ); + if (!result.Item) { + throw new Error(`No letter variant details found for id ${variantId}`); + } + + return $LetterVariant.parse(result.Item); + } + + async getVolumeGroup(groupId: string): Promise { + const result = await this.ddbClient.send( + new GetCommand({ + TableName: this.config.supplierConfigTableName, + Key: { PK: "VOLUME_GROUP", SK: groupId }, + }), + ); + if (!result.Item) { + throw new Error(`No volume group details found for id ${groupId}`); + } + return $VolumeGroup.parse(result.Item); + } + + async getSupplierAllocationsForVolumeGroup( + groupId: string, + ): Promise { + const result = await this.ddbClient.send( + new QueryCommand({ + TableName: this.config.supplierConfigTableName, + IndexName: "volumeGroup-index", + KeyConditionExpression: "#pk = :pk AND #group = :groupId", + FilterExpression: "#status = :status ", + ExpressionAttributeNames: { + "#pk": "PK", + "#group": "volumeGroup", + "#status": "status", + }, + ExpressionAttributeValues: { + ":pk": "SUPPLIER_ALLOCATION", + ":groupId": groupId, + ":status": "PROD", + }, + }), + ); + if (!result.Items || result.Items.length === 0) { + throw new Error( + `No active supplier allocations found for volume group id ${groupId}`, + ); + } + + return $SupplierAllocation.array().parse(result.Items); + } + + async getSuppliersDetails(supplierIds: string[]): Promise { + const suppliers: Supplier[] = []; + for (const supplierId of supplierIds) { + const result = await this.ddbClient.send( + new GetCommand({ + TableName: this.config.supplierConfigTableName, + Key: { PK: "SUPPLIER", SK: supplierId }, + }), + ); + if (!result.Item) { + throw new Error(`Supplier with id ${supplierId} not found`); + } + suppliers.push($Supplier.parse(result.Item)); + } + return suppliers; + } +} diff --git a/lambdas/api-handler/jest.config.ts b/lambdas/api-handler/jest.config.ts index 87279451..174e7f7f 100644 --- a/lambdas/api-handler/jest.config.ts +++ b/lambdas/api-handler/jest.config.ts @@ -9,6 +9,9 @@ export const baseJestConfig = { }, ], }, + transformIgnorePatterns: [ + "node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)", + ], // Automatically clear mock calls, instances, contexts and results before every test clearMocks: true, diff --git a/lambdas/letter-updates-transformer/jest.config.ts b/lambdas/letter-updates-transformer/jest.config.ts index 87279451..174e7f7f 100644 --- a/lambdas/letter-updates-transformer/jest.config.ts +++ b/lambdas/letter-updates-transformer/jest.config.ts @@ -9,6 +9,9 @@ export const baseJestConfig = { }, ], }, + transformIgnorePatterns: [ + "node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)", + ], // Automatically clear mock calls, instances, contexts and results before every test clearMocks: true, diff --git a/lambdas/mi-updates-transformer/jest.config.ts b/lambdas/mi-updates-transformer/jest.config.ts index f88e7277..93ec73a5 100644 --- a/lambdas/mi-updates-transformer/jest.config.ts +++ b/lambdas/mi-updates-transformer/jest.config.ts @@ -26,6 +26,9 @@ export const baseJestConfig: Config = { coveragePathIgnorePatterns: ["/__tests__/"], transform: { "^.+\\.ts$": "ts-jest" }, + transformIgnorePatterns: [ + "node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)", + ], testPathIgnorePatterns: [".build"], testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], diff --git a/lambdas/supplier-allocator/package.json b/lambdas/supplier-allocator/package.json index 1b21d001..2ea6cb67 100644 --- a/lambdas/supplier-allocator/package.json +++ b/lambdas/supplier-allocator/package.json @@ -8,6 +8,7 @@ "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^2.0.1", "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5", "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.8", + "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.0.1", "@types/aws-lambda": "^8.10.148", "aws-lambda": "^1.0.7", "esbuild": "^0.27.2", diff --git a/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts b/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts index b8b7b736..6069dc07 100644 --- a/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts +++ b/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts @@ -2,6 +2,7 @@ import type { Deps } from "lambdas/supplier-allocator/src/config/deps"; describe("createDependenciesContainer", () => { const env = { + SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable", VARIANT_MAP: { lv1: { supplierId: "supplier1", @@ -25,6 +26,11 @@ describe("createDependenciesContainer", () => { })), })); + // Repo client + jest.mock("@internal/datastore", () => ({ + SupplierConfigRepository: jest.fn(), + })); + // Env jest.mock("../env", () => ({ envVars: env })); }); @@ -32,12 +38,18 @@ describe("createDependenciesContainer", () => { test("constructs deps and wires repository config correctly", async () => { // get current mock instances const { createLogger } = jest.requireMock("@internal/helpers"); - + const { SupplierConfigRepository } = jest.requireMock( + "@internal/datastore", + ); // eslint-disable-next-line @typescript-eslint/no-require-imports const { createDependenciesContainer } = require("../deps"); const deps: Deps = createDependenciesContainer(); expect(createLogger).toHaveBeenCalledTimes(1); - + expect(SupplierConfigRepository).toHaveBeenCalledTimes(1); + const supplierConfigRepoCtorArgs = SupplierConfigRepository.mock.calls[0]; + expect(supplierConfigRepoCtorArgs[1]).toEqual({ + supplierConfigTableName: "SupplierConfigTable", + }); expect(deps.env).toEqual(env); }); }); diff --git a/lambdas/supplier-allocator/src/config/__tests__/env.test.ts b/lambdas/supplier-allocator/src/config/__tests__/env.test.ts index dd013aeb..1d8e3a1f 100644 --- a/lambdas/supplier-allocator/src/config/__tests__/env.test.ts +++ b/lambdas/supplier-allocator/src/config/__tests__/env.test.ts @@ -15,6 +15,7 @@ describe("lambdaEnv", () => { }); it("should load all environment variables successfully", () => { + process.env.SUPPLIER_CONFIG_TABLE_NAME = "SupplierConfigTable"; process.env.VARIANT_MAP = `{ "lv1": { "supplierId": "supplier1", @@ -25,6 +26,7 @@ describe("lambdaEnv", () => { const { envVars } = require("../env"); expect(envVars).toEqual({ + SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable", VARIANT_MAP: { lv1: { supplierId: "supplier1", diff --git a/lambdas/supplier-allocator/src/config/deps.ts b/lambdas/supplier-allocator/src/config/deps.ts index 1a5f6ce5..4d51f9a0 100644 --- a/lambdas/supplier-allocator/src/config/deps.ts +++ b/lambdas/supplier-allocator/src/config/deps.ts @@ -1,18 +1,40 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import { SQSClient } from "@aws-sdk/client-sqs"; import { Logger } from "pino"; import { createLogger } from "@internal/helpers"; +import { SupplierConfigRepository } from "@internal/datastore"; import { EnvVars, envVars } from "./env"; export type Deps = { + supplierConfigRepo: SupplierConfigRepository; logger: Logger; env: EnvVars; sqsClient: SQSClient; }; +function createDocumentClient(): DynamoDBDocumentClient { + const ddbClient = new DynamoDBClient({}); + return DynamoDBDocumentClient.from(ddbClient); +} + +function createSupplierConfigRepository( + log: Logger, + // eslint-disable-next-line @typescript-eslint/no-shadow + envVars: EnvVars, +): SupplierConfigRepository { + const config = { + supplierConfigTableName: envVars.SUPPLIER_CONFIG_TABLE_NAME, + }; + + return new SupplierConfigRepository(createDocumentClient(), config); +} + export function createDependenciesContainer(): Deps { const log = createLogger({ logLevel: envVars.PINO_LOG_LEVEL }); return { + supplierConfigRepo: createSupplierConfigRepository(log, envVars), logger: log, env: envVars, sqsClient: new SQSClient({}), diff --git a/lambdas/supplier-allocator/src/config/env.ts b/lambdas/supplier-allocator/src/config/env.ts index 0adc3920..5d924430 100644 --- a/lambdas/supplier-allocator/src/config/env.ts +++ b/lambdas/supplier-allocator/src/config/env.ts @@ -10,6 +10,7 @@ const LetterVariantSchema = z.record( export type LetterVariant = z.infer; const EnvVarsSchema = z.object({ + SUPPLIER_CONFIG_TABLE_NAME: z.string(), PINO_LOG_LEVEL: z.coerce.string().optional(), VARIANT_MAP: z.string().transform((str, _) => { const parsed = JSON.parse(str); diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts index 23fc5981..b86ab5dd 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts @@ -1,438 +1,365 @@ -import { SQSEvent, SQSRecord } from "aws-lambda"; -import pino from "pino"; -import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; +import { SQSBatchResponse, SQSEvent, SQSRecord } from "aws-lambda"; +import { SendMessageCommand } from "@aws-sdk/client-sqs"; import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; -import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; -import { - $LetterEvent, - LetterEvent, -} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events"; import createSupplierAllocatorHandler from "../allocate-handler"; +import * as supplierConfig from "../../services/supplier-config"; import { Deps } from "../../config/deps"; -import { EnvVars } from "../../config/env"; -function createSQSEvent(records: SQSRecord[]): SQSEvent { - return { - Records: records, +jest.mock("@aws-sdk/client-sqs"); +jest.mock("../../services/supplier-config"); + +function makeDeps(overrides: Partial = {}): Deps { + const logger = { + info: jest.fn(), + error: jest.fn(), }; -} -function createSqsRecord(msgId: string, body: string): SQSRecord { - return { - messageId: msgId, - receiptHandle: "", - body, - attributes: { - ApproximateReceiveCount: "", - SentTimestamp: "", - SenderId: "", - ApproximateFirstReceiveTimestamp: "", - }, - messageAttributes: {}, - md5OfBody: "", - eventSource: "", - eventSourceARN: "", - awsRegion: "", + const sqsClient = { + send: jest.fn().mockResolvedValue({}), }; -} -function createPreparedV1Event( - overrides: Partial = {}, -): LetterRequestPreparedEvent { - const now = new Date().toISOString(); + const supplierConfigRepo = { + getLetterVariant: jest.fn(), + getVolumeGroup: jest.fn(), + getSupplierAllocationsForVolumeGroup: jest.fn(), + }; - return { - specversion: "1.0", - id: overrides.id ?? "7b9a03ca-342a-4150-b56b-989109c45613", - source: "/data-plane/letter-rendering/test", - subject: "client/client1/letter-request/letterRequest1", - type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v1", - time: now, - dataschema: - "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.1.0.0.schema.json", - dataschemaversion: "1.0.0", - data: { - domainId: overrides.domainId ?? "letter1", - letterVariantId: "lv1", - requestId: "request1", - requestItemId: "requestItem1", - requestItemPlanId: "requestItemPlan1", - clientId: "client1", - campaignId: "campaign1", - templateId: "template1", - url: overrides.url ?? "s3://letterDataBucket/letter1.pdf", - sha256Hash: - "3a7bd3e2360a3d29eea436fcfb7e44c735d117c8f2f1d2d1e4f6e8f7e6e8f7e6", - createdAt: now, - pageCount: 1, - status: "PREPARED", + const env = { + VARIANT_MAP: { + "variant-1": { supplierId: "supplier-1", specId: "spec-1" }, + "variant-2": { supplierId: "supplier-2", specId: "spec-2" }, }, - traceparent: "00-0af7651916cd43dd8448eb211c803191-b7ad6b7169203331-01", - recordedtime: now, - severitynumber: 2, - severitytext: "INFO", - plane: "data", }; + + return { + logger: logger as any, + sqsClient: sqsClient as any, + supplierConfigRepo: supplierConfigRepo as any, + env: env as any, + ...overrides, + } as any; } -function createPreparedV2Event( - overrides: Partial = {}, +function makeLetterEventV2( + variantId = "variant-1", ): LetterRequestPreparedEventV2 { return { - ...createPreparedV1Event(overrides), type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v2", - dataschema: - "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.2.0.1.schema.json", - dataschemaversion: "2.0.1", - }; + data: { + letterVariantId: variantId, + eventId: "event-123", + timestamp: new Date().toISOString(), + }, + } as any; } -function createSupplierStatusChangeEvent( - overrides: Partial = {}, -): LetterEvent { - const now = new Date().toISOString(); - - return $LetterEvent.parse({ - data: { - domainId: overrides.domainId ?? "f47ac10b-58cc-4372-a567-0e02b2c3d479", - groupId: "client_template", - origin: { - domain: "letter-rendering", - event: "f47ac10b-58cc-4372-a567-0e02b2c3d479", - source: "/data-plane/letter-rendering/prod/render-pdf", - subject: - "client/00f3b388-bbe9-41c9-9e76-052d37ee8988/letter-request/0o5Fs0EELR0fUjHjbCnEtdUwQe4_0o5Fs0EELR0fUjHjbCnEtdUwQe5", - }, - reasonCode: "R07", - reasonText: "No such address", - specificationId: "1y3q9v1zzzz", - billingRef: "1y3q9v1zzzz", - status: "RETURNED", - supplierId: "supplier1", +function makeSQSRecord(body: any): SQSRecord { + return { + messageId: "message-123", + receiptHandle: "receipt-handle-123", + body: JSON.stringify(body), + attributes: { + ApproximateReceiveCount: "1", + SentTimestamp: Date.now().toString(), + SenderId: "sender-123", + ApproximateFirstReceiveTimestamp: Date.now().toString(), }, - datacontenttype: "application/json", - dataschema: - "https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.RETURNED.1.0.0.schema.json", - dataschemaversion: "1.0.0", - id: overrides.id ?? "23f1f09c-a555-4d9b-8405-0b33490bc920", - plane: "data", - recordedtime: now, - severitynumber: 2, - severitytext: "INFO", - source: "/data-plane/supplier-api/prod/update-status", - specversion: "1.0", - subject: - "letter-origin/letter-rendering/letter/f47ac10b-58cc-4372-a567-0e02b2c3d479", - time: now, - traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", - type: "uk.nhs.notify.supplier-api.letter.RETURNED.v1", - }); + messageAttributes: {}, + md5OfBody: "md5", + eventSource: "aws:sqs", + eventSourceARN: "arn:aws:sqs:region:account:queue", + awsRegion: "us-east-1", + } as any; } -describe("createSupplierAllocatorHandler", () => { - let mockSqsClient: jest.Mocked; - let mockedDeps: jest.Mocked; +function setupDefaultMocks() { + (supplierConfig.getVariantDetails as jest.Mock).mockResolvedValue({ + id: "v1", + volumeGroupId: "g1", + }); + (supplierConfig.getVolumeGroupDetails as jest.Mock).mockResolvedValue({ + id: "g1", + status: "PROD", + }); + ( + supplierConfig.getSupplierAllocationsForVolumeGroup as jest.Mock + ).mockResolvedValue([{ supplier: "s1" }]); + (supplierConfig.getSupplierDetails as jest.Mock).mockResolvedValue({ + supplierId: "supplier-1", + specId: "spec-1", + }); +} +describe("allocate-handler", () => { beforeEach(() => { - mockSqsClient = { - send: jest.fn(), - } as unknown as jest.Mocked; - - mockedDeps = { - logger: { error: jest.fn(), info: jest.fn() } as unknown as pino.Logger, - env: { - VARIANT_MAP: { - lv1: { - supplierId: "supplier1", - specId: "spec1", - }, - }, - } as EnvVars, - sqsClient: mockSqsClient, - } as jest.Mocked; - jest.clearAllMocks(); + process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.queue"; }); - test("parses SNS notification and sends message to SQS queue for v2 event", async () => { - const preparedEvent = createPreparedV2Event(); - const evt: SQSEvent = createSQSEvent([ - createSqsRecord("msg1", JSON.stringify(preparedEvent)), - ]); - - process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; - - const handler = createSupplierAllocatorHandler(mockedDeps); - const result = await handler(evt, {} as any, {} as any); - - expect(result).toBeDefined(); - if (!result) throw new Error("expected BatchResponse, got void"); - - expect(result.batchItemFailures).toHaveLength(0); - - expect(mockSqsClient.send).toHaveBeenCalledTimes(1); - const sendCall = (mockSqsClient.send as jest.Mock).mock.calls[0][0]; - expect(sendCall).toBeInstanceOf(SendMessageCommand); - - const messageBody = JSON.parse(sendCall.input.MessageBody); - expect(messageBody.letterEvent).toEqual(preparedEvent); - expect(messageBody.supplierSpec).toEqual({ - supplierId: "supplier1", - specId: "spec1", - }); + afterEach(() => { + delete process.env.UPSERT_LETTERS_QUEUE_URL; }); - test("parses SNS notification and sends message to SQS queue for v1 event", async () => { - const preparedEvent = createPreparedV1Event(); - - const evt: SQSEvent = createSQSEvent([ - createSqsRecord("msg1", JSON.stringify(preparedEvent)), - ]); - - process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; - - const handler = createSupplierAllocatorHandler(mockedDeps); - const result = await handler(evt, {} as any, {} as any); - - expect(result).toBeDefined(); - if (!result) throw new Error("expected BatchResponse, got void"); - - expect(result.batchItemFailures).toHaveLength(0); - - expect(mockSqsClient.send).toHaveBeenCalledTimes(1); - const sendCall = (mockSqsClient.send as jest.Mock).mock.calls[0][0]; - const messageBody = JSON.parse(sendCall.input.MessageBody); - expect(messageBody.supplierSpec).toEqual({ - supplierId: "supplier1", - specId: "spec1", + describe("createSupplierAllocatorHandler", () => { + it("creates a handler function", () => { + const deps = makeDeps(); + const handler = createSupplierAllocatorHandler(deps); + expect(handler).toBeInstanceOf(Function); }); }); - test("returns batch failure for Update event", async () => { - const preparedEvent = createSupplierStatusChangeEvent(); - - const evt: SQSEvent = createSQSEvent([ - createSqsRecord("invalid-event", JSON.stringify(preparedEvent)), - ]); - - process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; - - const handler = createSupplierAllocatorHandler(mockedDeps); - const result = await handler(evt, {} as any, {} as any); - - expect(result).toBeDefined(); - if (!result) throw new Error("expected BatchResponse, got void"); - - expect(result.batchItemFailures).toHaveLength(1); - expect(result.batchItemFailures[0].itemIdentifier).toBe("invalid-event"); - expect((mockedDeps.logger.error as jest.Mock).mock.calls).toHaveLength(1); - }); - - test("unwraps EventBridge envelope and extracts event details", async () => { - const preparedEvent = createPreparedV2Event({ domainId: "letter-test" }); - - const evt: SQSEvent = createSQSEvent([ - createSqsRecord("msg1", JSON.stringify(preparedEvent)), - ]); - - process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; - - const handler = createSupplierAllocatorHandler(mockedDeps); - await handler(evt, {} as any, {} as any); + describe("handler execution", () => { + it("processes single record successfully", async () => { + const deps = makeDeps(); + const letterEvent = makeLetterEventV2(); + + setupDefaultMocks(); + + const handler = createSupplierAllocatorHandler(deps); + const event: SQSEvent = { + Records: [makeSQSRecord(letterEvent)], + }; + + const result = (await handler( + event, + {} as any, + () => {}, + )) as SQSBatchResponse; + + expect(result.batchItemFailures).toHaveLength(0); + expect(deps.sqsClient.send).toHaveBeenCalledWith( + expect.any(SendMessageCommand), + ); + expect(deps.logger.info).toHaveBeenCalled(); + }); - const sendCall = (mockSqsClient.send as jest.Mock).mock.calls[0][0]; - const messageBody = JSON.parse(sendCall.input.MessageBody); - expect(messageBody.letterEvent.data.domainId).toBe("letter-test"); - }); + it("processes multiple records", async () => { + const deps = makeDeps(); + const letterEvent1 = makeLetterEventV2("variant-1"); + const letterEvent2 = makeLetterEventV2("variant-2"); - test("resolves correct supplier spec from variant map", async () => { - const preparedEvent = createPreparedV2Event(); + setupDefaultMocks(); - const evt: SQSEvent = createSQSEvent([ - createSqsRecord("msg1", JSON.stringify(preparedEvent)), - ]); + const handler = createSupplierAllocatorHandler(deps); + const event: SQSEvent = { + Records: [makeSQSRecord(letterEvent1), makeSQSRecord(letterEvent2)], + }; - process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; + const result = (await handler( + event, + {} as any, + () => {}, + )) as SQSBatchResponse; - const handler = createSupplierAllocatorHandler(mockedDeps); - await handler(evt, {} as any, {} as any); + expect(result.batchItemFailures).toHaveLength(0); + expect(deps.sqsClient.send).toHaveBeenCalledTimes(2); + }); - const sendCall = (mockSqsClient.send as jest.Mock).mock.calls[0][0]; - const messageBody = JSON.parse(sendCall.input.MessageBody); - expect(messageBody.supplierSpec.supplierId).toBe("supplier1"); - expect(messageBody.supplierSpec.specId).toBe("spec1"); - }); + it("records failures for invalid JSON", async () => { + const deps = makeDeps(); + const handler = createSupplierAllocatorHandler(deps); - test("processes multiple messages in batch", async () => { - const evt: SQSEvent = createSQSEvent([ - createSqsRecord( - "msg1", - JSON.stringify(createPreparedV2Event({ domainId: "letter1" })), - ), - createSqsRecord( - "msg2", - JSON.stringify(createPreparedV2Event({ domainId: "letter2" })), - ), - ]); + const badRecord = makeSQSRecord(""); + badRecord.body = "invalid json {"; - process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; + const event: SQSEvent = { + Records: [badRecord], + }; - const handler = createSupplierAllocatorHandler(mockedDeps); - const result = await handler(evt, {} as any, {} as any); + const result = (await handler( + event, + {} as any, + () => {}, + )) as SQSBatchResponse; - expect(result).toBeDefined(); - if (!result) throw new Error("expected BatchResponse, got void"); + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("message-123"); + expect(deps.logger.error).toHaveBeenCalled(); + }); - expect(result.batchItemFailures).toHaveLength(0); - expect(mockSqsClient.send).toHaveBeenCalledTimes(2); - }); + it("records failures for invalid event type", async () => { + const deps = makeDeps(); + const handler = createSupplierAllocatorHandler(deps); - test("returns batch failure for invalid JSON", async () => { - const evt: SQSEvent = createSQSEvent([ - createSqsRecord("bad-json", "this-is-not-json"), - ]); + const invalidEvent = { type: "invalid.type" }; + const event: SQSEvent = { + Records: [makeSQSRecord(invalidEvent)], + }; - process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; + const result = (await handler( + event, + {} as any, + () => {}, + )) as SQSBatchResponse; - const handler = createSupplierAllocatorHandler(mockedDeps); - const result = await handler(evt, {} as any, {} as any); + expect(result.batchItemFailures).toHaveLength(1); + expect(deps.logger.error).toHaveBeenCalled(); + }); - expect(result).toBeDefined(); - if (!result) throw new Error("expected BatchResponse, got void"); + it("records failures for missing envelope type", async () => { + const deps = makeDeps(); + const handler = createSupplierAllocatorHandler(deps); - expect(result.batchItemFailures).toHaveLength(1); - expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-json"); - expect((mockedDeps.logger.error as jest.Mock).mock.calls).toHaveLength(1); - }); + const invalidEvent = { data: { letterVariantId: "v1" } }; + const event: SQSEvent = { + Records: [makeSQSRecord(invalidEvent)], + }; - test("returns batch failure when event type is missing", async () => { - const event = { no: "type" }; + const result = (await handler( + event, + {} as any, + () => {}, + )) as SQSBatchResponse; - const evt: SQSEvent = createSQSEvent([ - createSqsRecord("no-type", JSON.stringify(event)), - ]); + expect(result.batchItemFailures).toHaveLength(1); + expect(deps.logger.error).toHaveBeenCalled(); + }); - process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; + it("records failures for missing variant mapping", async () => { + const deps = makeDeps(); + const letterEvent = makeLetterEventV2("unknown-variant"); + + const handler = createSupplierAllocatorHandler(deps); + const event: SQSEvent = { + Records: [makeSQSRecord(letterEvent)], + }; + + const result = (await handler( + event, + {} as any, + () => {}, + )) as SQSBatchResponse; + + expect(result.batchItemFailures).toHaveLength(1); + expect(deps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Error processing allocation of record", + }), + ); + }); - const handler = createSupplierAllocatorHandler(mockedDeps); - const result = await handler(evt, {} as any, {} as any); - if (!result) throw new Error("expected BatchResponse, got void"); + it("records failures when supplier config resolution fails", async () => { + const deps = makeDeps(); + const letterEvent = makeLetterEventV2(); - expect(result.batchItemFailures).toHaveLength(1); - expect(result.batchItemFailures[0].itemIdentifier).toBe("no-type"); - }); + (supplierConfig.getVariantDetails as jest.Mock).mockRejectedValue( + new Error("Database error"), + ); - test("returns batch failure when UPSERT_LETTERS_QUEUE_URL is not set", async () => { - const preparedEvent = createPreparedV2Event(); + const handler = createSupplierAllocatorHandler(deps); + const event: SQSEvent = { + Records: [makeSQSRecord(letterEvent)], + }; - const evt: SQSEvent = createSQSEvent([ - createSqsRecord("msg1", JSON.stringify(preparedEvent)), - ]); + const result = (await handler( + event, + {} as any, + () => {}, + )) as SQSBatchResponse; - delete process.env.UPSERT_LETTERS_QUEUE_URL; + expect(result.batchItemFailures).toHaveLength(0); + expect(deps.logger.error).toHaveBeenCalled(); + }); - const handler = createSupplierAllocatorHandler(mockedDeps); - const result = await handler(evt, {} as any, {} as any); - if (!result) throw new Error("expected BatchResponse, got void"); - - expect(result.batchItemFailures).toHaveLength(1); - expect(result.batchItemFailures[0].itemIdentifier).toBe("msg1"); - expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][0].err).toEqual( - expect.objectContaining({ - message: "UPSERT_LETTERS_QUEUE_URL not configured", - }), - ); - }); + it("records failures when UPSERT_LETTERS_QUEUE_URL not configured", async () => { + delete process.env.UPSERT_LETTERS_QUEUE_URL; - test("returns batch failure when variant mapping is missing", async () => { - const preparedEvent = createPreparedV2Event(); - preparedEvent.data.letterVariantId = "missing-variant"; - - const evt: SQSEvent = createSQSEvent([ - createSqsRecord("msg1", JSON.stringify(preparedEvent)), - ]); - - process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; - - // Override variant map to be empty for this test - mockedDeps.env.VARIANT_MAP = {} as any; - - const handler = createSupplierAllocatorHandler(mockedDeps); - const result = await handler(evt, {} as any, {} as any); - if (!result) throw new Error("expected BatchResponse, got void"); - - expect(result.batchItemFailures).toHaveLength(1); - expect(result.batchItemFailures[0].itemIdentifier).toBe("msg1"); - expect( - (mockedDeps.logger.error as jest.Mock).mock.calls.length, - ).toBeGreaterThan(0); - expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][0]).toEqual( - expect.objectContaining({ - description: "No supplier mapping found for variant", - }), - ); - }); + const deps = makeDeps(); + const letterEvent = makeLetterEventV2(); - test("handles SQS send errors and returns batch failure", async () => { - const preparedEvent = createPreparedV2Event(); + setupDefaultMocks(); - const evt: SQSEvent = createSQSEvent([ - createSqsRecord("msg1", JSON.stringify(preparedEvent)), - ]); + const handler = createSupplierAllocatorHandler(deps); + const event: SQSEvent = { + Records: [makeSQSRecord(letterEvent)], + }; - process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; + const result = (await handler( + event, + {} as any, + () => {}, + )) as SQSBatchResponse; - const sqsError = new Error("SQS send failed"); - (mockSqsClient.send as jest.Mock).mockRejectedValueOnce(sqsError); + expect(result.batchItemFailures).toHaveLength(1); + expect(deps.logger.error).toHaveBeenCalled(); + }); - const handler = createSupplierAllocatorHandler(mockedDeps); - const result = await handler(evt, {} as any, {} as any); - if (!result) throw new Error("expected BatchResponse, got void"); + it("records failures when SQS send fails", async () => { + const deps = makeDeps(); + const letterEvent = makeLetterEventV2(); - expect(result.batchItemFailures).toHaveLength(1); - expect(result.batchItemFailures[0].itemIdentifier).toBe("msg1"); - expect((mockedDeps.logger.error as jest.Mock).mock.calls).toHaveLength(1); - }); + setupDefaultMocks(); - test("processes mixed batch with successes and failures", async () => { - const evt: SQSEvent = createSQSEvent([ - createSqsRecord( - "ok-msg", - JSON.stringify(createPreparedV2Event({ domainId: "letter1" })), - ), - createSqsRecord("fail-msg", "invalid-json"), - createSqsRecord( - "ok-msg-2", - JSON.stringify(createPreparedV2Event({ domainId: "letter2" })), - ), - ]); - - process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; - - const handler = createSupplierAllocatorHandler(mockedDeps); - const result = await handler(evt, {} as any, {} as any); - if (!result) throw new Error("expected BatchResponse, got void"); - - expect(result.batchItemFailures).toHaveLength(1); - expect(result.batchItemFailures[0].itemIdentifier).toBe("fail-msg"); - - expect(mockSqsClient.send).toHaveBeenCalledTimes(2); - }); + deps.sqsClient.send = jest.fn().mockRejectedValue(new Error("SQS error")); - test("sends correct queue URL in SQS message command", async () => { - const preparedEvent = createPreparedV2Event(); + const handler = createSupplierAllocatorHandler(deps); + const event: SQSEvent = { + Records: [makeSQSRecord(letterEvent)], + }; - const evt: SQSEvent = createSQSEvent([ - createSqsRecord("msg1", JSON.stringify(preparedEvent)), - ]); + const result = (await handler( + event, + {} as any, + () => {}, + )) as SQSBatchResponse; - const queueUrl = "https://sqs.eu-west-2.amazonaws.com/123456789/test-queue"; - process.env.UPSERT_LETTERS_QUEUE_URL = queueUrl; + expect(result.batchItemFailures).toHaveLength(1); + expect(deps.logger.error).toHaveBeenCalled(); + }); - const handler = createSupplierAllocatorHandler(mockedDeps); - await handler(evt, {} as any, {} as any); + it("handles mixed success and failure records", async () => { + const deps = makeDeps(); + const letterEventGood = makeLetterEventV2("variant-1"); + const letterEventBad = { type: "invalid.type" }; + + setupDefaultMocks(); + + const handler = createSupplierAllocatorHandler(deps); + const event: SQSEvent = { + Records: [ + makeSQSRecord(letterEventGood), + makeSQSRecord(letterEventBad), + ], + }; + + const result = (await handler( + event, + {} as any, + () => {}, + )) as SQSBatchResponse; + + expect(result.batchItemFailures).toHaveLength(1); + expect(deps.sqsClient.send).toHaveBeenCalledTimes(1); + }); - const sendCall = (mockSqsClient.send as jest.Mock).mock.calls[0][0]; - expect(sendCall.input.QueueUrl).toBe(queueUrl); + it("logs extraction and resolution for successful records", async () => { + const deps = makeDeps(); + const letterEvent = makeLetterEventV2(); + + setupDefaultMocks(); + + const handler = createSupplierAllocatorHandler(deps); + const event: SQSEvent = { + Records: [makeSQSRecord(letterEvent)], + }; + + await handler(event, {} as any, () => {}); + + expect(deps.logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Extracted letter event", + }), + ); + expect(deps.logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Resolved supplier spec", + }), + ); + expect(deps.logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Sending message to upsert letter queue", + }), + ); + }); }); }); diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 0402e284..401d1270 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -1,9 +1,20 @@ import { SQSBatchItemFailure, SQSEvent, SQSHandler } from "aws-lambda"; import { SendMessageCommand } from "@aws-sdk/client-sqs"; import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; - +import { + LetterVariant, + Supplier, + SupplierAllocation, + VolumeGroup, +} from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; import z from "zod"; +import { + getSupplierAllocationsForVolumeGroup, + getSupplierDetails, + getVariantDetails, + getVolumeGroupDetails, +} from "../services/supplier-config"; import { Deps } from "../config/deps"; type SupplierSpec = { supplierId: string; specId: string }; @@ -46,6 +57,48 @@ function validateType(event: unknown) { } } +async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { + try { + const variantDetails: LetterVariant = await getVariantDetails( + letterEvent.data.letterVariantId, + deps, + ); + + const volumeGroupDetails: VolumeGroup = await getVolumeGroupDetails( + variantDetails.volumeGroupId, + deps, + ); + + const supplierAllocations: SupplierAllocation[] = + await getSupplierAllocationsForVolumeGroup( + variantDetails.volumeGroupId, + deps, + variantDetails.supplierId, + ); + + const supplierDetails: Supplier[] = await getSupplierDetails( + supplierAllocations, + deps, + ); + deps.logger.info({ + description: "Fetched supplier details for supplier allocations", + variantId: letterEvent.data.letterVariantId, + volumeGroupId: volumeGroupDetails.id, + supplierAllocationIds: supplierAllocations.map((a) => a.id), + supplierDetails, + }); + + return supplierDetails; + } catch (error) { + deps.logger.error({ + description: "Error fetching supplier from config", + err: error, + variantId: letterEvent.data.letterVariantId, + }); + return []; + } +} + function getSupplier(letterEvent: PreparedEvents, deps: Deps): SupplierSpec { return resolveSupplierForVariant(letterEvent.data.letterVariantId, deps); } @@ -66,6 +119,7 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { validateType(letterEvent); const supplierSpec = getSupplier(letterEvent as PreparedEvents, deps); + await getSupplierFromConfig(letterEvent as PreparedEvents, deps); deps.logger.info({ description: "Resolved supplier spec", diff --git a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts new file mode 100644 index 00000000..b6fa8e1e --- /dev/null +++ b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts @@ -0,0 +1,287 @@ +import { + getSupplierAllocationsForVolumeGroup, + getSupplierDetails, + getVariantDetails, + getVolumeGroupDetails, +} from "../supplier-config"; +import { Deps } from "../../config/deps"; + +function makeDeps(overrides: Partial = {}): Deps { + const logger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + } as unknown as Deps["logger"]; + + const supplierConfigRepo = { + getLetterVariant: jest.fn(), + getVolumeGroup: jest.fn(), + getSupplierAllocationsForVolumeGroup: jest.fn(), + } as unknown as Deps["supplierConfigRepo"]; + + const base: Partial = { + logger: logger as any, + supplierConfigRepo: supplierConfigRepo as any, + }; + + return { ...(base as Deps), ...overrides } as Deps; +} + +describe("supplier-config service", () => { + afterEach(() => jest.resetAllMocks()); + + describe("getVariantDetails", () => { + it("returns variant details", async () => { + const variant = { id: "v1", volumeGroupId: "g1" } as any; + const deps = makeDeps(); + deps.supplierConfigRepo.getLetterVariant = jest + .fn() + .mockResolvedValue(variant); + + const result = await getVariantDetails("v1", deps); + + expect(result).toBe(variant); + }); + + it("returns undefined when not found", async () => { + const variant = undefined; + const deps = makeDeps(); + deps.supplierConfigRepo.getLetterVariant = jest + .fn() + .mockResolvedValue(variant); + + const result = await getVariantDetails("missing", deps); + + expect(result).toBeUndefined(); + }); + }); + + describe("getVolumeGroupDetails", () => { + it("returns group details when active", async () => { + const group = { + id: "g1", + status: "PROD", + startDate: "2020-01-01", + } as any; + const deps = makeDeps(); + deps.supplierConfigRepo.getVolumeGroup = jest + .fn() + .mockResolvedValue(group); + + const result = await getVolumeGroupDetails("g1", deps); + + expect(result).toBe(group); + }); + it("logs an error and returns group details when not found", async () => { + const group = undefined; + const deps = makeDeps(); + deps.supplierConfigRepo.getVolumeGroup = jest + .fn() + .mockResolvedValue(group); + + const result = await getVolumeGroupDetails("missing", deps); + + expect(result).toBeUndefined(); + }); + + it("throws when group is not active based on status", async () => { + const group = { + id: "g2", + status: "DRAFT", + startDate: "2020-01-01", + } as any; + const deps = makeDeps(); + deps.supplierConfigRepo.getVolumeGroup = jest + .fn() + .mockResolvedValue(group); + + await expect(getVolumeGroupDetails("g2", deps)).rejects.toThrow( + /not active/, + ); + expect(deps.logger.error).toHaveBeenCalled(); + }); + + it("throws when group is not active based on start date", async () => { + const future = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(); + const group = { id: "g3", status: "PROD", startDate: future } as any; + const deps = makeDeps(); + deps.supplierConfigRepo.getVolumeGroup = jest + .fn() + .mockResolvedValue(group); + + await expect(getVolumeGroupDetails("g3", deps)).rejects.toThrow( + /not active/, + ); + expect(deps.logger.error).toHaveBeenCalled(); + }); + + it("throws when group is not active based on end date", async () => { + const past = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(); + const group = { + id: "g3", + status: "PROD", + startDate: "2020-01-01", + endDate: past, + } as any; + const deps = makeDeps(); + deps.supplierConfigRepo.getVolumeGroup = jest + .fn() + .mockResolvedValue(group); + + await expect(getVolumeGroupDetails("g3", deps)).rejects.toThrow( + /not active/, + ); + expect(deps.logger.error).toHaveBeenCalled(); + }); + }); + + describe("getSupplierAllocationsForVolumeGroup", () => { + const allocations = [ + { supplier: "s1", variantId: "v1" }, + { supplier: "s2", variantId: "v2" }, + ] as any[]; + + it("returns all allocations when no supplierId provided", async () => { + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierAllocationsForVolumeGroup = jest + .fn() + .mockResolvedValue(allocations); + + const result = await getSupplierAllocationsForVolumeGroup("g1", deps); + + expect(result).toEqual(allocations); + }); + + it("filters by supplierId when provided", async () => { + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierAllocationsForVolumeGroup = jest + .fn() + .mockResolvedValue(allocations); + + const result = await getSupplierAllocationsForVolumeGroup( + "g1", + deps, + "s2", + ); + + expect(result).toEqual([allocations[1]]); + }); + + it("throws when supplierId provided but no matching allocation", async () => { + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierAllocationsForVolumeGroup = jest + .fn() + .mockResolvedValue(allocations); + + await expect( + getSupplierAllocationsForVolumeGroup("g1", deps, "missing"), + ).rejects.toThrow(/No supplier allocations found/); + expect(deps.logger.error).toHaveBeenCalled(); + }); + }); + + describe("getSupplierDetails", () => { + it("returns supplier details when found", async () => { + const allocations = [ + { supplier: "s1", variantId: "v1" }, + { supplier: "s2", variantId: "v2" }, + ] as any[]; + const suppliers = [ + { id: "s1", name: "Supplier 1" }, + { id: "s2", name: "Supplier 2" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSuppliersDetails = jest + .fn() + .mockResolvedValue(suppliers); + + const result = await getSupplierDetails(allocations, deps); + + expect(result).toEqual(suppliers); + expect(deps.supplierConfigRepo.getSuppliersDetails).toHaveBeenCalledWith([ + "s1", + "s2", + ]); + }); + + it("throws when no supplier details found", async () => { + const allocations = [{ supplier: "s1", variantId: "v1" }] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSuppliersDetails = jest + .fn() + .mockResolvedValue([]); + + await expect(getSupplierDetails(allocations, deps)).rejects.toThrow( + /No supplier details found/, + ); + }); + + it("extracts supplier ids from allocations and requests details", async () => { + const allocations = [ + { supplier: "s1", variantId: "v1" }, + { supplier: "s3", variantId: "v2" }, + { supplier: "s5", variantId: "v3" }, + ] as any[]; + const suppliers = [ + { id: "s1", name: "Supplier 1" }, + { id: "s3", name: "Supplier 3" }, + { id: "s5", name: "Supplier 5" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSuppliersDetails = jest + .fn() + .mockResolvedValue(suppliers); + + await getSupplierDetails(allocations, deps); + + expect(deps.supplierConfigRepo.getSuppliersDetails).toHaveBeenCalledWith([ + "s1", + "s3", + "s5", + ]); + }); + }); + it("logs a warning when supplier allocations count differs from supplier details count", async () => { + const allocations = [ + { supplier: "s1", variantId: "v1" }, + { supplier: "s2", variantId: "v2" }, + { supplier: "s3", variantId: "v3" }, + ] as any[]; + const suppliers = [ + { id: "s1", name: "Supplier 1" }, + { id: "s2", name: "Supplier 2" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSuppliersDetails = jest + .fn() + .mockResolvedValue(suppliers); + + await getSupplierDetails(allocations, deps); + + expect(deps.logger.warn).toHaveBeenCalledWith({ + description: "Mismatch between supplier allocations and supplier details", + allocationsCount: 3, + detailsCount: 2, + missingSuppliers: ["s3"], + }); + }); + + it("does not log a warning when counts match", async () => { + const allocations = [ + { supplier: "s1", variantId: "v1" }, + { supplier: "s2", variantId: "v2" }, + ] as any[]; + const suppliers = [ + { id: "s1", name: "Supplier 1" }, + { id: "s2", name: "Supplier 2" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSuppliersDetails = jest + .fn() + .mockResolvedValue(suppliers); + + await getSupplierDetails(allocations, deps); + + expect(deps.logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/lambdas/supplier-allocator/src/services/supplier-config.ts b/lambdas/supplier-allocator/src/services/supplier-config.ts new file mode 100644 index 00000000..f8619520 --- /dev/null +++ b/lambdas/supplier-allocator/src/services/supplier-config.ts @@ -0,0 +1,103 @@ +import { + LetterVariant, + Supplier, + SupplierAllocation, + VolumeGroup, +} from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; +import { Deps } from "../config/deps"; + +export async function getVariantDetails( + variantId: string, + deps: Deps, +): Promise { + const variantDetails: LetterVariant = + await deps.supplierConfigRepo.getLetterVariant(variantId); + return variantDetails; +} + +export async function getVolumeGroupDetails( + groupId: string, + deps: Deps, +): Promise { + const groupDetails = await deps.supplierConfigRepo.getVolumeGroup(groupId); + + if ( + groupDetails && + (groupDetails.status !== "PROD" || + new Date(groupDetails.startDate) > new Date() || + (groupDetails.endDate && new Date(groupDetails.endDate) < new Date())) + ) { + deps.logger.error({ + description: "Volume group is not active based on status and dates", + groupId, + status: groupDetails.status, + startDate: groupDetails.startDate, + endDate: groupDetails.endDate, + }); + throw new Error(`Volume group with id ${groupId} is not active`); + } + return groupDetails; +} + +export async function getSupplierAllocationsForVolumeGroup( + groupId: string, + deps: Deps, + supplierId?: string, +): Promise { + const allocations = + await deps.supplierConfigRepo.getSupplierAllocationsForVolumeGroup(groupId); + + if (supplierId) { + const filteredAllocations = allocations.filter( + (alloc) => alloc.supplier === supplierId, + ); + if (filteredAllocations.length === 0) { + deps.logger.error({ + description: + "No supplier allocations found for variantsupplier id in volume group", + groupId, + supplierId, + }); + throw new Error( + `No supplier allocations found for variant supplier id ${supplierId} in volume group ${groupId}`, + ); + } + return filteredAllocations; + } + + return allocations; +} + +export async function getSupplierDetails( + supplierAllocations: SupplierAllocation[], + deps: Deps, +): Promise { + const supplierIds = supplierAllocations.map((alloc) => alloc.supplier); + + const supplierDetails: Supplier[] = + await deps.supplierConfigRepo.getSuppliersDetails(supplierIds); + + if (Object.keys(supplierDetails).length === 0) { + deps.logger.error({ + description: "No supplier details found for supplier allocations", + supplierIds, + }); + throw new Error( + `No supplier details found for supplier ids ${supplierIds.join(", ")}`, + ); + } + // Log a warning if some supplier details are missing compared to allocations + if (supplierAllocations.length !== supplierDetails.length) { + const foundSupplierIds = new Set(supplierDetails.map((s) => s.id)); + const missingSupplierIds = supplierIds.filter( + (id) => !foundSupplierIds.has(id), + ); + deps.logger.warn({ + description: "Mismatch between supplier allocations and supplier details", + allocationsCount: supplierAllocations.length, + detailsCount: supplierDetails.length, + missingSuppliers: missingSupplierIds, + }); + } + return supplierDetails; +} diff --git a/lambdas/update-letter-queue/jest.config.ts b/lambdas/update-letter-queue/jest.config.ts index e173d5e3..c6f65159 100644 --- a/lambdas/update-letter-queue/jest.config.ts +++ b/lambdas/update-letter-queue/jest.config.ts @@ -9,6 +9,9 @@ export const baseJestConfig = { }, ], }, + transformIgnorePatterns: [ + "node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)", + ], // Automatically clear mock calls, instances, contexts and results before every test clearMocks: true, diff --git a/package-lock.json b/package-lock.json index 84ca6562..fc88f6af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,6 +93,7 @@ "@aws-sdk/client-dynamodb": "^3.984.0", "@aws-sdk/lib-dynamodb": "^3.984.0", "@internal/helpers": "*", + "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.0.1", "pino": "^10.3.0", "zod": "^4.1.11", "zod-mermaid": "^1.0.9" @@ -294,6 +295,7 @@ "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^2.0.1", "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5", "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.8", + "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.0.1", "@types/aws-lambda": "^8.10.148", "aws-lambda": "^1.0.7", "esbuild": "^0.27.2", @@ -4225,6 +4227,15 @@ "resolved": "internal/events", "link": true }, + "node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-config": { + "version": "1.0.1", + "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-supplier-config/1.0.1/ff1ce566201ae291825acd5e771537229d6aa9ca", + "integrity": "sha512-gIZgfzgvkCfZE+HCosrVJ3tBce2FJRGfwPmtYtZDBG+ox/KvbpJFWXzJ5Jkh/42YzcVn2GxT1fy1L1F6pxiYWA==", + "dependencies": { + "@asyncapi/bundler": "^0.6.4", + "zod": "^4.1.12" + } + }, "node_modules/@nhsdigital/notify-supplier-api-consumer-contracts": { "resolved": "pact-contracts", "link": true