diff --git a/config/suppliers/letter-variant/client1-campaign2.json b/config/suppliers/letter-variant/client1-campaign2.json index 0df86359d..83ac0e308 100644 --- a/config/suppliers/letter-variant/client1-campaign2.json +++ b/config/suppliers/letter-variant/client1-campaign2.json @@ -32,7 +32,7 @@ "client1-campaign2" ], "priority": 1, - "status": "INT", + "status": "PROD", "type": "STANDARD", - "volumeGroupId": "volumeGroup-test3" + "volumeGroupId": "volumeGroup-test4" } diff --git a/config/suppliers/letter-variant/notify-first-test.json b/config/suppliers/letter-variant/notify-first-test.json new file mode 100644 index 000000000..a32388158 --- /dev/null +++ b/config/suppliers/letter-variant/notify-first-test.json @@ -0,0 +1,30 @@ +{ + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 2 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Black printing, first class postage tariff", + "id": "notify-first-test", + "name": "First class letter", + "packSpecificationIds": [ + "notify-first-test" + ], + "priority": 10, + "status": "PROD", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test5" +} diff --git a/config/suppliers/letter-variant/notify-standard-test1.json b/config/suppliers/letter-variant/notify-standard-test1.json index f69b59360..762356f1a 100644 --- a/config/suppliers/letter-variant/notify-standard-test1.json +++ b/config/suppliers/letter-variant/notify-standard-test1.json @@ -21,7 +21,8 @@ "id": "notify-standard-test1", "name": "Dev Happy Path", "packSpecificationIds": [ - "notify-c5" + "notify-c5", + "notify-c4" ], "priority": 50, "status": "PROD", diff --git a/config/suppliers/pack-specification/client1-campaign2.json b/config/suppliers/pack-specification/client1-campaign2.json index 9710d4aa8..7cc5b572d 100644 --- a/config/suppliers/pack-specification/client1-campaign2.json +++ b/config/suppliers/pack-specification/client1-campaign2.json @@ -49,7 +49,7 @@ "maxWeightGrams": 100, "size": "STANDARD" }, - "status": "INT", + "status": "PROD", "updatedAt": "2026-04-14T00:00:00.000Z", "version": 1 } diff --git a/config/suppliers/pack-specification/notify-first-test.json b/config/suppliers/pack-specification/notify-first-test.json new file mode 100644 index 000000000..f92d2f2f1 --- /dev/null +++ b/config/suppliers/pack-specification/notify-first-test.json @@ -0,0 +1,48 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "first-c5", + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "notify-first-test", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 2 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "First class postage tariff", + "id": "notify-first-test", + "name": "First class", + "postage": { + "deliveryDays": 2, + "id": "first-class", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/supplier-allocation/supplier1-volumeGroup-test3.json b/config/suppliers/supplier-allocation/supplier1-volumeGroup-test3.json index f08467c9f..299dc86c7 100644 --- a/config/suppliers/supplier-allocation/supplier1-volumeGroup-test3.json +++ b/config/suppliers/supplier-allocation/supplier1-volumeGroup-test3.json @@ -1,5 +1,5 @@ { - "allocationPercentage": 100, + "allocationPercentage": 50, "id": "supplier1-volumeGroup-test3", "status": "PROD", "supplier": "supplier1", diff --git a/config/suppliers/supplier-allocation/supplier2-volumeGroup-test3.json b/config/suppliers/supplier-allocation/supplier2-volumeGroup-test3.json new file mode 100644 index 000000000..3d8fa19cd --- /dev/null +++ b/config/suppliers/supplier-allocation/supplier2-volumeGroup-test3.json @@ -0,0 +1,7 @@ +{ + "allocationPercentage": 50, + "id": "supplier2-volumeGroup-test3", + "status": "PROD", + "supplier": "supplier2", + "volumeGroup": "volumeGroup-test3" +} diff --git a/config/suppliers/supplier-allocation/supplier3-volumeGroup-test4.json b/config/suppliers/supplier-allocation/supplier3-volumeGroup-test4.json new file mode 100644 index 000000000..76909d155 --- /dev/null +++ b/config/suppliers/supplier-allocation/supplier3-volumeGroup-test4.json @@ -0,0 +1,7 @@ +{ + "allocationPercentage": 50, + "id": "supplier3-volumeGroup-test4", + "status": "PROD", + "supplier": "supplier3", + "volumeGroup": "volumeGroup-test4" +} diff --git a/config/suppliers/supplier-allocation/supplier4-volumeGroup-test4.json b/config/suppliers/supplier-allocation/supplier4-volumeGroup-test4.json new file mode 100644 index 000000000..e4888175b --- /dev/null +++ b/config/suppliers/supplier-allocation/supplier4-volumeGroup-test4.json @@ -0,0 +1,7 @@ +{ + "allocationPercentage": 50, + "id": "supplier4-volumeGroup-test4", + "status": "PROD", + "supplier": "supplier4", + "volumeGroup": "volumeGroup-test4" +} diff --git a/config/suppliers/supplier-allocation/supplier5-volumeGroup-test5.json b/config/suppliers/supplier-allocation/supplier5-volumeGroup-test5.json new file mode 100644 index 000000000..b3339096c --- /dev/null +++ b/config/suppliers/supplier-allocation/supplier5-volumeGroup-test5.json @@ -0,0 +1,7 @@ +{ + "allocationPercentage": 100, + "id": "supplier5-volumeGroup-test5", + "status": "PROD", + "supplier": "supplier5", + "volumeGroup": "volumeGroup-test5" +} diff --git a/config/suppliers/supplier-pack/supplier2-client1-campaign1.json b/config/suppliers/supplier-pack/supplier2-client1-campaign1.json new file mode 100644 index 000000000..fad6dec27 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier2-client1-campaign1.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier2-client1-campaign1", + "packSpecificationId": "client1-campaign1", + "status": "PROD", + "supplierId": "supplier2" +} diff --git a/config/suppliers/supplier-pack/supplier3-client1-campaign2.json b/config/suppliers/supplier-pack/supplier3-client1-campaign2.json new file mode 100644 index 000000000..d19f1bffd --- /dev/null +++ b/config/suppliers/supplier-pack/supplier3-client1-campaign2.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier3-client1-campaign2", + "packSpecificationId": "client1-campaign2", + "status": "PROD", + "supplierId": "supplier3" +} diff --git a/config/suppliers/supplier-pack/supplier4-client1-campaign2.json b/config/suppliers/supplier-pack/supplier4-client1-campaign2.json new file mode 100644 index 000000000..a131fddc2 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier4-client1-campaign2.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier4-client1-campaign2", + "packSpecificationId": "client1-campaign2", + "status": "PROD", + "supplierId": "supplier4" +} diff --git a/config/suppliers/supplier-pack/supplier5-notify-first-test.json b/config/suppliers/supplier-pack/supplier5-notify-first-test.json new file mode 100644 index 000000000..e67ebfbc7 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier5-notify-first-test.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier5-notify-first-test", + "packSpecificationId": "notify-first-test", + "status": "PROD", + "supplierId": "supplier5" +} diff --git a/config/suppliers/supplier/supplier3.json b/config/suppliers/supplier/supplier3.json new file mode 100644 index 000000000..aa1492568 --- /dev/null +++ b/config/suppliers/supplier/supplier3.json @@ -0,0 +1,7 @@ +{ + "channelType": "LETTER", + "dailyCapacity": 500000, + "id": "supplier3", + "name": "Supplier3", + "status": "PROD" +} diff --git a/config/suppliers/supplier/supplier4.json b/config/suppliers/supplier/supplier4.json new file mode 100644 index 000000000..47d358491 --- /dev/null +++ b/config/suppliers/supplier/supplier4.json @@ -0,0 +1,7 @@ +{ + "channelType": "LETTER", + "dailyCapacity": 500000, + "id": "supplier4", + "name": "Supplier4", + "status": "PROD" +} diff --git a/config/suppliers/supplier/supplier5.json b/config/suppliers/supplier/supplier5.json new file mode 100644 index 000000000..fa7af6895 --- /dev/null +++ b/config/suppliers/supplier/supplier5.json @@ -0,0 +1,7 @@ +{ + "channelType": "LETTER", + "dailyCapacity": 500000, + "id": "supplier5", + "name": "Supplier5", + "status": "PROD" +} diff --git a/config/suppliers/volume-group/volumeGroup-test4.json b/config/suppliers/volume-group/volumeGroup-test4.json new file mode 100644 index 000000000..93e11e651 --- /dev/null +++ b/config/suppliers/volume-group/volumeGroup-test4.json @@ -0,0 +1,7 @@ +{ + "description": "Dev Test Volume Group 4", + "id": "volumeGroup-test4", + "name": "Dev Test Volume Group 4", + "startDate": "2026-01-01", + "status": "PROD" +} diff --git a/config/suppliers/volume-group/volumeGroup-test5.json b/config/suppliers/volume-group/volumeGroup-test5.json new file mode 100644 index 000000000..fec914c32 --- /dev/null +++ b/config/suppliers/volume-group/volumeGroup-test5.json @@ -0,0 +1,7 @@ +{ + "description": "Dev Test Volume Group 5", + "id": "volumeGroup-test5", + "name": "Dev Test Volume Group 5", + "startDate": "2026-01-01", + "status": "PROD" +} diff --git a/tests/component-tests/allocation-tests/letter-allocation-capacity.spec.ts b/tests/component-tests/allocation-tests/letter-allocation-capacity.spec.ts new file mode 100644 index 000000000..d8537efa5 --- /dev/null +++ b/tests/component-tests/allocation-tests/letter-allocation-capacity.spec.ts @@ -0,0 +1,208 @@ +import { expect, test } from "@playwright/test"; +import { sendSnsEvent } from "tests/helpers/send-sns-event"; +import { createPreparedV1Event } from "tests/helpers/event-fixtures"; +import { randomUUID } from "node:crypto"; +import { logger } from "tests/helpers/pino-logger"; +import { + getAllocationLog, + getAllocationLogForDomainId, + getExceededDailyCapacityLog, + getLetterVariantConfigFromDb, + getOrSeedLetterDailyAllocationFromDb, + getVariantsForAllocation, + updateSupplierDailyAllocation, +} from "tests/helpers/allocation-helper"; +import { getLettersFromSupplierTable } from "tests/helpers/generate-fetch-test-data"; + +test.describe("Allocator Lambda Tests", () => { + test.setTimeout(180_000); // 3 minutes for long running polling + + test(`Verify that allocator successfully allocates a letter and emits PENDING event`, async () => { + const domainId = randomUUID(); + logger.info(`Testing event subscription with domainId: ${domainId}`); + + const letterVariant = getVariantsForAllocation(1); + const preparedEvent = createPreparedV1Event({ + domainId, + letterVariantId: letterVariant, + }); + + const response = await sendSnsEvent(preparedEvent); + + expect(response.MessageId).toBeTruthy(); + + const supplierAllocatorLog = await getAllocationLogForDomainId(domainId); + const supplierId = + supplierAllocatorLog.msg?.allocationDetails?.supplierSpec?.supplierId; + const lettersInDb = await getLettersFromSupplierTable( + supplierId!, + domainId, + "PENDING", + ); + const allocationStatus = + supplierAllocatorLog.msg?.allocationDetails?.allocationStatus?.status; + expect(lettersInDb).toBeTruthy(); + expect(lettersInDb.status).toBe(allocationStatus); + }); + + test("Verify that first eligible supplier is selected", async () => { + const letterVariant = getVariantsForAllocation(1); + const domainId = randomUUID(); + + const letterVariantConfig = + await getLetterVariantConfigFromDb(letterVariant); + expect(letterVariantConfig.packSpecificationIds).toEqual( + expect.arrayContaining(["notify-c4", "notify-c5"]), + ); + + const preparedEvent = createPreparedV1Event({ + domainId, + letterVariantId: letterVariant, + pageCount: 6, // pagecount that makes notify-c5 ineligible and notify-c4 eligible based on their pack configs + }); + + const response = await sendSnsEvent(preparedEvent); + expect(response.MessageId).toBeTruthy(); + + const supplierAllocatorLog = await getAllocationLog( + "Pack specification filtered out based on constraints", + ); + const filteredPackSpecId = supplierAllocatorLog.packSpecId; + logger.info(`Pack spec filtered out ${filteredPackSpecId}`); + expect(filteredPackSpecId).toBe("notify-c5"); + expect(letterVariantConfig.packSpecificationIds).toContain( + filteredPackSpecId as string, + ); + + const allocationLog = await getAllocationLogForDomainId(domainId); + const lettersInDb = await getLettersFromSupplierTable( + allocationLog.msg?.allocationDetails?.supplierSpec?.supplierId!, + domainId, + "PENDING", + ); + expect(lettersInDb.status).toBe("PENDING"); + const allocatedPackSpecId = + allocationLog.msg?.allocationDetails?.supplierSpec?.specId; + expect(allocatedPackSpecId).toBe("notify-c4"); + expect(lettersInDb.specificationId).toBe(allocatedPackSpecId); + }); + + test("Verify if suppliers without capacity are filtered out", async () => { + const letterVariant = getVariantsForAllocation(4); + const domainId = `supcapacity-4-${randomUUID()}`; + const dailyAllocatedCapacity = 500_000; + const allocationDate = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/London", + }).format(new Date()); + + const dailyAllocation = await getOrSeedLetterDailyAllocationFromDb( + { + supplier1: 0, + supplier2: 0, + }, + allocationDate, + ); + logger.info( + `Daily allocation before test execution ${JSON.stringify(dailyAllocation.allocations)}`, + ); + const originalSupplier1Allocation = + dailyAllocation.allocations.supplier3 ?? 0; + + // set supplier1 to exactly daily capacity so allocator filters it out + if (dailyAllocation.allocations.supplier1 !== dailyAllocatedCapacity) { + await updateSupplierDailyAllocation( + "supplier3", + dailyAllocatedCapacity, + allocationDate, + ); + } + const preparedEvent = createPreparedV1Event({ + domainId, + letterVariantId: letterVariant, + }); + + const response = await sendSnsEvent(preparedEvent); + expect(response.MessageId).toBeTruthy(); + + const exceededCapacityLog = await getExceededDailyCapacityLog("supplier3"); + expect(exceededCapacityLog.description).toBe( + "Supplier has exceeded daily capacity", + ); + + const lettersInDb = await getLettersFromSupplierTable( + "supplier4", + domainId, + "PENDING", + ); + expect(lettersInDb.status).toBe("PENDING"); + expect(lettersInDb.supplierId).toBe("supplier4"); + + await updateSupplierDailyAllocation( + "supplier3", + originalSupplier1Allocation, + allocationDate, + ); + }); + + test("Verify that fallback is triggered when a suppliers are at daily capacity, ignoring capacity", async () => { + const letterVariant = getVariantsForAllocation(5); + const domainId = `supcapacity-5-${randomUUID()}`; + const dailyAllocatedCapacity = 500_000; + const allocationDate = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/London", + }).format(new Date()); + + const dailyAllocation = await getOrSeedLetterDailyAllocationFromDb( + { + supplier5: 0, + }, + allocationDate, + ); + logger.info( + `Daily allocation before test execution ${JSON.stringify(dailyAllocation.allocations)}`, + ); + + const originalSupplierAllocation = + dailyAllocation.allocations.supplier5 ?? 0; + + // set supplier1 to exactly daily capacity so allocator filters it out + if (dailyAllocation.allocations.supplier1 !== dailyAllocatedCapacity) { + await updateSupplierDailyAllocation( + "supplier5", + dailyAllocatedCapacity, + allocationDate, + ); + } + const preparedEvent = createPreparedV1Event({ + domainId, + letterVariantId: letterVariant, + }); + + const response = await sendSnsEvent(preparedEvent); + expect(response.MessageId).toBeTruthy(); + + const exceededCapacityLog = await getExceededDailyCapacityLog("supplier5"); + expect(exceededCapacityLog.description).toBe( + "Supplier has exceeded daily capacity", + ); + + const lettersInDb = await getLettersFromSupplierTable( + "supplier5", + domainId, + "PENDING", + ); + expect(lettersInDb.status).toBe("PENDING"); + const fallbackDailyAllocation = await getOrSeedLetterDailyAllocationFromDb({ + supplier5: 0, + }); + expect(fallbackDailyAllocation.allocations.supplier5).toBe( + dailyAllocatedCapacity + 1, + ); + + await updateSupplierDailyAllocation( + "supplier5", + originalSupplierAllocation, + allocationDate, + ); + }); +}); diff --git a/tests/component-tests/allocation-tests/letter-allocation-rejected.spec.ts b/tests/component-tests/allocation-tests/letter-allocation-rejected.spec.ts new file mode 100644 index 000000000..45f0d73fd --- /dev/null +++ b/tests/component-tests/allocation-tests/letter-allocation-rejected.spec.ts @@ -0,0 +1,89 @@ +import { randomUUID } from "node:crypto"; +import test, { expect } from "playwright/test"; +import { + PackErrorLog, + getAllocationLog, + getAllocationLogForDomainId, + getVariantsForAllocation, +} from "tests/helpers/allocation-helper"; +import { createPreparedV1Event } from "tests/helpers/event-fixtures"; +import { getLettersFromSupplierTable } from "tests/helpers/generate-fetch-test-data"; +import { logger } from "tests/helpers/pino-logger"; +import { sendSnsEvent } from "tests/helpers/send-sns-event"; + +test.describe("Allocator Rejected Allocation Tests", () => { + test.setTimeout(180_000); // 3 minutes for long running polling + test("Verify that unknown letter variant is marked as rejected allocation", async () => { + const domainId = randomUUID(); + logger.info(`Testing rejected allocation with domainId: ${domainId}`); + + const preparedEvent = createPreparedV1Event({ + domainId, + letterVariantId: `unknown-variant-${domainId}`, + }); + + const response = await sendSnsEvent(preparedEvent); + + expect(response.MessageId).toBeTruthy(); + + const supplierAllocatorLog = await getAllocationLogForDomainId(domainId); + const supplierId = + supplierAllocatorLog.msg?.allocationDetails?.supplierSpec?.supplierId; + const allocationStatus = + supplierAllocatorLog.msg?.allocationDetails?.allocationStatus?.status; + const reasonCode = + supplierAllocatorLog.msg?.allocationDetails?.allocationStatus?.reasonCode; + + expect(supplierId).toBe("unknown"); + expect(allocationStatus).toBe("REJECTED"); + expect(reasonCode).toBe("NO_SUPPLIERS_AVAILABLE"); + + const lettersInDb = await getLettersFromSupplierTable( + supplierId!, + domainId, + "REJECTED", + ); + expect(lettersInDb).toBeTruthy(); + expect(lettersInDb.status).toBe(allocationStatus); + expect(lettersInDb.reasonCode).toBe(reasonCode); + expect(lettersInDb.reasonText).toBe( + `No letter variant details found for id unknown-variant-${domainId}`, + ); + }); + + test("Verify that the letters are REJECTED when no pack specification is eligible", async () => { + const letterVariant = getVariantsForAllocation(1); + const domainId = `NoEligiblePackSpecs-${randomUUID()}`; + const preparedEvent = createPreparedV1Event({ + domainId, + letterVariantId: letterVariant, + pageCount: 100, // high page count to ensure pack specifications are filtered out based on constraints + }); + + const response = await sendSnsEvent(preparedEvent); + expect(response.MessageId).toBeTruthy(); + + const supplierAllocatorLog = await getAllocationLog( + "No eligible pack specifications found for letter", + ); + + const { packSpecificationIds } = supplierAllocatorLog; + expect(packSpecificationIds).toBeTruthy(); + expect(supplierAllocatorLog.letterVariantId).toBe(letterVariant); + + const allocationLog = await getAllocationLogForDomainId(domainId); + const lettersInDb = await getLettersFromSupplierTable( + "unknown", + domainId, + "REJECTED", + ); + + expect(lettersInDb.status).toBe("REJECTED"); + expect(lettersInDb.supplierId).toBe( + allocationLog.msg?.allocationDetails?.supplierSpec?.supplierId, + ); + expect(lettersInDb.reasonText).toBe( + `No eligible pack specifications found for letter variant id ${letterVariant} and pack specification ids ${packSpecificationIds?.join(", ")}`, + ); + }); +}); diff --git a/tests/component-tests/allocation-tests/letter-allocation-weighting.spec.ts b/tests/component-tests/allocation-tests/letter-allocation-weighting.spec.ts new file mode 100644 index 000000000..6f598c799 --- /dev/null +++ b/tests/component-tests/allocation-tests/letter-allocation-weighting.spec.ts @@ -0,0 +1,255 @@ +import { randomUUID } from "node:crypto"; +import { expect, test } from "@playwright/test"; +import { + SupplierFactorLog, + getAllocationLog, + getAllocationLogForDomainId, + getOrSeedLetterDailyAllocationFromDb, + getOrSeedOverallAllocationFromDb, + getOverallAllocationFromDb, + getVariantsForAllocation, + updateSupplierDailyAllocation, + updateSupplierOverallAllocation, +} from "tests/helpers/allocation-helper"; +import { createPreparedV1Event } from "tests/helpers/event-fixtures"; +import { sendSnsEvent } from "tests/helpers/send-sns-event"; +import { + buildWeightingSnapshot, + getLowestWeightingSupplier, +} from "tests/helpers/allocation-factor-helper"; + +test.describe("Allocator Weighting Tests", () => { + test.setTimeout(180_000); + + test("Verify weighting and lowest weighting supplier selection for multiple suppliers", async () => { + const testStartedAt = Date.now(); + const domainId = randomUUID(); + const letterVariant = getVariantsForAllocation(2); + const volumeGroupId = "volumeGroup-test3"; + const targetPercentages = { + supplier1: 50, + supplier2: 50, + }; + + const dailyAllocation = await getOrSeedLetterDailyAllocationFromDb({ + supplier1: 0, + supplier2: 0, + }); + const overallAllocation = await getOrSeedOverallAllocationFromDb( + { + supplier1: 0, + supplier2: 0, + }, + volumeGroupId, + ); + + const originalDailySupplier1 = dailyAllocation.allocations.supplier1 ?? 0; + const originalDailySupplier2 = dailyAllocation.allocations.supplier2 ?? 0; + const originalOverallSupplier1 = + overallAllocation.allocations.supplier1 ?? 0; + const originalOverallSupplier2 = + overallAllocation.allocations.supplier2 ?? 0; + + const seededOverall = { + supplier1: 900, + supplier2: 100, + }; + + try { + await updateSupplierDailyAllocation("supplier1", 0); + await updateSupplierDailyAllocation("supplier2", 0); + await updateSupplierOverallAllocation( + "supplier1", + seededOverall.supplier1, + volumeGroupId, + ); + await updateSupplierOverallAllocation( + "supplier2", + seededOverall.supplier2, + volumeGroupId, + ); + + const weightingSnapshot = buildWeightingSnapshot( + seededOverall, + targetPercentages, + ); + const lowestWeightingSupplier = + getLowestWeightingSupplier(weightingSnapshot); + + expect(weightingSnapshot.supplier1.allocatedVolume).toBe(900); + expect(weightingSnapshot.supplier2.allocatedVolume).toBe(100); + expect(weightingSnapshot.supplier1.allocatedPercentage).toBe(90); + expect(weightingSnapshot.supplier2.allocatedPercentage).toBe(10); + expect(lowestWeightingSupplier).toBe("supplier2"); + + const preparedEvent = createPreparedV1Event({ + domainId, + letterVariantId: letterVariant, + }); + const response = await sendSnsEvent(preparedEvent); + expect(response.MessageId).toBeTruthy(); + + const supplierAllocatorLog = await getAllocationLogForDomainId(domainId); + const supplierFactorLog = await getAllocationLog( + "Calculated supplier factors for allocation", + { + startTimeMs: testStartedAt, + }, + ); + + const selectedSupplierId = + supplierAllocatorLog.msg?.allocationDetails?.supplierSpec?.supplierId; + + expect(selectedSupplierId).toBe(lowestWeightingSupplier); + expect(weightingSnapshot.supplier1.weighting).toBe( + supplierFactorLog.supplierFactors?.find( + (factor) => factor.supplierId === "supplier1", + )?.factor, + ); + expect(weightingSnapshot.supplier2.weighting).toBe( + supplierFactorLog.supplierFactors?.find( + (factor) => factor.supplierId === "supplier2", + )?.factor, + ); + + const updatedOverallAllocation = + await getOverallAllocationFromDb(volumeGroupId); + expect(updatedOverallAllocation.allocations.supplier1).toBe(900); + expect(updatedOverallAllocation.allocations.supplier2).toBe(101); + expect(supplierFactorLog.description).toBe( + "Calculated supplier factors for allocation", + ); + } finally { + await updateSupplierDailyAllocation("supplier1", originalDailySupplier1); + await updateSupplierDailyAllocation("supplier2", originalDailySupplier2); + await updateSupplierOverallAllocation( + "supplier1", + originalOverallSupplier1, + volumeGroupId, + ); + await updateSupplierOverallAllocation( + "supplier2", + originalOverallSupplier2, + volumeGroupId, + ); + } + }); + + test("Verify lowest weighting supplier selection with valid unequal target percentages", async () => { + const testStartedAt = Date.now(); + const domainId = randomUUID(); + const letterVariant = getVariantsForAllocation(1); + const volumeGroupId = "volumeGroup-test1"; + const targetPercentages = { + supplier1: 30, + supplier2: 70, + }; + + const dailyAllocation = await getOrSeedLetterDailyAllocationFromDb({ + supplier1: 0, + supplier2: 0, + }); + const overallAllocation = await getOrSeedOverallAllocationFromDb( + { + supplier1: 0, + supplier2: 0, + }, + volumeGroupId, + ); + + const originalDailySupplier1 = dailyAllocation.allocations.supplier1 ?? 0; + const originalDailySupplier2 = dailyAllocation.allocations.supplier2 ?? 0; + const originalOverallSupplier1 = + overallAllocation.allocations.supplier1 ?? 0; + const originalOverallSupplier2 = + overallAllocation.allocations.supplier2 ?? 0; + + const seededOverall = { + supplier1: 600, + supplier2: 400, + }; + + try { + await updateSupplierDailyAllocation("supplier1", 0); + await updateSupplierDailyAllocation("supplier2", 0); + await updateSupplierOverallAllocation( + "supplier1", + seededOverall.supplier1, + volumeGroupId, + ); + await updateSupplierOverallAllocation( + "supplier2", + seededOverall.supplier2, + volumeGroupId, + ); + + const weightingSnapshot = buildWeightingSnapshot( + seededOverall, + targetPercentages, + ); + const lowestWeightingSupplier = + getLowestWeightingSupplier(weightingSnapshot); + + expect(weightingSnapshot.supplier1.allocatedVolume).toBe(600); + expect(weightingSnapshot.supplier2.allocatedVolume).toBe(400); + expect(weightingSnapshot.supplier1.allocatedPercentage).toBe(60); + expect(weightingSnapshot.supplier2.allocatedPercentage).toBe(40); + expect(weightingSnapshot.supplier1.targetPercentage).toBe(30); + expect(weightingSnapshot.supplier2.targetPercentage).toBe(70); + expect(weightingSnapshot.supplier1.weighting).toBe(2); + expect(weightingSnapshot.supplier2.weighting).toBeCloseTo(4 / 7, 10); + expect(lowestWeightingSupplier).toBe("supplier2"); + expect(Object.keys(weightingSnapshot)).toEqual( + expect.arrayContaining(["supplier1", "supplier2"]), + ); + + const preparedEvent = createPreparedV1Event({ + domainId, + letterVariantId: letterVariant, + }); + const response = await sendSnsEvent(preparedEvent); + expect(response.MessageId).toBeTruthy(); + + const supplierAllocatorLog = await getAllocationLogForDomainId(domainId); + const supplierFactorLog = await getAllocationLog( + "Calculated supplier factors for allocation", + { + startTimeMs: testStartedAt, + }, + ); + + const selectedSupplierId = + supplierAllocatorLog.msg?.allocationDetails?.supplierSpec?.supplierId; + + expect(selectedSupplierId).toBe(lowestWeightingSupplier); + expect(weightingSnapshot.supplier1.weighting).toBe( + supplierFactorLog.supplierFactors?.find( + (factor) => factor.supplierId === "supplier1", + )?.factor, + ); + expect(weightingSnapshot.supplier2.weighting).toBe( + supplierFactorLog.supplierFactors?.find( + (factor) => factor.supplierId === "supplier2", + )?.factor, + ); + + const updatedOverallAllocation = + await getOverallAllocationFromDb(volumeGroupId); + expect(updatedOverallAllocation.allocations.supplier1).toBe(600); + expect(updatedOverallAllocation.allocations.supplier2).toBe(401); + } finally { + await updateSupplierDailyAllocation("supplier1", originalDailySupplier1); + await updateSupplierDailyAllocation("supplier2", originalDailySupplier2); + await updateSupplierOverallAllocation( + "supplier1", + originalOverallSupplier1, + volumeGroupId, + ); + await updateSupplierOverallAllocation( + "supplier2", + originalOverallSupplier2, + volumeGroupId, + ); + } + }); +}); diff --git a/tests/helpers/allocation-factor-helper.ts b/tests/helpers/allocation-factor-helper.ts new file mode 100644 index 000000000..dc1036b9f --- /dev/null +++ b/tests/helpers/allocation-factor-helper.ts @@ -0,0 +1,57 @@ +type SnapshotEntry = { + allocatedVolume: number; + allocatedPercentage: number; + targetPercentage: number; + weighting: number; +}; + +export function buildWeightingSnapshot( + allocations: Record, + targetPercentages: Record, +): Record { + const allocationsMap = new Map(Object.entries(allocations)); + let totalAllocated = 0; + for (const value of allocationsMap.values()) { + totalAllocated += value; + } + + return Object.fromEntries( + Object.entries(targetPercentages).map(([supplierId, targetPercentage]) => { + const allocatedVolume = allocationsMap.get(supplierId) ?? 0; + const allocatedPercentage = + totalAllocated > 0 ? (allocatedVolume / totalAllocated) * 100 : 0; + const weighting = allocatedPercentage / targetPercentage; + + return [ + supplierId, + { + allocatedVolume, + allocatedPercentage, + targetPercentage, + weighting, + }, + ]; + }), + ); +} + +export function getLowestWeightingSupplier( + snapshot: Record, +): string { + const entries = Object.entries(snapshot); + if (entries.length === 0) { + throw new Error("Weighting snapshot is empty"); + } + + let lowestSupplierId = entries[0][0]; + let lowestWeighting = entries[0][1].weighting; + + for (const [supplierId, snapshotEntry] of entries) { + if (snapshotEntry.weighting < lowestWeighting) { + lowestWeighting = snapshotEntry.weighting; + lowestSupplierId = supplierId; + } + } + + return lowestSupplierId; +} diff --git a/tests/helpers/allocation-helper.ts b/tests/helpers/allocation-helper.ts new file mode 100644 index 000000000..34ad1a3d2 --- /dev/null +++ b/tests/helpers/allocation-helper.ts @@ -0,0 +1,374 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { + DynamoDBDocumentClient, + GetCommand, + PutCommand, + UpdateCommand, +} from "@aws-sdk/lib-dynamodb"; +import { envName } from "tests/constants/api-constants"; +import { + pollAllocatorLogWithOptions, + pollSupplierAllocatorLogForExceededDailyCapacity, + pollSupplierAllocatorLogForResolvedSpec, +} from "./aws-cloudwatch-helper"; + +const ddb = new DynamoDBClient({}); +const docClient = DynamoDBDocumentClient.from(ddb); + +export const AllocationTestVariantMap: Record = { + "notify-standard-test1": 1, + "client1-campaign1": 2, + "notify-standard-colour": 3, + "client1-campaign2": 4, + "notify-first-test": 5, +}; + +export function getVariantsForAllocation(testCase: number) { + const variants = Object.keys(AllocationTestVariantMap).filter( + // eslint-disable-next-line security/detect-object-injection + (variant) => AllocationTestVariantMap[variant] === testCase, + ); + if (variants.length === 0) { + throw new Error(`No variants found with testCase ${testCase}`); + } + return variants[0]; +} + +export type SupplierAllocatorLog = { + msg?: { + allocationDetails?: { + supplierSpec?: { + supplierId?: string; + specId?: string; + billingId?: string; + }; + allocationStatus?: { + status?: "PENDING" | "REJECTED"; + reasonCode?: string; + }; + }; + }; +}; + +type PackSpecificationLog = { + description: string; + packSpecId?: string; + pageCount?: number; + constraintValue?: number; + constraintOperator?: string; +}; + +export type PackErrorLog = { + description: string; + letterVariantId?: string; + packSpecificationIds?: string[]; +}; + +export type SupplierFactorEntry = { + supplierId: string; + factor: number; +}; + +export type SupplierFactorLog = { + description: string; + supplierFactors?: SupplierFactorEntry[]; +}; + +export type AllocationLogOptions = { + startTimeMs?: number; + extraPatterns?: string[]; +}; + +type LetterVariantConfig = { + id: string; + packSpecificationIds: string[]; + constraints: { + blackCoveragePercentage: Record; + deliveryDays: Record; + sides: Record; + sheets: Record; + }; +}; + +type DailyAllocationConfig = { + id: string; + date: string; + allocations: Record; +}; + +type OverallAllocationConfig = { + id: string; + volumeGroup: string; + allocations: Record; +}; + +export type SupplierDailyCapacityExceededLog = { + level?: string; + timestamp?: string; + pid?: number; + hostname?: string; + description?: string; + supplierId?: string; + allocated?: number; + dailyCapacity?: number; +}; + +const getSupplierConfigTableName = (): string => + process.env.SUPPLIER_CONFIG_TABLE_NAME ?? + `nhs-${envName}-supapi-supplier-config`; + +const getSupplierQuotasTableName = (): string => + process.env.SUPPLIER_QUOTAS_TABLE_NAME ?? + `nhs-${envName}-supapi-supplier-quotas`; + +const getAllocationDate = (): string => new Date().toISOString().slice(0, 10); + +export async function getAllocationLogForDomainId( + domainId: string, +): Promise { + const message = await pollSupplierAllocatorLogForResolvedSpec(domainId); + const supplierAllocatorLog = JSON.parse(message) as SupplierAllocatorLog; + + return supplierAllocatorLog; +} + +export async function getAllocationLog< + TLog extends { description: string } = PackSpecificationLog, +>(description: string, options?: AllocationLogOptions): Promise { + const message = await pollAllocatorLogWithOptions(description, options); + const allocationLog = JSON.parse(message) as TLog; + return allocationLog; +} + +export async function getExceededDailyCapacityLog( + supplierId: string, +): Promise { + const message = + await pollSupplierAllocatorLogForExceededDailyCapacity(supplierId); + return JSON.parse(message) as SupplierDailyCapacityExceededLog; +} + +export async function getLetterVariantConfigFromDb( + letterVariantId: string, +): Promise { + const { Item } = await docClient.send( + new GetCommand({ + TableName: getSupplierConfigTableName(), + Key: { + pk: "ENTITY#letter-variant", + sk: `ID#${letterVariantId}`, + }, + }), + ); + + if (!Item) { + throw new Error( + `Letter variant config was not found in supplier config table for id ${letterVariantId}`, + ); + } + + return Item as LetterVariantConfig; +} + +export async function getLetterDailyAllocationFromDb( + allocationDate: string = getAllocationDate(), +): Promise { + const { Item } = await docClient.send( + new GetCommand({ + TableName: getSupplierQuotasTableName(), + Key: { + pk: "ENTITY#daily-allocation", + sk: `ID#${allocationDate}`, + }, + }), + ); + + if (!Item) { + throw new Error( + `Letter daily allocation was not found in supplier config table for date ${allocationDate}`, + ); + } + + return Item as DailyAllocationConfig; +} + +export async function getOverallAllocationFromDb( + volumeGroupId: string, +): Promise { + const { Item } = await docClient.send( + new GetCommand({ + TableName: getSupplierQuotasTableName(), + Key: { + pk: "ENTITY#overall-allocation", + sk: `ID#${volumeGroupId}`, + }, + }), + ); + + if (!Item) { + throw new Error( + `Overall allocation was not found in supplier config table for volume group ${volumeGroupId}`, + ); + } + + return Item as OverallAllocationConfig; +} + +export async function seedLetterDailyAllocation( + allocations: Record, + allocationDate: string = getAllocationDate(), +): Promise { + const now = new Date().toISOString(); + const item: DailyAllocationConfig & { + pk: string; + sk: string; + createdAt: string; + updatedAt: string; + } = { + pk: "ENTITY#daily-allocation", + sk: `ID#${allocationDate}`, + id: `ID#${allocationDate}`, + date: allocationDate, + allocations, + createdAt: now, + updatedAt: now, + }; + + await docClient.send( + new PutCommand({ + TableName: getSupplierQuotasTableName(), + Item: item, + ConditionExpression: "attribute_not_exists(pk)", + }), + ); + + return item; +} + +export async function seedOverallAllocation( + allocations: Record, + volumeGroupId: string, +): Promise { + const now = new Date().toISOString(); + const item: OverallAllocationConfig & { + pk: string; + sk: string; + createdAt: string; + updatedAt: string; + } = { + pk: "ENTITY#overall-allocation", + sk: `ID#${volumeGroupId}`, + id: volumeGroupId, + volumeGroup: volumeGroupId, + allocations, + createdAt: now, + updatedAt: now, + }; + + await docClient.send( + new PutCommand({ + TableName: getSupplierQuotasTableName(), + Item: item, + ConditionExpression: "attribute_not_exists(pk)", + }), + ); + + return item; +} + +export async function getOrSeedLetterDailyAllocationFromDb( + defaultAllocations: Record, + allocationDate: string = getAllocationDate(), +): Promise { + try { + return await getLetterDailyAllocationFromDb(allocationDate); + } catch { + return seedLetterDailyAllocation(defaultAllocations, allocationDate); + } +} + +export async function getOrSeedOverallAllocationFromDb( + defaultAllocations: Record, + volumeGroupId: string, +): Promise { + try { + return await getOverallAllocationFromDb(volumeGroupId); + } catch { + return seedOverallAllocation(defaultAllocations, volumeGroupId); + } +} + +export async function updateSupplierDailyAllocation( + supplierId: string, + allocation: number, + allocationDate: string = getAllocationDate(), +): Promise { + const now = new Date().toISOString(); + + const key = { + pk: "ENTITY#daily-allocation", + sk: `ID#${allocationDate}`, + }; + + await docClient.send( + new UpdateCommand({ + TableName: getSupplierQuotasTableName(), + Key: key, + UpdateExpression: ` + SET + allocations.#supplierId = :allocation, + id = if_not_exists(id, :id), + #date = if_not_exists(#date, :date), + createdAt = if_not_exists(createdAt, :now), + updatedAt = :now + `, + ExpressionAttributeNames: { + "#supplierId": supplierId, + "#date": "date", + }, + ExpressionAttributeValues: { + ":allocation": allocation, + ":id": `ID#${allocationDate}`, + ":date": allocationDate, + ":now": now, + }, + }), + ); +} + +export async function updateSupplierOverallAllocation( + supplierId: string, + allocation: number, + volumeGroupId: string, +): Promise { + const now = new Date().toISOString(); + + const key = { + pk: "ENTITY#overall-allocation", + sk: `ID#${volumeGroupId}`, + }; + + await docClient.send( + new UpdateCommand({ + TableName: getSupplierQuotasTableName(), + Key: key, + UpdateExpression: ` + SET + allocations.#supplierId = :allocation, + id = if_not_exists(id, :id), + volumeGroup = if_not_exists(volumeGroup, :volumeGroup), + createdAt = if_not_exists(createdAt, :now), + updatedAt = :now + `, + ExpressionAttributeNames: { + "#supplierId": supplierId, + }, + ExpressionAttributeValues: { + ":allocation": allocation, + ":id": volumeGroupId, + ":volumeGroup": volumeGroupId, + ":now": now, + }, + }), + ); +} diff --git a/tests/helpers/aws-cloudwatch-helper.ts b/tests/helpers/aws-cloudwatch-helper.ts index facd450c4..140082f70 100644 --- a/tests/helpers/aws-cloudwatch-helper.ts +++ b/tests/helpers/aws-cloudwatch-helper.ts @@ -14,9 +14,10 @@ async function pollLambdaLog( lambdaName: string, filterPatterns: string[], extraPatterns?: string[], + startTimeMs?: number, ): Promise { const intervalMs = 5000; - const startTimeMs = Date.now() - 5 * 60_000; + const queryStartTimeMs = startTimeMs ?? Date.now() - 5 * 60_000; const timeoutMs = 120_000; const client = new CloudWatchLogsClient({ region: AWS_REGION }); @@ -27,7 +28,7 @@ async function pollLambdaLog( const response = await client.send( new FilterLogEventsCommand({ logGroupName, - startTime: startTimeMs, + startTime: queryStartTimeMs, interleaved: true, limit: 100, filterPattern: filterPatterns.join(" "), @@ -40,6 +41,7 @@ async function pollLambdaLog( ? extraPatterns.some((pattern) => message.includes(pattern)) : true; }); + if (foundEvent?.message) { return foundEvent.message; } @@ -113,3 +115,40 @@ export async function supplierIdFromSupplierAllocatorLog( } return supplierId; } + +export async function pollAllocatorLog(description: string): Promise { + const filterPatterns = ['"INFO"', `"${description}"`]; + const log = await pollLambdaLog("supplier-allocator", filterPatterns); + return log; +} + +export type AllocatorLogPollOptions = { + startTimeMs?: number; + extraPatterns?: string[]; +}; + +export async function pollAllocatorLogWithOptions( + description: string, + options?: AllocatorLogPollOptions, +): Promise { + const filterPatterns = [`"${description}"`]; + console.log(filterPatterns); + const log = await pollLambdaLog( + "supplier-allocator", + filterPatterns, + options?.extraPatterns, + options?.startTimeMs, + ); + return log; +} + +export async function pollSupplierAllocatorLogForExceededDailyCapacity( + supplierId: string, +): Promise { + const filterPatterns = [ + '"INFO"', + '"Supplier has exceeded daily capacity"', + `"${supplierId}"`, + ]; + return pollLambdaLog("supplier-allocator", filterPatterns); +} diff --git a/tests/helpers/generate-fetch-test-data.ts b/tests/helpers/generate-fetch-test-data.ts index 60aa046ae..832f70d85 100644 --- a/tests/helpers/generate-fetch-test-data.ts +++ b/tests/helpers/generate-fetch-test-data.ts @@ -306,3 +306,38 @@ export async function getLetterFromQueueById( return []; } } + +export async function getLettersFromSupplierTable( + supplierId: string, + id: string, + status: string, + options?: { + timeoutMs?: number; + intervalMs?: number; + }, +): Promise { + const timeoutMs = options?.timeoutMs ?? 60_000; + const intervalMs = options?.intervalMs ?? 5000; + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const { Item } = await docClient.send( + new GetCommand({ + TableName: LETTERSTABLENAME, + Key: { id, supplierId }, + }), + ); + + const letter = Item as SupplierApiLetters; + + if (letter && letter.status === status) { + return letter; + } + + await delay(intervalMs); + } + + throw new Error( + `Timed out waiting for letter ${id} to reach status ${status} for supplier ${supplierId}.`, + ); +}