Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ e12407e09151898bfd8d049d57eee9db9977d56b:.github/copilot-instructions.md:generic
4ad86108d4e08cd410061e8842dd3a2b3bee4867:scripts/JWT/README.md:generic-api-key:38
504844c9838740c8c5235024919f0775ad817cde:pact-contracts/pacts/letter-rendering/supplier-api-letter-request-prepared.json:generic-api-key:10
82cf3b2e89ea24b97c4ffc09e618700fb1b0aff3:pact-contracts/pacts/letter-rendering/supplier-api-letter-request-prepared.json:generic-api-key:10
82f6be3e657b46d8447e77cdc1894fba0b855c26:tests/component-tests/testCases/create-letter-request.spec.ts:generic-api-key:10
debc75a97cfe551a69fd1e8694be483213322a9d:pact-contracts/pacts/letter-rendering/supplier-api-letter-request-prepared.json:generic-api-key:10
1 change: 1 addition & 0 deletions infrastructure/terraform/modules/eventsub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
|------|-------------|
| <a name="output_s3_bucket_event_cache"></a> [s3\_bucket\_event\_cache](#output\_s3\_bucket\_event\_cache) | S3 Bucket ARN and Name for event cache |
| <a name="output_sns_topic"></a> [sns\_topic](#output\_sns\_topic) | SNS Topic ARN and Name |
| <a name="output_sns_topic_supplier"></a> [sns\_topic\_supplier](#output\_sns\_topic\_supplier) | SNS Topic ARN and Name |
<!-- vale on -->
<!-- markdownlint-enable -->
<!-- END_TF_DOCS -->
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ resource "aws_cloudwatch_metric_alarm" "sns_delivery_failures" {
treat_missing_data = "notBreaching"

dimensions = {
TopicName = aws_sns_topic.main.name
TopicName = aws_sns_topic.sns_topic_event_bus.name
}
}
12 changes: 10 additions & 2 deletions infrastructure/terraform/modules/eventsub/outputs.tf
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
output "sns_topic" {
description = "SNS Topic ARN and Name"
value = {
arn = aws_sns_topic.main.arn
name = aws_sns_topic.main.name
arn = aws_sns_topic.sns_topic_event_bus.arn
name = aws_sns_topic.sns_topic_event_bus.name
}
}

output "sns_topic_supplier" {
description = "SNS Topic ARN and Name"
value = {
arn = aws_sns_topic.sns_topic_supplier.arn
name = aws_sns_topic.sns_topic_supplier.name
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
resource "aws_sns_topic" "sns_topic_event_bus" {
name = "${local.csi}-event-bus-events"
kms_master_key_id = var.kms_key_arn

application_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
application_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
application_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null

firehose_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
firehose_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
firehose_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null

http_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
http_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
http_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null

lambda_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
lambda_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
lambda_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null

sqs_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
sqs_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
sqs_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
resource "aws_sns_topic_policy" "main" {
arn = aws_sns_topic.main.arn
arn = aws_sns_topic.sns_topic_event_bus.arn

policy = data.aws_iam_policy_document.sns_topic_policy.json
}
Expand Down Expand Up @@ -29,7 +29,7 @@ data "aws_iam_policy_document" "sns_topic_policy" {
]

resources = [
aws_sns_topic.main.arn,
aws_sns_topic.sns_topic_event_bus.arn,
]

condition {
Expand Down Expand Up @@ -57,7 +57,7 @@ data "aws_iam_policy_document" "sns_topic_policy" {
}

resources = [
aws_sns_topic.main.arn,
aws_sns_topic.sns_topic_event_bus.arn,
]
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
resource "aws_sns_topic_subscription" "firehose" {
count = var.enable_event_cache ? 1 : 0

topic_arn = aws_sns_topic.main.arn
topic_arn = aws_sns_topic.sns_topic_event_bus.arn
protocol = "firehose"
subscription_role_arn = aws_iam_role.sns_role.arn
endpoint = aws_kinesis_firehose_delivery_stream.main[0].arn
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
resource "aws_sns_topic" "main" {
name = local.csi
resource "aws_sns_topic" "sns_topic_supplier" {
name = "${local.csi}-supplier-events"
kms_master_key_id = var.kms_key_arn

application_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
Expand Down
3 changes: 3 additions & 0 deletions internal/datastore/src/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ erDiagram
string supplierStatus
string supplierStatusSk
number ttl "min: -9007199254740991, max: 9007199254740991"
string source
string subject
string billingRef
}
```

Expand Down
2 changes: 1 addition & 1 deletion internal/events/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,5 @@
"typecheck": "tsc --noEmit"
},
"types": "dist/index.d.ts",
"version": "1.0.6"
"version": "1.0.7"
}
1 change: 1 addition & 0 deletions lambdas/allocation/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist
4 changes: 4 additions & 0 deletions lambdas/allocation/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.build
coverage
node_modules
dist
60 changes: 60 additions & 0 deletions lambdas/allocation/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { Config } from "jest";

export const baseJestConfig: Config = {
preset: "ts-jest",

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

// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,

// The directory where Jest should output its coverage files
coverageDirectory: "./.reports/unit/coverage",

// Indicates which provider should be used to instrument code for coverage
coverageProvider: "babel",

coverageThreshold: {
global: {
branches: 100,
functions: 100,
lines: 100,
statements: -10,
},
},

coveragePathIgnorePatterns: ["/__tests__/"],
transform: { "^.+\\.ts$": "ts-jest" },
testPathIgnorePatterns: [".build"],
testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],

// Use this configuration option to add custom reporters to Jest
reporters: [
"default",
[
"jest-html-reporter",
{
pageTitle: "Test Report",
outputPath: "./.reports/unit/test-report.html",
includeFailureMsg: true,
},
],
],

// The test environment that will be used for testing
testEnvironment: "jsdom",
};

const utilsJestConfig = {
...baseJestConfig,

testEnvironment: "node",

coveragePathIgnorePatterns: [
...(baseJestConfig.coveragePathIgnorePatterns ?? []),
"zod-validators.ts",
],
};

export default utilsJestConfig;
27 changes: 27 additions & 0 deletions lambdas/allocation/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"dependencies": {
"@aws-sdk/client-sqs": "^3.925.0",
"@nhsdigital/nhs-notify-event-schemas-supplier-api": "*",
"aws-lambda": "^1.0.7",
"esbuild": "^0.25.11",
"pino": "^9.7.0",
"zod": "^4.1.11"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.2",
"@types/jest": "^30.0.0",
"jest": "^30.2.0",
"jest-mock-extended": "^4.0.0",
"typescript": "^5.9.3"
},
"name": "nhs-notify-supplier-allocation",
"private": true,
"scripts": {
"lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --entry-names=[name] --outdir=dist src/index.ts",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test:unit": "jest",
"typecheck": "tsc --noEmit"
},
"version": "0.0.1"
}
159 changes: 159 additions & 0 deletions lambdas/allocation/src/__tests__/allocator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { Context, SNSEvent, SNSEventRecord } from "aws-lambda";
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
import { mockDeep } from "jest-mock-extended";
import pino from "pino";
import {
$LetterEvent,
LetterEvent,
} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src";
import createAllocator from "../allocator";
import { Deps } from "../deps";

function createSNSEvent(records: SNSEventRecord[]): SNSEvent {
return {
Records: records,
};
}

function createSNSEventRecord(message: string): SNSEventRecord {
return {
Sns: {
Message: message,
} as SNSEventRecord["Sns"],
} as SNSEventRecord;
}

function createLetterEvent(domainId: string): LetterEvent {
const now = new Date().toISOString();

return $LetterEvent.parse({
data: {
domainId,
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/test",
},

specificationId: "spec-001",
billingRef: "billing-001",
status: "PENDING",
supplierId: "supplier-001",
},
datacontenttype: "application/json",
dataschema:
"https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.PENDING.1.0.0.schema.json",
dataschemaversion: "1.0.0",
id: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
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.PENDING.v1",
});
}

describe("allocator", () => {
const mockQueueUrl =
"https://sqs.eu-west-2.amazonaws.com/123456789012/test-queue.fifo";

let mockDeps: Deps;

beforeEach(() => {
mockDeps = {
sqsClient: { send: jest.fn() } as unknown as SQSClient,
queueUrl: mockQueueUrl,
logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
};

jest.clearAllMocks();
});

describe("createAllocator", () => {
it("should process a single SNS record and send message to SQS", async () => {
const letterEvent = createLetterEvent("id1");
const snsEvent = createSNSEvent([
createSNSEventRecord(JSON.stringify(letterEvent)),
]);

const handler = createAllocator(mockDeps);
await handler(snsEvent, mockDeep<Context>(), jest.fn());

expect(mockDeps.sqsClient.send).toHaveBeenCalledWith(
expect.objectContaining({
input: {
QueueUrl: mockQueueUrl,
MessageBody: JSON.stringify(letterEvent),
MessageGroupId: "id1",
},
}),
);
});

it("should process multiple SNS records and send messages to SQS", async () => {
const letterEvent1 = createLetterEvent("id1");
const letterEvent2 = createLetterEvent("id2");
const letterEvent3 = createLetterEvent("id3");

const snsEvent = createSNSEvent([
createSNSEventRecord(JSON.stringify(letterEvent1)),
createSNSEventRecord(JSON.stringify(letterEvent2)),
createSNSEventRecord(JSON.stringify(letterEvent3)),
]);

const handler = createAllocator(mockDeps);
await handler(snsEvent, mockDeep<Context>(), jest.fn());

expect(mockDeps.sqsClient.send).toHaveBeenCalledTimes(3);

expect(mockDeps.sqsClient.send).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
input: {
QueueUrl: mockQueueUrl,
MessageBody: JSON.stringify(letterEvent1),
MessageGroupId: "id1",
},
}),
);
expect(mockDeps.sqsClient.send).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
input: {
QueueUrl: mockQueueUrl,
MessageBody: JSON.stringify(letterEvent2),
MessageGroupId: "id2",
},
}),
);
expect(mockDeps.sqsClient.send).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
input: {
QueueUrl: mockQueueUrl,
MessageBody: JSON.stringify(letterEvent3),
MessageGroupId: "id3",
},
}),
);
});

it("should handle empty SNS event with no records", async () => {
const snsEvent = createSNSEvent([]);

const handler = createAllocator(mockDeps);
await handler(snsEvent, mockDeep<Context>(), jest.fn());

expect(mockDeps.sqsClient.send).not.toHaveBeenCalled();
});
});
});
Loading