Skip to content

Commit 4d7c97c

Browse files
committed
CCM-11189 Lambda to POST management information
1 parent 790eb11 commit 4d7c97c

File tree

16 files changed

+544
-5
lines changed

16 files changed

+544
-5
lines changed

infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" {
5050
resources = [
5151
module.authorizer_lambda.function_arn,
5252
module.get_letters.function_arn,
53-
module.patch_letter.function_arn
53+
module.patch_letter.function_arn,
54+
module.post_mi.function_arn
5455
]
5556
}
5657
}

infrastructure/terraform/components/api/locals.tf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ locals {
1010
AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn
1111
GET_LETTERS_LAMBDA_ARN = module.get_letters.function_arn
1212
PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn
13+
POST_MI_LAMBDA_ARN = module.post_mi.function_arn
1314
})
1415

1516
destination_arn = "arn:aws:logs:${var.region}:${var.shared_infra_account_id}:destination:nhs-main-obs-firehose-logs"
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
module "patch_letter" {
2+
source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-lambda.zip"
3+
4+
function_name = "post_mi"
5+
description = "Add management information"
6+
7+
aws_account_id = var.aws_account_id
8+
component = var.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.post_mi_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 = "api-handler/dist"
24+
function_include_common = true
25+
handler_function_name = "patchLetter"
26+
runtime = "nodejs22.x"
27+
memory = 128
28+
timeout = 5
29+
log_level = var.log_level
30+
31+
force_lambda_code_deploy = var.force_lambda_code_deploy
32+
enable_lambda_insights = false
33+
34+
send_to_firehose = true
35+
log_destination_arn = local.destination_arn
36+
log_subscription_role_arn = local.acct.log_subscription_role_arn
37+
38+
lambda_env_vars = merge(local.common_lambda_env_vars, {})
39+
}
40+
41+
data "aws_iam_policy_document" "post_mi_lambda" {
42+
statement {
43+
sid = "KMSPermissions"
44+
effect = "Allow"
45+
46+
actions = [
47+
"kms:Decrypt",
48+
"kms:GenerateDataKey",
49+
]
50+
51+
resources = [
52+
module.kms.key_arn, ## Requires shared kms module
53+
]
54+
}
55+
56+
statement {
57+
sid = "AllowDynamoDBAccess"
58+
effect = "Allow"
59+
60+
actions = [
61+
"dynamodb:PutItem",
62+
]
63+
64+
resources = [
65+
aws_dynamodb_table.mi.arn,
66+
]
67+
}
68+
}

infrastructure/terraform/components/api/resources/spec.tmpl.json

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,45 @@
105105
"uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${PATCH_LETTER_LAMBDA_ARN}/invocations"
106106
}
107107
}
108+
},
109+
"/mi": {
110+
"post": {
111+
"description": "Provide management information.",
112+
"operationId": "postMi",
113+
"requestBody": {
114+
"required": true
115+
},
116+
"responses": {
117+
"201": {
118+
"description": "Resource created"
119+
},
120+
"400": {
121+
"description": "Bad request, invalid input data"
122+
},
123+
"500": {
124+
"description": "Server error"
125+
}
126+
},
127+
"security": [
128+
{
129+
"LambdaAuthorizer": []
130+
}
131+
],
132+
"x-amazon-apigateway-integration": {
133+
"contentHandling": "CONVERT_TO_TEXT",
134+
"credentials": "${APIG_EXECUTION_ROLE_ARN}",
135+
"httpMethod": "POST",
136+
"passthroughBehavior": "WHEN_NO_TEMPLATES",
137+
"responses": {
138+
".*": {
139+
"statusCode": "200"
140+
}
141+
},
142+
"timeoutInMillis": 29000,
143+
"type": "AWS_PROXY",
144+
"uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${POST_MI_LAMBDA_ARN}/invocations"
145+
}
146+
}
108147
}
109148
}
110149
}

internal/datastore/src/__test__/mi-repository.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Logger } from "pino";
2-
import { LetterRepository } from "../letter-repository";
32
import { setupDynamoDBContainer, createTables, DBContext, deleteTables } from "./db";
43
import { createTestLogger, LogStream } from "./logs";
54
import { MIRepository } from "../mi-repository";
@@ -46,6 +45,7 @@ describe('MiRepository', () => {
4645
groupId:'group1',
4746
lineItem: 'item1',
4847
quantity: 12,
48+
timestamp: new Date().toISOString(),
4949
stockRemaining: 0
5050
};
5151

internal/datastore/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './types';
2+
export * from './mi-repository';
23
export * from './letter-repository';
34
export * from './types';

internal/datastore/src/mi-repository.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export class MIRepository {
1717
}
1818

1919
async putMI(mi: Omit<MI, 'id' | 'createdAt' | 'updatedAt'>): Promise<MI> {
20+
2021
const now = new Date().toISOString();
2122
const miDb = {
2223
...mi,

internal/datastore/src/types.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,12 @@ export type LetterBase = z.infer<typeof LetterSchemaBase>;
4848

4949
export const MISchemaBase = z.object({
5050
id: z.string(),
51-
specificationId: z.string(),
52-
groupId: z.string(),
5351
lineItem: z.string(),
52+
timestamp: z.string(),
5453
quantity: z.number(),
55-
stockRemaining: z.number()
54+
specificationId: z.string().optional(),
55+
groupId: z.string().optional(),
56+
stockRemaining: z.number().optional()
5657
});
5758

5859
export const MISchema = MISchemaBase.extend({
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import z from "zod";
2+
import { makeDocumentSchema } from "./json-api";
3+
4+
export const PostMIRequestResourceSchema = z.object({
5+
type: z.literal('ManagementInformation'),
6+
attributes: z.object({
7+
lineItem: z.string(),
8+
timestamp: z.string(),
9+
quantity: z.number(),
10+
specificationId: z.string().optional(),
11+
groupId: z.string().optional(),
12+
stockRemaining: z.number().optional(),
13+
}).strict()
14+
}).strict();
15+
16+
export const PostMIResponseResourceSchema = z.object({
17+
type: z.literal('ManagementInformation'),
18+
id: z.string(),
19+
attributes: z.object({
20+
lineItem: z.string(),
21+
timestamp: z.string(),
22+
quantity: z.number(),
23+
specificationId: z.string().optional(),
24+
groupId: z.string().optional(),
25+
stockRemaining: z.number().optional(),
26+
}).strict()
27+
}).strict();
28+
29+
export const PostMIRequestSchema = makeDocumentSchema(PostMIRequestResourceSchema);
30+
export const PostMIResponseSchema = makeDocumentSchema(PostMIResponseResourceSchema);
31+
32+
export type PostMIRequest = z.infer<typeof PostMIRequestSchema>;
33+
export type PostMIResponse = z.infer<typeof PostMIResponseSchema>;
34+
35+
export type IncomingMI = PostMIRequest['data']['attributes'] & {supplierId: string};
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { Context } from "aws-lambda";
2+
import { mockDeep } from "jest-mock-extended";
3+
import { makeApiGwEvent } from "./utils/test-utils";
4+
import { PostMIRequest, PostMIResponse } from "../../contracts/mi";
5+
import * as miService from '../../services/mi-operations';
6+
import { postMi } from "../post-mi";
7+
8+
jest.mock('../../services/mi-operations');
9+
10+
jest.mock('../../config/lambda-config', () => ({
11+
lambdaConfig: {
12+
SUPPLIER_ID_HEADER: 'nhsd-supplier-id',
13+
APIM_CORRELATION_HEADER: 'nhsd-correlation-id'
14+
}
15+
}));
16+
17+
const postMiRequest : PostMIRequest = {
18+
data: {
19+
type: 'ManagementInformation',
20+
attributes: {
21+
lineItem: 'envelope-business-standard',
22+
timestamp: '2023-11-17T14:27:51.413Z',
23+
quantity: 22,
24+
specificationId: 'spec1',
25+
groupId: 'group1',
26+
stockRemaining: 20000
27+
}
28+
}
29+
};
30+
const requestBody = JSON.stringify(postMiRequest, null, 2);
31+
32+
const postMiResponse : PostMIResponse = {
33+
data: {
34+
id: 'id1',
35+
...postMiRequest.data
36+
}
37+
};
38+
39+
const mockedPostMiOperation = jest.mocked(miService.postMI);
40+
41+
beforeEach(() => {
42+
jest.clearAllMocks();
43+
});
44+
45+
46+
describe('postMI API Handler', () => {
47+
it('returns 200 OK with updated resource', async () => {
48+
const event = makeApiGwEvent({
49+
path: '/mi',
50+
body: requestBody,
51+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'}
52+
});
53+
54+
mockedPostMiOperation.mockResolvedValue(postMiResponse);
55+
56+
const result = await postMi(event, mockDeep<Context>(), jest.fn());
57+
58+
expect(result).toEqual({
59+
statusCode: 201,
60+
body: JSON.stringify(postMiResponse, null, 2)
61+
});
62+
});
63+
64+
65+
it.each([['not a date string'], ['2025-10-16T00:00:00'], ['2025-16-10T00:00:00Z']])
66+
('returns 400 Bad Request when the timestamp is not an ISO8601 instant', async (timestamp: string) => {
67+
const invalidRequest = JSON.parse(requestBody);
68+
invalidRequest['data']['attributes']['timestamp'] = timestamp;
69+
const event = makeApiGwEvent({
70+
path: '/mi',
71+
body: JSON.stringify(invalidRequest),
72+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'}
73+
});
74+
75+
const result = await postMi(event, mockDeep<Context>(), jest.fn());
76+
77+
expect(result).toEqual(expect.objectContaining({
78+
statusCode: 400
79+
}));
80+
});
81+
82+
it('returns 400 Bad Request when there is no body', async () => {
83+
const event = makeApiGwEvent({
84+
path: '/letters/id1',
85+
pathParameters: {id: 'id1'},
86+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'}
87+
});
88+
89+
const result = await postMi(event, mockDeep<Context>(), jest.fn());
90+
91+
expect(result).toEqual(expect.objectContaining({
92+
statusCode: 400
93+
}));
94+
});
95+
96+
97+
it('returns 500 Internal Error when error is thrown by service', async () => {
98+
const event = makeApiGwEvent({
99+
path: '/letters/id1',
100+
body: requestBody,
101+
pathParameters: {id: 'id1'},
102+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'}
103+
});
104+
mockedPostMiOperation.mockRejectedValue(new Error());
105+
106+
const result = await postMi(event, mockDeep<Context>(), jest.fn());
107+
108+
expect(result).toEqual(expect.objectContaining({
109+
statusCode: 500
110+
}));
111+
});
112+
113+
it('returns 400 Bad Request when supplier id is missing', async () => {
114+
const event = makeApiGwEvent({
115+
path: '/mi',
116+
body: requestBody,
117+
headers: {'nhsd-correlation-id': 'correlationId'}
118+
});
119+
120+
const result = await postMi(event, mockDeep<Context>(), jest.fn());
121+
122+
expect(result).toEqual(expect.objectContaining({
123+
statusCode: 400
124+
}));
125+
});
126+
127+
it('returns 500 Internal Server Error when correlation id is missing', async () => {
128+
const event = makeApiGwEvent({
129+
path: '/mi',
130+
body: requestBody,
131+
headers: {'nhsd-supplier-id': 'supplier1'}
132+
});
133+
134+
const result = await postMi(event, mockDeep<Context>(), jest.fn());
135+
136+
expect(result).toEqual(expect.objectContaining({
137+
statusCode: 500
138+
}));
139+
});
140+
141+
it('returns 400 Bad Request when request does not have correct shape', async () => {
142+
const event = makeApiGwEvent({
143+
path: '/mi',
144+
body: '{"test": "test"}',
145+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'}
146+
});
147+
148+
const result = await postMi(event, mockDeep<Context>(), jest.fn());
149+
150+
expect(result).toEqual(expect.objectContaining({
151+
statusCode: 400
152+
}));
153+
});
154+
155+
it('returns 400 Bad Request when request body is not json', async () => {
156+
const event = makeApiGwEvent({
157+
path: '/mi',
158+
body: '{#invalidJSON',
159+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'}
160+
});
161+
162+
const result = await postMi(event, mockDeep<Context>(), jest.fn());
163+
164+
expect(result).toEqual(expect.objectContaining({
165+
statusCode: 400
166+
}));
167+
});
168+
169+
it('returns 500 Internal Server Error when parsing fails', async () => {
170+
const event = makeApiGwEvent({
171+
path: '/mi',
172+
body: requestBody,
173+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'}
174+
});
175+
const spy = jest.spyOn(JSON, 'parse').mockImplementation(() => {
176+
throw 'Unexpected error';
177+
})
178+
179+
const result = await postMi(event, mockDeep<Context>(), jest.fn());
180+
181+
expect(result).toEqual(expect.objectContaining({
182+
statusCode: 500
183+
}));
184+
185+
spy.mockRestore();
186+
});
187+
});

0 commit comments

Comments
 (0)