Skip to content

Commit 06c7841

Browse files
CCM-13303: Create report scheduler (#195)
* CCM-13303: Create report scheduler * CCM-13303: Undo me * CCM-13303: Report scheduler component test * CCM-13303: Report scheduler component test * CCM-13303: Improve component test * CCM-13303: Address review comments * CCM-13303: Update Generate Report schema to require a reportDate * CCM-13303: Address comments + update Generate Report schema * CCM-13303: Address comments + update Generate Report schema * CCM-13303: Fix component test * CCM-13303: Fix Core Notifier permission * CCM-13303: Fix Core Notifier permissions
1 parent 996b33f commit 06c7841

22 files changed

Lines changed: 824 additions & 18 deletions

infrastructure/terraform/components/dl/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ No requirements.
3737
| <a name="input_queue_batch_size"></a> [queue\_batch\_size](#input\_queue\_batch\_size) | maximum number of queue items to process | `number` | `10` | no |
3838
| <a name="input_queue_batch_window_seconds"></a> [queue\_batch\_window\_seconds](#input\_queue\_batch\_window\_seconds) | maximum time in seconds between processing events | `number` | `1` | no |
3939
| <a name="input_region"></a> [region](#input\_region) | The AWS Region | `string` | n/a | yes |
40+
| <a name="input_report_scheduler_schedule"></a> [report\_scheduler\_schedule](#input\_report\_scheduler\_schedule) | Schedule to trigger sender reports | `string` | `"cron(30 4 * * ? *)"` | no |
4041
| <a name="input_shared_infra_account_id"></a> [shared\_infra\_account\_id](#input\_shared\_infra\_account\_id) | The AWS Shared Infra Account ID (numeric) | `string` | n/a | yes |
4142
| <a name="input_ttl_poll_schedule"></a> [ttl\_poll\_schedule](#input\_ttl\_poll\_schedule) | Schedule to poll for any overdue TTL records | `string` | `"rate(10 minutes)"` | no |
4243
## Modules
@@ -58,6 +59,7 @@ No requirements.
5859
| <a name="module_print_analyser"></a> [print\_analyser](#module\_print\_analyser) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
5960
| <a name="module_print_status_handler"></a> [print\_status\_handler](#module\_print\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
6061
| <a name="module_report_event_transformer"></a> [report\_event\_transformer](#module\_report\_event\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
62+
| <a name="module_report_scheduler"></a> [report\_scheduler](#module\_report\_scheduler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
6163
| <a name="module_s3bucket_cf_logs"></a> [s3bucket\_cf\_logs](#module\_s3bucket\_cf\_logs) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a |
6264
| <a name="module_s3bucket_file_quarantine"></a> [s3bucket\_file\_quarantine](#module\_s3bucket\_file\_quarantine) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a |
6365
| <a name="module_s3bucket_file_safe"></a> [s3bucket\_file\_safe](#module\_s3bucket\_file\_safe) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a |

infrastructure/terraform/components/dl/module_lambda_core_notifier.tf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ data "aws_iam_policy_document" "core_notifier_lambda" {
5656

5757
resources = [
5858
"arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.component}/${var.environment}/apim/*",
59-
"arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.component}/${var.environment}/senders/*"
59+
"arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter${local.ssm_senders_prefix}/*"
6060
]
6161
}
6262

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
module "report_scheduler" {
2+
source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip"
3+
4+
function_name = "report-scheduler"
5+
description = "A function for triggering reports"
6+
7+
aws_account_id = var.aws_account_id
8+
component = local.component
9+
environment = var.environment
10+
project = var.project
11+
region = var.region
12+
group = var.group
13+
14+
log_retention_in_days = var.log_retention_in_days
15+
kms_key_arn = module.kms.key_arn
16+
17+
iam_policy_document = {
18+
body = data.aws_iam_policy_document.report_scheduler_lambda.json
19+
}
20+
21+
function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
22+
function_code_base_path = local.aws_lambda_functions_dir_path
23+
function_code_dir = "report-scheduler/dist"
24+
function_include_common = true
25+
handler_function_name = "handler"
26+
runtime = "nodejs22.x"
27+
memory = 128
28+
timeout = 360
29+
log_level = var.log_level
30+
schedule = var.report_scheduler_schedule
31+
32+
force_lambda_code_deploy = var.force_lambda_code_deploy
33+
enable_lambda_insights = false
34+
35+
log_destination_arn = local.log_destination_arn
36+
log_subscription_role_arn = local.acct.log_subscription_role_arn
37+
38+
lambda_env_vars = {
39+
"EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn
40+
"EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url
41+
"ENVIRONMENT" = var.environment
42+
}
43+
}
44+
45+
data "aws_iam_policy_document" "report_scheduler_lambda" {
46+
statement {
47+
sid = "KMSPermissions"
48+
effect = "Allow"
49+
50+
actions = [
51+
"kms:Decrypt",
52+
"kms:GenerateDataKey",
53+
]
54+
55+
resources = [
56+
module.kms.key_arn,
57+
]
58+
}
59+
60+
statement {
61+
sid = "EventBridgePermissions"
62+
effect = "Allow"
63+
64+
actions = [
65+
"events:PutEvents",
66+
]
67+
68+
resources = [
69+
aws_cloudwatch_event_bus.main.arn,
70+
]
71+
}
72+
73+
statement {
74+
sid = "DLQPermissions"
75+
effect = "Allow"
76+
77+
actions = [
78+
"sqs:SendMessage",
79+
"sqs:SendMessageBatch",
80+
]
81+
82+
resources = [
83+
module.sqs_event_publisher_errors.sqs_queue_arn,
84+
]
85+
}
86+
87+
statement {
88+
sid = "SSMPermissions"
89+
effect = "Allow"
90+
91+
actions = [
92+
"ssm:GetParameter",
93+
"ssm:GetParametersByPath",
94+
]
95+
96+
resources = [
97+
"arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter${local.ssm_senders_prefix}/*"
98+
]
99+
}
100+
}

infrastructure/terraform/components/dl/module_lambda_ttl_create.tf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ data "aws_iam_policy_document" "ttl_create_lambda" {
125125
]
126126

127127
resources = [
128-
"arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.component}/${var.environment}/senders/*"
128+
"arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter${local.ssm_senders_prefix}/*"
129129
]
130130
}
131131
}

infrastructure/terraform/components/dl/variables.tf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ variable "enable_pdm_mock" {
181181
default = true
182182
}
183183

184+
variable "report_scheduler_schedule" {
185+
type = string
186+
description = "Schedule to trigger sender reports"
187+
default = "cron(30 4 * * ? *)" # Daily at 04:30
188+
}
189+
184190
variable "pii_data_retention_policy_days" {
185191
type = number
186192
description = "The number of days for data retention policy for PII"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { baseJestConfig } from '../../jest.config.base';
2+
3+
const config = baseJestConfig;
4+
5+
export default config;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"dependencies": {
3+
"digital-letters-events": "^0.0.1",
4+
"sender-management": "^0.0.1",
5+
"utils": "^0.0.1"
6+
},
7+
"devDependencies": {
8+
"@tsconfig/node22": "^22.0.2",
9+
"jest": "^29.7.0",
10+
"typescript": "^5.9.3"
11+
},
12+
"name": "nhs-notify-digital-letters-report-scheduler-lambda",
13+
"private": true,
14+
"scripts": {
15+
"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",
16+
"lint": "eslint .",
17+
"lint:fix": "eslint . --fix",
18+
"test:unit": "jest",
19+
"typecheck": "tsc --noEmit"
20+
},
21+
"version": "0.0.1"
22+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { EventPublisher, Sender } from 'utils';
2+
import { ISenderManagement } from 'sender-management';
3+
import { GenerateReport } from 'digital-letters-events';
4+
import { createHandler } from 'apis/scheduled-event-handler';
5+
import GenerateReportValidator from 'digital-letters-events/GenerateReport.js';
6+
7+
describe('scheduled-event-handler', () => {
8+
let mockSenderManagement: jest.Mocked<ISenderManagement>;
9+
let mockEventPublisher: jest.Mocked<EventPublisher>;
10+
11+
beforeEach(() => {
12+
mockSenderManagement = {
13+
listSenders: jest.fn(),
14+
} as unknown as jest.Mocked<ISenderManagement>;
15+
16+
mockEventPublisher = {
17+
sendEvents: jest.fn(),
18+
} as unknown as jest.Mocked<EventPublisher>;
19+
20+
jest.useFakeTimers();
21+
});
22+
23+
afterEach(() => {
24+
jest.useRealTimers();
25+
});
26+
27+
describe('createHandler', () => {
28+
it('should retrieve senders from sender management', async () => {
29+
mockSenderManagement.listSenders.mockResolvedValue([]);
30+
mockEventPublisher.sendEvents.mockResolvedValue([]);
31+
32+
const handler = createHandler({
33+
senderManagement: mockSenderManagement,
34+
eventPublisher: mockEventPublisher,
35+
});
36+
37+
await handler();
38+
39+
expect(mockSenderManagement.listSenders).toHaveBeenCalledTimes(1);
40+
});
41+
42+
it('should publish generate report events for each sender', async () => {
43+
const mockDate = new Date('2024-01-15T12:00:00.000Z');
44+
jest.setSystemTime(mockDate);
45+
46+
const mockSenders = [
47+
{ senderId: 'sender-1' },
48+
{ senderId: 'sender-2' },
49+
{ senderId: 'sender-3' },
50+
] as unknown as Sender[];
51+
52+
mockSenderManagement.listSenders.mockResolvedValue(mockSenders);
53+
mockEventPublisher.sendEvents.mockResolvedValue([]);
54+
55+
const handler = createHandler({
56+
senderManagement: mockSenderManagement,
57+
eventPublisher: mockEventPublisher,
58+
});
59+
60+
await handler();
61+
62+
expect(mockEventPublisher.sendEvents).toHaveBeenCalledTimes(1);
63+
const [[events, validator]] = mockEventPublisher.sendEvents.mock.calls;
64+
65+
expect(events).toHaveLength(3);
66+
expect(validator).toBeDefined();
67+
});
68+
69+
it('should create events with correct structure for each sender', async () => {
70+
const mockDate = new Date('2024-01-15T12:00:00.000Z');
71+
jest.setSystemTime(mockDate);
72+
73+
const mockSenders = [
74+
{ senderId: 'test-sender-123' },
75+
] as unknown as Sender[];
76+
77+
mockSenderManagement.listSenders.mockResolvedValue(mockSenders);
78+
mockEventPublisher.sendEvents.mockResolvedValue([]);
79+
80+
const handler = createHandler({
81+
senderManagement: mockSenderManagement,
82+
eventPublisher: mockEventPublisher,
83+
});
84+
85+
await handler();
86+
87+
const [[events]] = mockEventPublisher.sendEvents.mock.calls;
88+
const event = events[0] as GenerateReport;
89+
90+
expect(event.data.senderId).toBe('test-sender-123');
91+
expect(event.data.reportDate).toBe('2024-01-14');
92+
expect(event.specversion).toBe('1.0');
93+
expect(event.id).toBeDefined();
94+
expect(event.source).toBe(
95+
'/nhs/england/notify/production/primary/data-plane/digitalletters/reporting',
96+
);
97+
expect(event.subject).toBe('customer/test-sender-123');
98+
expect(event.type).toBe(
99+
'uk.nhs.notify.digital.letters.reporting.generate.report.v1',
100+
);
101+
expect(event.time).toBe('2024-01-15T12:00:00.000Z');
102+
expect(event.severitynumber).toBe(2);
103+
104+
const isEventValid = GenerateReportValidator(event);
105+
expect(GenerateReportValidator.errors).toBeNull();
106+
expect(isEventValid).toBe(true);
107+
});
108+
109+
it('should handle empty sender list', async () => {
110+
mockSenderManagement.listSenders.mockResolvedValue([]);
111+
mockEventPublisher.sendEvents.mockResolvedValue([]);
112+
113+
const handler = createHandler({
114+
senderManagement: mockSenderManagement,
115+
eventPublisher: mockEventPublisher,
116+
});
117+
118+
await handler();
119+
120+
const [[events]] = mockEventPublisher.sendEvents.mock.calls;
121+
expect(events).toHaveLength(0);
122+
});
123+
124+
it('should handle event publisher errors', async () => {
125+
const mockSenders = [{ senderId: 'sender-1' }] as unknown as Sender[];
126+
const error = new Error('Failed to publish events');
127+
128+
mockSenderManagement.listSenders.mockResolvedValue(mockSenders);
129+
mockEventPublisher.sendEvents.mockRejectedValue(error);
130+
131+
const handler = createHandler({
132+
senderManagement: mockSenderManagement,
133+
eventPublisher: mockEventPublisher,
134+
});
135+
136+
await expect(handler()).rejects.toThrow('Failed to publish events');
137+
});
138+
139+
it('should generate unique event IDs for multiple senders', async () => {
140+
const mockSenders = [
141+
{ senderId: 'sender-1' },
142+
{ senderId: 'sender-2' },
143+
] as unknown as Sender[];
144+
145+
mockSenderManagement.listSenders.mockResolvedValue(mockSenders);
146+
mockEventPublisher.sendEvents.mockResolvedValue([]);
147+
148+
const handler = createHandler({
149+
senderManagement: mockSenderManagement,
150+
eventPublisher: mockEventPublisher,
151+
});
152+
153+
await handler();
154+
155+
const [[events]] = mockEventPublisher.sendEvents.mock.calls;
156+
const eventIds = events.map((e) => e.id);
157+
158+
expect(new Set(eventIds).size).toBe(eventIds.length);
159+
});
160+
});
161+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { createContainer } from 'container';
2+
3+
jest.mock('infra/config', () => ({
4+
loadConfig: jest.fn(() => ({
5+
eventPublisherDlqUrl: 'test-url',
6+
eventPublisherEventBusArn: 'test-arn',
7+
})),
8+
}));
9+
10+
jest.mock('sender-management', () => ({
11+
SenderManagement: jest.fn(() => ({})),
12+
}));
13+
14+
jest.mock('utils', () => ({
15+
EventPublisher: jest.fn(() => ({})),
16+
ParameterStoreCache: jest.fn(() => ({})),
17+
eventBridgeClient: {},
18+
logger: {},
19+
sqsClient: {},
20+
}));
21+
22+
describe('container', () => {
23+
it('should create container', () => {
24+
const container = createContainer();
25+
expect(container).toBeDefined();
26+
});
27+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as indexModule from 'index';
2+
3+
jest.mock('apis/scheduled-event-handler', () => ({
4+
createHandler: jest.fn(() => jest.fn()),
5+
}));
6+
7+
jest.mock('container', () => ({
8+
createContainer: jest.fn(() => ({})),
9+
}));
10+
11+
describe('index', () => {
12+
it('should export handler', () => {
13+
expect(indexModule.handler).toBeDefined();
14+
});
15+
});

0 commit comments

Comments
 (0)