From f427b3d2e05a973ef9b3fcca579527298e8d4060 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Tue, 12 May 2026 08:56:59 +0100 Subject: [PATCH 01/28] init --- .../terraform/modules/backend-api/locals.tf | 2 + .../modules/backend-api/spec.tmpl.json | 170 ++++++++++++++++ .../src/api/create-proofing-request.ts | 184 ++++++++++++++++++ .../src/container/proofing-request.ts | 41 ++++ .../src/create-proofing-request.ts | 4 + lambdas/backend-api/src/infra/config.ts | 2 + .../src/infra/contact-details-repository.ts | 42 +++- .../src/infra/proof-request-repository.ts | 58 ++++++ .../schemas/contact-details/contact-detail.ts | 9 + .../src/schemas/contact-details/index.ts | 2 + lambdas/backend-client/src/schemas/index.ts | 1 + .../src/schemas/proof-request.ts | 14 ++ packages/types/src/index.ts | 8 + packages/types/src/types.gen.ts | 59 ++++++ 14 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 lambdas/backend-api/src/api/create-proofing-request.ts create mode 100644 lambdas/backend-api/src/container/proofing-request.ts create mode 100644 lambdas/backend-api/src/create-proofing-request.ts create mode 100644 lambdas/backend-api/src/infra/proof-request-repository.ts create mode 100644 lambdas/backend-client/src/schemas/contact-details/contact-detail.ts create mode 100644 lambdas/backend-client/src/schemas/proof-request.ts diff --git a/infrastructure/terraform/modules/backend-api/locals.tf b/infrastructure/terraform/modules/backend-api/locals.tf index bcf1e5bd91..74b1636edc 100644 --- a/infrastructure/terraform/modules/backend-api/locals.tf +++ b/infrastructure/terraform/modules/backend-api/locals.tf @@ -19,6 +19,7 @@ locals { AWS_REGION = var.region COUNT_ROUTING_CONFIGS_LAMBDA_ARN = module.count_routing_configs_lambda.function_arn CREATE_CONTACT_DETAILS_LAMBDA_ARN = module.create_contact_details_lambda.function_arn + CREATE_PROOFING_REQUEST_LAMBDA_ARN = module.create_proofing_request_lambda.function_arn CREATE_ROUTING_CONFIG_LAMBDA_ARN = module.create_routing_config_lambda.function_arn CREATE_TEMPLATE_LAMBDA_ARN = module.create_template_lambda.function_arn DELETE_ROUTING_CONFIG_LAMBDA_ARN = module.delete_routing_config_lambda.function_arn @@ -50,6 +51,7 @@ locals { CONTACT_DETAILS_UNVERIFIED_TTL_SECONDS = 60 * 60 DEFAULT_LETTER_SUPPLIER = local.default_letter_supplier_name ENVIRONMENT = var.environment + PROOF_REQUESTS_TABLE_NAME = aws_dynamodb_table.proof_requests.name EVENT_SOURCE = local.event_source EVENT_TOPIC_ARN = var.events_sns_topic_arn LETTER_VARIANT_CACHE_TTL_MS = 300000 diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index ec2ecff27b..85a263e654 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -629,6 +629,31 @@ } ] }, + "CreateProofingRequest": { + "properties": { + "contactDetailId": { + "format": "uuid", + "type": "string" + }, + "personalisation": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "templateId": { + "format": "uuid", + "type": "string" + }, + "testNhsNumber": { + "type": "string" + } + }, + "required": [ + "templateId" + ], + "type": "object" + }, "CreateRoutingConfig": { "properties": { "campaignId": { @@ -1039,6 +1064,87 @@ ], "type": "object" }, + "ProofRequest": { + "properties": { + "contactDetails": { + "oneOf": [ + { + "properties": { + "email": { + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + { + "properties": { + "sms": { + "type": "string" + } + }, + "required": [ + "sms" + ], + "type": "object" + } + ] + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "personalisation": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "templateId": { + "format": "uuid", + "type": "string" + }, + "templateType": { + "enum": [ + "NHS_APP", + "EMAIL", + "SMS" + ], + "type": "string" + }, + "testPatientNhsNumber": { + "type": "string" + } + }, + "required": [ + "id", + "templateId", + "templateType", + "createdAt" + ], + "type": "object" + }, + "ProofRequestSuccess": { + "properties": { + "data": { + "$ref": "#/components/schemas/ProofRequest" + }, + "statusCode": { + "type": "integer" + } + }, + "required": [ + "data", + "statusCode" + ], + "type": "object" + }, "RenderDetails": { "allOf": [ { @@ -1836,6 +1942,70 @@ } } }, + "/v1/proofing-request": { + "post": { + "description": "Create a proofing request for a template", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProofingRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProofRequestSuccess" + } + } + }, + "description": "201 response", + "headers": { + "Content-Type": { + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Failure" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authorizer": [] + } + ], + "summary": "Create a proofing request for a template", + "x-amazon-apigateway-integration": { + "contentHandling": "CONVERT_TO_TEXT", + "credentials": "${APIG_EXECUTION_ROLE_ARN}", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "responses": { + ".*": { + "statusCode": "200" + } + }, + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${CREATE_PROOFING_REQUEST_LAMBDA_ARN}/invocations" + } + } + }, "/v1/routing-configuration": { "post": { "description": "Create a routing configuration", diff --git a/lambdas/backend-api/src/api/create-proofing-request.ts b/lambdas/backend-api/src/api/create-proofing-request.ts new file mode 100644 index 0000000000..ff949e81ff --- /dev/null +++ b/lambdas/backend-api/src/api/create-proofing-request.ts @@ -0,0 +1,184 @@ +import type { APIGatewayProxyHandler, APIGatewayProxyResult } from 'aws-lambda'; +import type { + CreateProofingRequest, + ProofRequest, +} from 'nhs-notify-web-template-management-types'; +import type { User } from 'nhs-notify-web-template-management-utils'; +import { apiFailure } from '@backend-api/api/responses'; +import type { ContactDetailsRepository } from '@backend-api/infra/contact-details-repository'; +import type { ProofRequestRepository } from '@backend-api/infra/proof-request-repository'; +import type { TemplateRepository } from '@backend-api/infra/template-repository'; +import type { ApplicationResult } from '@backend-api/utils'; + +type Dependencies = { + templateRepository: TemplateRepository; + contactDetailsRepository: ContactDetailsRepository; + proofRequestRepository: ProofRequestRepository; +}; + +function proofRequestSuccess(data: ProofRequest): APIGatewayProxyResult { + return { + statusCode: 201, + body: JSON.stringify({ statusCode: 201, data }), + }; +} + +function resultToResponse( + result: ApplicationResult +): APIGatewayProxyResult { + if (result.error) { + return apiFailure( + result.error.errorMeta.code, + result.error.errorMeta.description + ); + } + + return proofRequestSuccess(result.data); +} + +async function handleNhsApp( + body: Partial, + templateId: string, + proofRequestRepository: ProofRequestRepository, + user: User +): Promise { + if (!body.testNhsNumber) { + return apiFailure(400, 'testNhsNumber is required for NHS_APP templates'); + } + + if (body.contactDetailId) { + return apiFailure( + 400, + 'contactDetailId is not accepted for NHS_APP templates' + ); + } + + const result = await proofRequestRepository.put( + { + templateId, + templateType: 'NHS_APP', + testPatientNhsNumber: body.testNhsNumber, + personalisation: body.personalisation, + }, + user + ); + + return resultToResponse(result); +} + +async function handleEmailOrSms( + body: Partial, + templateId: string, + templateType: 'EMAIL' | 'SMS', + contactDetailsRepository: ContactDetailsRepository, + proofRequestRepository: ProofRequestRepository, + user: User +): Promise { + if (!body.contactDetailId) { + return apiFailure( + 400, + `contactDetailId is required for ${templateType} templates` + ); + } + + if (body.testNhsNumber) { + return apiFailure( + 400, + `testNhsNumber is not accepted for ${templateType} templates` + ); + } + + const contactDetailResult = await contactDetailsRepository.getById( + body.contactDetailId, + user + ); + + if (contactDetailResult.error) { + return apiFailure( + contactDetailResult.error.errorMeta.code, + contactDetailResult.error.errorMeta.description + ); + } + + if (contactDetailResult.data.status !== 'VERIFIED') { + return apiFailure(400, 'Contact detail is not verified'); + } + + const contactDetails = + templateType === 'EMAIL' + ? { email: contactDetailResult.data.value } + : { sms: contactDetailResult.data.value }; + + const result = await proofRequestRepository.put( + { + templateId, + templateType, + contactDetails, + personalisation: body.personalisation, + }, + user + ); + + return resultToResponse(result); +} + +export function createHandler({ + templateRepository, + contactDetailsRepository, + proofRequestRepository, +}: Dependencies): APIGatewayProxyHandler { + return async function (event) { + const { internalUserId, clientId } = event.requestContext.authorizer ?? {}; + + if (!internalUserId || !clientId) { + return apiFailure(400, 'Invalid request'); + } + + const user = { internalUserId, clientId }; + + let body: Partial; + + try { + body = JSON.parse(event.body ?? '{}'); + } catch { + return apiFailure(400, 'Invalid JSON body'); + } + + const { templateId } = body; + + if (!templateId) { + return apiFailure(400, 'templateId is required'); + } + + const templateResult = await templateRepository.get(templateId, clientId); + + if (templateResult.error) { + return apiFailure( + templateResult.error.errorMeta.code, + templateResult.error.errorMeta.description + ); + } + + const { templateType } = templateResult.data; + + if (templateType === 'LETTER') { + return apiFailure( + 400, + 'Proofing requests are not supported for LETTER templates' + ); + } + + if (templateType === 'NHS_APP') { + return handleNhsApp(body, templateId, proofRequestRepository, user); + } + + return handleEmailOrSms( + body, + templateId, + templateType, + contactDetailsRepository, + proofRequestRepository, + user + ); + }; +} diff --git a/lambdas/backend-api/src/container/proofing-request.ts b/lambdas/backend-api/src/container/proofing-request.ts new file mode 100644 index 0000000000..9f51b4f2ff --- /dev/null +++ b/lambdas/backend-api/src/container/proofing-request.ts @@ -0,0 +1,41 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { SSMClient } from '@aws-sdk/client-ssm'; +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { loadConfig } from '@backend-api/infra/config'; +import { ContactDetailsRepository } from '@backend-api/infra/contact-details-repository'; +import { ProofRequestRepository } from '@backend-api/infra/proof-request-repository'; +import { TemplateRepository } from '@backend-api/infra/template-repository'; + +export function proofingRequestContainer() { + const config = loadConfig(); + + const ddbDocClient = DynamoDBDocumentClient.from(new DynamoDBClient(), { + marshallOptions: { removeUndefinedValues: true }, + }); + + const ssm = new SSMClient(); + + const templateRepository = new TemplateRepository( + ddbDocClient, + config.templatesTableName + ); + + const contactDetailsRepository = new ContactDetailsRepository( + ddbDocClient, + ssm, + config.contactDetailsTableName, + config.contactDetailsUnverifiedTtlSeconds, + config.contactDetailsOtpSecretPath + ); + + const proofRequestRepository = new ProofRequestRepository( + ddbDocClient, + config.proofRequestsTableName + ); + + return { + templateRepository, + contactDetailsRepository, + proofRequestRepository, + }; +} diff --git a/lambdas/backend-api/src/create-proofing-request.ts b/lambdas/backend-api/src/create-proofing-request.ts new file mode 100644 index 0000000000..06c9b8381a --- /dev/null +++ b/lambdas/backend-api/src/create-proofing-request.ts @@ -0,0 +1,4 @@ +import { createHandler } from '@backend-api/api/create-proofing-request'; +import { proofingRequestContainer } from '@backend-api/container/proofing-request'; + +export const handler = createHandler(proofingRequestContainer()); diff --git a/lambdas/backend-api/src/infra/config.ts b/lambdas/backend-api/src/infra/config.ts index 2dbc390cec..b18bbff24b 100644 --- a/lambdas/backend-api/src/infra/config.ts +++ b/lambdas/backend-api/src/infra/config.ts @@ -8,6 +8,7 @@ const $Env = z.object({ CONTACT_DETAILS_UNVERIFIED_TTL_SECONDS: z.string().pipe(z.coerce.number()), DEFAULT_LETTER_SUPPLIER: z.string(), ENVIRONMENT: z.string(), + PROOF_REQUESTS_TABLE_NAME: z.string(), EVENT_SOURCE: z.string(), EVENT_TOPIC_ARN: z.string(), LETTER_VARIANT_CACHE_TTL_MS: z.string().pipe(z.coerce.number()), @@ -41,6 +42,7 @@ export function loadConfig() { defaultLetterSupplier: env.DEFAULT_LETTER_SUPPLIER, downloadBucket: env.TEMPLATES_DOWNLOAD_BUCKET_NAME, environment: env.ENVIRONMENT, + proofRequestsTableName: env.PROOF_REQUESTS_TABLE_NAME, eventSource: env.EVENT_SOURCE, eventTopicArn: env.EVENT_TOPIC_ARN, internalBucket: env.TEMPLATES_INTERNAL_BUCKET_NAME, diff --git a/lambdas/backend-api/src/infra/contact-details-repository.ts b/lambdas/backend-api/src/infra/contact-details-repository.ts index 2033f65ab7..cae7952fec 100644 --- a/lambdas/backend-api/src/infra/contact-details-repository.ts +++ b/lambdas/backend-api/src/infra/contact-details-repository.ts @@ -1,7 +1,12 @@ import { randomUUID } from 'node:crypto'; import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; import { GetParameterCommand, type SSMClient } from '@aws-sdk/client-ssm'; -import { PutCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { + PutCommand, + QueryCommand, + type DynamoDBDocumentClient, +} from '@aws-sdk/lib-dynamodb'; +import { $ContactDetail } from 'nhs-notify-backend-client/schemas'; import { ErrorCase } from 'nhs-notify-backend-client/types'; import type { ContactDetail, @@ -84,6 +89,41 @@ export class ContactDetailsRepository { } } + async getById( + id: string, + user: User + ): Promise> { + try { + const response = await this.dynamodb.send( + new QueryCommand({ + TableName: this.tableName, + IndexName: 'ById', + KeyConditionExpression: '#id = :id AND #owner = :owner', + ExpressionAttributeNames: { + '#id': 'id', + '#owner': 'owner', + }, + ExpressionAttributeValues: { + ':id': id, + ':owner': `INTERNAL_USER#${user.internalUserId}`, + }, + }) + ); + + const item = response.Items?.[0]; + + if (!item) { + return failure(ErrorCase.NOT_FOUND, 'Contact detail not found'); + } + + const parsed = $ContactDetail.parse(item); + + return success(parsed); + } catch (error) { + return failure(ErrorCase.INTERNAL, 'Failed to get contact detail', error); + } + } + private getContactDetailKey(detail: ContactDetailInputNormalized) { return `${detail.type}#${detail.value}`; } diff --git a/lambdas/backend-api/src/infra/proof-request-repository.ts b/lambdas/backend-api/src/infra/proof-request-repository.ts new file mode 100644 index 0000000000..d7766ad69d --- /dev/null +++ b/lambdas/backend-api/src/infra/proof-request-repository.ts @@ -0,0 +1,58 @@ +import { randomUUID } from 'node:crypto'; +import { PutCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { $ProofRequest } from 'nhs-notify-backend-client/schemas'; +import { ErrorCase } from 'nhs-notify-backend-client/types'; +import type { ProofRequest } from 'nhs-notify-web-template-management-types'; +import type { User } from 'nhs-notify-web-template-management-utils'; +import { failure, success, type ApplicationResult } from '@backend-api/utils'; + +export class ProofRequestRepository { + constructor( + private dynamodb: DynamoDBDocumentClient, + private tableName: string + ) {} + + async put( + params: { + templateId: string; + templateType: 'NHS_APP' | 'EMAIL' | 'SMS'; + testPatientNhsNumber?: string; + contactDetails?: { email: string } | { sms: string }; + personalisation?: Record; + }, + user: User + ): Promise> { + const now = new Date().toISOString(); + + const record = $ProofRequest.parse({ + id: randomUUID(), + templateId: params.templateId, + templateType: params.templateType, + testPatientNhsNumber: params.testPatientNhsNumber, + contactDetails: params.contactDetails, + personalisation: params.personalisation, + createdAt: now, + }); + + try { + await this.dynamodb.send( + new PutCommand({ + TableName: this.tableName, + Item: { + ...record, + owner: `INTERNAL_USER#${user.internalUserId}`, + createdBy: `INTERNAL_USER#${user.internalUserId}`, + }, + }) + ); + + return success(record); + } catch (error) { + return failure( + ErrorCase.INTERNAL, + 'Failed to create proof request', + error + ); + } + } +} diff --git a/lambdas/backend-client/src/schemas/contact-details/contact-detail.ts b/lambdas/backend-client/src/schemas/contact-details/contact-detail.ts new file mode 100644 index 0000000000..eb403382a2 --- /dev/null +++ b/lambdas/backend-client/src/schemas/contact-details/contact-detail.ts @@ -0,0 +1,9 @@ +import { z } from 'zod/v4'; +import type { ContactDetail } from 'nhs-notify-web-template-management-types'; + +export const $ContactDetail: z.ZodType = z.object({ + id: z.string(), + status: z.enum(['PENDING_VERIFICATION', 'VERIFIED']), + type: z.enum(['EMAIL', 'SMS']), + value: z.string(), +}); diff --git a/lambdas/backend-client/src/schemas/contact-details/index.ts b/lambdas/backend-client/src/schemas/contact-details/index.ts index c6bbbc4646..dd0fe0819a 100644 --- a/lambdas/backend-client/src/schemas/contact-details/index.ts +++ b/lambdas/backend-client/src/schemas/contact-details/index.ts @@ -64,3 +64,5 @@ export const $ContactDetailInputNormalized: z.ZodType = z.object({ + id: z.string(), + templateId: z.string(), + templateType: z.enum(['NHS_APP', 'EMAIL', 'SMS']), + testPatientNhsNumber: z.string().optional(), + contactDetails: z + .union([z.object({ email: z.string() }), z.object({ sms: z.string() })]) + .optional(), + personalisation: z.record(z.string(), z.string()).optional(), + createdAt: z.string(), +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c41fba7abc..35183cee47 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -31,6 +31,7 @@ export type { CountSuccess, CreateAuthoringLetterProperties, CreatePdfLetterProperties, + CreateProofingRequest, CreateRoutingConfig, CreateUpdateTemplate, DeleteV1RoutingConfigurationByRoutingConfigIdData, @@ -144,6 +145,11 @@ export type { PostV1LetterTemplateErrors, PostV1LetterTemplateResponse, PostV1LetterTemplateResponses, + PostV1ProofingRequestData, + PostV1ProofingRequestError, + PostV1ProofingRequestErrors, + PostV1ProofingRequestResponse, + PostV1ProofingRequestResponses, PostV1RoutingConfigurationData, PostV1RoutingConfigurationError, PostV1RoutingConfigurationErrors, @@ -165,6 +171,8 @@ export type { PostV1TemplateResponse, PostV1TemplateResponses, ProofFileDetails, + ProofRequest, + ProofRequestSuccess, PutV1TemplateByTemplateIdData, PutV1TemplateByTemplateIdError, PutV1TemplateByTemplateIdErrors, diff --git a/packages/types/src/types.gen.ts b/packages/types/src/types.gen.ts index f9fde1dd5f..2367096a60 100644 --- a/packages/types/src/types.gen.ts +++ b/packages/types/src/types.gen.ts @@ -310,6 +310,38 @@ export type PdfLetterProperties = BaseLetterTemplateProperties & { export type PersonalisedRenderRequestVariant = 'long' | 'short'; +export type CreateProofingRequest = { + templateId: string; + testNhsNumber?: string; + contactDetailId?: string; + personalisation?: { + [key: string]: string; + }; +}; + +export type ProofRequest = { + id: string; + templateId: string; + templateType: 'NHS_APP' | 'EMAIL' | 'SMS'; + testPatientNhsNumber?: string; + contactDetails?: + | { + email: string; + } + | { + sms: string; + }; + personalisation?: { + [key: string]: string; + }; + createdAt: string; +}; + +export type ProofRequestSuccess = { + data: ProofRequest; + statusCode: number; +}; + export type ProofFileDetails = { fileName: string; supplier: string; @@ -492,6 +524,33 @@ export type PostV1ContactDetailsResponses = { export type PostV1ContactDetailsResponse = PostV1ContactDetailsResponses[keyof PostV1ContactDetailsResponses]; +export type PostV1ProofingRequestData = { + body: CreateProofingRequest; + path?: never; + query?: never; + url: '/v1/proofing-request'; +}; + +export type PostV1ProofingRequestErrors = { + /** + * Error + */ + default: Failure; +}; + +export type PostV1ProofingRequestError = + PostV1ProofingRequestErrors[keyof PostV1ProofingRequestErrors]; + +export type PostV1ProofingRequestResponses = { + /** + * 201 response + */ + 201: ProofRequestSuccess; +}; + +export type PostV1ProofingRequestResponse = + PostV1ProofingRequestResponses[keyof PostV1ProofingRequestResponses]; + export type PostV1DocxLetterTemplateData = { /** * Letter template to create From 465e7ba372203ff4e2f31870d8eb130917ce2fc8 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Tue, 12 May 2026 10:56:46 +0100 Subject: [PATCH 02/28] tf --- .../terraform/modules/backend-api/README.md | 1 + .../module_create_proofing_request_lambda.tf | 104 ++++++++++ .../modules/backend-api/spec.tmpl.json | 178 ++++++++---------- lambdas/backend-api/build.sh | 1 + .../src/api/create-proofing-request.ts | 111 ++++++----- .../src/container/proofing-request.ts | 9 + .../src/infra/proof-request-repository.ts | 14 +- .../src/schemas/proof-request.ts | 5 +- .../__tests__/domain/event-builder.test.ts | 20 +- .../src/domain/event-builder.ts | 35 +++- .../src/domain/input-schemas.ts | 9 +- packages/types/src/index.ts | 10 +- packages/types/src/types.gen.ts | 93 +++++---- .../create-proofing-request.api.spec.ts | 78 ++++++++ 14 files changed, 445 insertions(+), 223 deletions(-) create mode 100644 infrastructure/terraform/modules/backend-api/module_create_proofing_request_lambda.tf create mode 100644 tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts diff --git a/infrastructure/terraform/modules/backend-api/README.md b/infrastructure/terraform/modules/backend-api/README.md index 96c04f038d..3b1abf60c9 100644 --- a/infrastructure/terraform/modules/backend-api/README.md +++ b/infrastructure/terraform/modules/backend-api/README.md @@ -47,6 +47,7 @@ No requirements. | [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [count\_routing\_configs\_lambda](#module\_count\_routing\_configs\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [create\_contact\_details\_lambda](#module\_create\_contact\_details\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.8/terraform-lambda.zip | n/a | +| [create\_proofing\_request\_lambda](#module\_create\_proofing\_request\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.8/terraform-lambda.zip | n/a | | [create\_routing\_config\_lambda](#module\_create\_routing\_config\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [create\_template\_lambda](#module\_create\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [delete\_routing\_config\_lambda](#module\_delete\_routing\_config\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | diff --git a/infrastructure/terraform/modules/backend-api/module_create_proofing_request_lambda.tf b/infrastructure/terraform/modules/backend-api/module_create_proofing_request_lambda.tf new file mode 100644 index 0000000000..489833ca7f --- /dev/null +++ b/infrastructure/terraform/modules/backend-api/module_create_proofing_request_lambda.tf @@ -0,0 +1,104 @@ +module "create_proofing_request_lambda" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.8/terraform-lambda.zip" + + project = var.project + environment = var.environment + component = var.component + aws_account_id = var.aws_account_id + region = var.region + + kms_key_arn = var.kms_key_arn + + function_name = "create-proofing-request" + + function_module_name = "create-proofing-request" + handler_function_name = "handler" + description = "API endpoint for creating a digital proofing request" + + memory = 2048 + timeout = 20 + runtime = "nodejs22.x" + + log_retention_in_days = var.log_retention_in_days + iam_policy_document = { + body = data.aws_iam_policy_document.create_proofing_request.json + } + + lambda_env_vars = local.backend_lambda_environment_variables + function_s3_bucket = var.function_s3_bucket + function_code_base_path = local.lambdas_dir + function_code_dir = "backend-api/dist/create-proofing-request" + + send_to_firehose = var.send_to_firehose + log_destination_arn = var.log_destination_arn + log_subscription_role_arn = var.log_subscription_role_arn +} + +data "aws_iam_policy_document" "create_proofing_request" { + statement { + sid = "AllowKMSAccess" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:DescribeKey", + "kms:Encrypt", + "kms:GenerateDataKey*", + "kms:ReEncrypt*", + ] + + resources = [ + var.kms_key_arn + ] + } + + statement { + sid = "AllowTemplatesRead" + effect = "Allow" + + actions = [ + "dynamodb:GetItem", + ] + + resources = [ + aws_dynamodb_table.templates.arn, + ] + } + + statement { + sid = "AllowProofRequestsWrite" + effect = "Allow" + + actions = [ + "dynamodb:PutItem", + ] + + resources = [ + aws_dynamodb_table.proof_requests.arn, + ] + } + + statement { + sid = "AllowContactDetailsQuery" + effect = "Allow" + + actions = [ + "dynamodb:Query", + ] + + resources = [ + "${aws_dynamodb_table.contact_details.arn}/index/ById", + ] + } + + statement { + sid = "AllowSSMParameterRead" + effect = "Allow" + + actions = [ + "ssm:GetParameter", + ] + + resources = [local.client_ssm_path_pattern] + } +} diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index 85a263e654..431f7f8c33 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -641,16 +641,12 @@ }, "type": "object" }, - "templateId": { - "format": "uuid", - "type": "string" - }, - "testNhsNumber": { + "testPatientNhsNumber": { "type": "string" } }, "required": [ - "templateId" + "testPatientNhsNumber" ], "type": "object" }, @@ -1066,31 +1062,8 @@ }, "ProofRequest": { "properties": { - "contactDetails": { - "oneOf": [ - { - "properties": { - "email": { - "type": "string" - } - }, - "required": [ - "email" - ], - "type": "object" - }, - { - "properties": { - "sms": { - "type": "string" - } - }, - "required": [ - "sms" - ], - "type": "object" - } - ] + "contactDetailValue": { + "type": "string" }, "createdAt": { "format": "date-time", @@ -1117,15 +1090,13 @@ "SMS" ], "type": "string" - }, - "testPatientNhsNumber": { - "type": "string" } }, "required": [ "id", "templateId", "templateType", + "contactDetailValue", "createdAt" ], "type": "object" @@ -1942,70 +1913,6 @@ } } }, - "/v1/proofing-request": { - "post": { - "description": "Create a proofing request for a template", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateProofingRequest" - } - } - }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProofRequestSuccess" - } - } - }, - "description": "201 response", - "headers": { - "Content-Type": { - "schema": { - "type": "string" - } - } - } - }, - "default": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Failure" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authorizer": [] - } - ], - "summary": "Create a proofing request for a template", - "x-amazon-apigateway-integration": { - "contentHandling": "CONVERT_TO_TEXT", - "credentials": "${APIG_EXECUTION_ROLE_ARN}", - "httpMethod": "POST", - "passthroughBehavior": "WHEN_NO_TEMPLATES", - "responses": { - ".*": { - "statusCode": "200" - } - }, - "timeoutInMillis": 29000, - "type": "AWS_PROXY", - "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${CREATE_PROOFING_REQUEST_LAMBDA_ARN}/invocations" - } - } - }, "/v1/routing-configuration": { "post": { "description": "Create a routing configuration", @@ -3131,6 +3038,81 @@ } } }, + "/v1/template/{templateId}/proofing-request": { + "post": { + "description": "Create a proofing request for a template", + "parameters": [ + { + "description": "ID of the template to create a proofing request for", + "in": "path", + "name": "templateId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProofingRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProofRequestSuccess" + } + } + }, + "description": "201 response", + "headers": { + "Content-Type": { + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Failure" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authorizer": [] + } + ], + "summary": "Create a proofing request for a template", + "x-amazon-apigateway-integration": { + "contentHandling": "CONVERT_TO_TEXT", + "credentials": "${APIG_EXECUTION_ROLE_ARN}", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "responses": { + ".*": { + "statusCode": "200" + } + }, + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${CREATE_PROOFING_REQUEST_LAMBDA_ARN}/invocations" + } + } + }, "/v1/template/{templateId}/routing-configurations": { "get": { "description": "Get all routing configurations that reference a specific template", diff --git a/lambdas/backend-api/build.sh b/lambdas/backend-api/build.sh index 0caf4394aa..b709333df1 100755 --- a/lambdas/backend-api/build.sh +++ b/lambdas/backend-api/build.sh @@ -18,6 +18,7 @@ npx esbuild \ src/copy-scanned-object-to-internal.ts \ src/count-routing-configs.ts \ src/create-contact-details.ts \ + src/create-proofing-request.ts \ src/create-routing-config.ts \ src/create.ts \ src/delete-failed-scanned-object.ts \ diff --git a/lambdas/backend-api/src/api/create-proofing-request.ts b/lambdas/backend-api/src/api/create-proofing-request.ts index ff949e81ff..2f2e11f9db 100644 --- a/lambdas/backend-api/src/api/create-proofing-request.ts +++ b/lambdas/backend-api/src/api/create-proofing-request.ts @@ -1,10 +1,13 @@ import type { APIGatewayProxyHandler, APIGatewayProxyResult } from 'aws-lambda'; import type { + ClientFeatures, CreateProofingRequest, ProofRequest, + TemplateType, } from 'nhs-notify-web-template-management-types'; import type { User } from 'nhs-notify-web-template-management-utils'; import { apiFailure } from '@backend-api/api/responses'; +import type { ClientConfigRepository } from '@backend-api/infra/client-config-repository'; import type { ContactDetailsRepository } from '@backend-api/infra/contact-details-repository'; import type { ProofRequestRepository } from '@backend-api/infra/proof-request-repository'; import type { TemplateRepository } from '@backend-api/infra/template-repository'; @@ -14,15 +17,9 @@ type Dependencies = { templateRepository: TemplateRepository; contactDetailsRepository: ContactDetailsRepository; proofRequestRepository: ProofRequestRepository; + clientConfigRepository: ClientConfigRepository; }; -function proofRequestSuccess(data: ProofRequest): APIGatewayProxyResult { - return { - statusCode: 201, - body: JSON.stringify({ statusCode: 201, data }), - }; -} - function resultToResponse( result: ApplicationResult ): APIGatewayProxyResult { @@ -33,19 +30,18 @@ function resultToResponse( ); } - return proofRequestSuccess(result.data); + return { + statusCode: 201, + body: JSON.stringify({ statusCode: 201, data: result.data }), + }; } async function handleNhsApp( - body: Partial, + body: CreateProofingRequest, templateId: string, proofRequestRepository: ProofRequestRepository, user: User ): Promise { - if (!body.testNhsNumber) { - return apiFailure(400, 'testNhsNumber is required for NHS_APP templates'); - } - if (body.contactDetailId) { return apiFailure( 400, @@ -57,7 +53,7 @@ async function handleNhsApp( { templateId, templateType: 'NHS_APP', - testPatientNhsNumber: body.testNhsNumber, + contactDetailValue: body.testPatientNhsNumber, personalisation: body.personalisation, }, user @@ -67,7 +63,7 @@ async function handleNhsApp( } async function handleEmailOrSms( - body: Partial, + body: CreateProofingRequest, templateId: string, templateType: 'EMAIL' | 'SMS', contactDetailsRepository: ContactDetailsRepository, @@ -81,13 +77,6 @@ async function handleEmailOrSms( ); } - if (body.testNhsNumber) { - return apiFailure( - 400, - `testNhsNumber is not accepted for ${templateType} templates` - ); - } - const contactDetailResult = await contactDetailsRepository.getById( body.contactDetailId, user @@ -104,16 +93,11 @@ async function handleEmailOrSms( return apiFailure(400, 'Contact detail is not verified'); } - const contactDetails = - templateType === 'EMAIL' - ? { email: contactDetailResult.data.value } - : { sms: contactDetailResult.data.value }; - const result = await proofRequestRepository.put( { templateId, templateType, - contactDetails, + contactDetailValue: contactDetailResult.data.value, personalisation: body.personalisation, }, user @@ -122,10 +106,20 @@ async function handleEmailOrSms( return resultToResponse(result); } +const digitalProofingFeatureFlag: Record< + Exclude, + keyof ClientFeatures +> = { + NHS_APP: 'digitalProofingNhsApp', + EMAIL: 'digitalProofingEmail', + SMS: 'digitalProofingSms', +}; + export function createHandler({ templateRepository, contactDetailsRepository, proofRequestRepository, + clientConfigRepository, }: Dependencies): APIGatewayProxyHandler { return async function (event) { const { internalUserId, clientId } = event.requestContext.authorizer ?? {}; @@ -136,6 +130,12 @@ export function createHandler({ const user = { internalUserId, clientId }; + const templateId = event.pathParameters?.templateId; + + if (!templateId) { + return apiFailure(400, 'templateId is required'); + } + let body: Partial; try { @@ -144,12 +144,12 @@ export function createHandler({ return apiFailure(400, 'Invalid JSON body'); } - const { templateId } = body; - - if (!templateId) { - return apiFailure(400, 'templateId is required'); + if (!body.testPatientNhsNumber) { + return apiFailure(400, 'testPatientNhsNumber is required'); } + const validatedBody = body as CreateProofingRequest; + const templateResult = await templateRepository.get(templateId, clientId); if (templateResult.error) { @@ -168,17 +168,44 @@ export function createHandler({ ); } - if (templateType === 'NHS_APP') { - return handleNhsApp(body, templateId, proofRequestRepository, user); + const clientConfigResult = await clientConfigRepository.get(clientId); + + if (clientConfigResult.error) { + return apiFailure( + clientConfigResult.error.errorMeta.code, + clientConfigResult.error.errorMeta.description + ); } - return handleEmailOrSms( - body, - templateId, - templateType, - contactDetailsRepository, - proofRequestRepository, - user - ); + const featureFlag = digitalProofingFeatureFlag[templateType]; + + if (!clientConfigResult.data?.features[featureFlag]) { + return apiFailure( + 403, + `Digital proofing is not enabled for ${templateType} templates` + ); + } + + switch (templateType) { + case 'NHS_APP': { + return handleNhsApp( + validatedBody, + templateId, + proofRequestRepository, + user + ); + } + case 'EMAIL': + case 'SMS': { + return handleEmailOrSms( + validatedBody, + templateId, + templateType, + contactDetailsRepository, + proofRequestRepository, + user + ); + } + } }; } diff --git a/lambdas/backend-api/src/container/proofing-request.ts b/lambdas/backend-api/src/container/proofing-request.ts index 9f51b4f2ff..bdc2fb7158 100644 --- a/lambdas/backend-api/src/container/proofing-request.ts +++ b/lambdas/backend-api/src/container/proofing-request.ts @@ -1,7 +1,9 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { SSMClient } from '@aws-sdk/client-ssm'; import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import NodeCache from 'node-cache'; import { loadConfig } from '@backend-api/infra/config'; +import { ClientConfigRepository } from '@backend-api/infra/client-config-repository'; import { ContactDetailsRepository } from '@backend-api/infra/contact-details-repository'; import { ProofRequestRepository } from '@backend-api/infra/proof-request-repository'; import { TemplateRepository } from '@backend-api/infra/template-repository'; @@ -33,9 +35,16 @@ export function proofingRequestContainer() { config.proofRequestsTableName ); + const clientConfigRepository = new ClientConfigRepository( + config.clientConfigSsmKeyPrefix, + ssm, + new NodeCache({ stdTTL: config.clientConfigTtlSeconds }) + ); + return { templateRepository, contactDetailsRepository, proofRequestRepository, + clientConfigRepository, }; } diff --git a/lambdas/backend-api/src/infra/proof-request-repository.ts b/lambdas/backend-api/src/infra/proof-request-repository.ts index d7766ad69d..c7428c19c5 100644 --- a/lambdas/backend-api/src/infra/proof-request-repository.ts +++ b/lambdas/backend-api/src/infra/proof-request-repository.ts @@ -13,24 +13,14 @@ export class ProofRequestRepository { ) {} async put( - params: { - templateId: string; - templateType: 'NHS_APP' | 'EMAIL' | 'SMS'; - testPatientNhsNumber?: string; - contactDetails?: { email: string } | { sms: string }; - personalisation?: Record; - }, + params: Omit, user: User ): Promise> { const now = new Date().toISOString(); const record = $ProofRequest.parse({ + ...params, id: randomUUID(), - templateId: params.templateId, - templateType: params.templateType, - testPatientNhsNumber: params.testPatientNhsNumber, - contactDetails: params.contactDetails, - personalisation: params.personalisation, createdAt: now, }); diff --git a/lambdas/backend-client/src/schemas/proof-request.ts b/lambdas/backend-client/src/schemas/proof-request.ts index a45f42c6ad..d6ed979c66 100644 --- a/lambdas/backend-client/src/schemas/proof-request.ts +++ b/lambdas/backend-client/src/schemas/proof-request.ts @@ -5,10 +5,7 @@ export const $ProofRequest: z.ZodType = z.object({ id: z.string(), templateId: z.string(), templateType: z.enum(['NHS_APP', 'EMAIL', 'SMS']), - testPatientNhsNumber: z.string().optional(), - contactDetails: z - .union([z.object({ email: z.string() }), z.object({ sms: z.string() })]) - .optional(), + contactDetailValue: z.string(), personalisation: z.record(z.string(), z.string()).optional(), createdAt: z.string(), }); diff --git a/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts b/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts index 0b1e60e863..d635202575 100644 --- a/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts +++ b/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts @@ -333,12 +333,8 @@ const publishableProofRequestEventRecord = (): PublishableEventRecord => ({ testPatientNhsNumber: { S: '9000000009', }, - contactDetails: { - M: { - sms: { - S: '07700900000', - }, - }, + contactDetailValue: { + S: '07700900000', }, personalisation: { M: { @@ -703,17 +699,17 @@ describe('proof request events', () => { expect(event).toEqual(expectedProofRequestedEvent()); }); - test('errors on output schema validation failure after input parsing', () => { + test('errors on input schema validation failure when contactDetailValue is missing', () => { const valid = publishableProofRequestEventRecord(); + const { contactDetailValue: _, ...newImageWithoutContact } = + valid.dynamodb.NewImage!; + const invalidDomainEventRecord: PublishableEventRecord = { ...valid, dynamodb: { ...valid.dynamodb, - NewImage: { - ...valid.dynamodb.NewImage, - templateType: { S: 'EMAIL' }, - }, + NewImage: newImageWithoutContact, }, }; @@ -723,7 +719,7 @@ describe('proof request events', () => { issues: [ expect.objectContaining({ code: 'invalid_type', - path: ['data', 'contactDetails', 'email'], + path: ['contactDetailValue'], }), ], }) diff --git a/lambdas/event-publisher/src/domain/event-builder.ts b/lambdas/event-publisher/src/domain/event-builder.ts index dce9e90145..b0a0acefac 100644 --- a/lambdas/event-publisher/src/domain/event-builder.ts +++ b/lambdas/event-publisher/src/domain/event-builder.ts @@ -7,6 +7,7 @@ import { $DynamoDBRoutingConfig, $DynamoDBTemplate, $DynamoDBTemplateOldImage, + DynamoDBProofRequest, DynamoDBTemplate, PublishableEventRecord, } from './input-schemas'; @@ -254,6 +255,36 @@ export class EventBuilder extends NHSNotifyEventBuilder { return { event: event.data }; } + private mapProofRequestToEventData( + record: DynamoDBProofRequest + ): Record { + const base = { + id: record.id, + templateId: record.templateId, + templateType: record.templateType, + testPatientNhsNumber: record.testPatientNhsNumber, + ...(record.personalisation && { + personalisation: record.personalisation, + }), + }; + + switch (record.templateType) { + case 'SMS': + return { ...base, contactDetails: { sms: record.contactDetailValue } }; + case 'EMAIL': + return { + ...base, + contactDetails: { email: record.contactDetailValue }, + }; + case 'NHS_APP': + return base; + default: + throw new Error( + `Unsupported templateType for proof request: ${record.templateType}` + ); + } + } + private buildProofRequestedEvent( publishableEventRecord: PublishableEventRecord ): EventBuilderOutput { @@ -271,6 +302,8 @@ export class EventBuilder extends NHSNotifyEventBuilder { const databaseProofRequest = $DynamoDBProofRequest.parse(dynamoRecord); + const eventData = this.mapProofRequestToEventData(databaseProofRequest); + try { return { event: $Event.parse({ @@ -280,7 +313,7 @@ export class EventBuilder extends NHSNotifyEventBuilder { subject: databaseProofRequest.id, plane: 'data', }), - data: dynamoRecord, + data: eventData, }), }; } catch (error) { diff --git a/lambdas/event-publisher/src/domain/input-schemas.ts b/lambdas/event-publisher/src/domain/input-schemas.ts index 2ce5fc2a45..0da582a0c4 100644 --- a/lambdas/event-publisher/src/domain/input-schemas.ts +++ b/lambdas/event-publisher/src/domain/input-schemas.ts @@ -68,7 +68,14 @@ export const $DynamoDBRoutingConfig = schemaFor>()( ); export type DynamoDBRoutingConfig = z.infer; -export const $DynamoDBProofRequest = z.object({ id: z.string() }); +export const $DynamoDBProofRequest = z.object({ + id: z.string(), + templateId: z.string(), + templateType: z.enum(TEMPLATE_TYPE_LIST), + testPatientNhsNumber: z.string(), + contactDetailValue: z.string(), + personalisation: z.record(z.string(), z.string()).optional(), +}); export type DynamoDBProofRequest = z.infer; // the lambda doesn't necessarily have to only accept inputs from a dynamodb stream via an diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 35183cee47..687da0295e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -145,11 +145,6 @@ export type { PostV1LetterTemplateErrors, PostV1LetterTemplateResponse, PostV1LetterTemplateResponses, - PostV1ProofingRequestData, - PostV1ProofingRequestError, - PostV1ProofingRequestErrors, - PostV1ProofingRequestResponse, - PostV1ProofingRequestResponses, PostV1RoutingConfigurationData, PostV1RoutingConfigurationError, PostV1RoutingConfigurationErrors, @@ -163,6 +158,11 @@ export type { PostV1TemplateByTemplateIdProofData, PostV1TemplateByTemplateIdProofError, PostV1TemplateByTemplateIdProofErrors, + PostV1TemplateByTemplateIdProofingRequestData, + PostV1TemplateByTemplateIdProofingRequestError, + PostV1TemplateByTemplateIdProofingRequestErrors, + PostV1TemplateByTemplateIdProofingRequestResponse, + PostV1TemplateByTemplateIdProofingRequestResponses, PostV1TemplateByTemplateIdProofResponse, PostV1TemplateByTemplateIdProofResponses, PostV1TemplateData, diff --git a/packages/types/src/types.gen.ts b/packages/types/src/types.gen.ts index 2367096a60..18119d1416 100644 --- a/packages/types/src/types.gen.ts +++ b/packages/types/src/types.gen.ts @@ -173,6 +173,14 @@ export type CreatePdfLetterProperties = BaseLetterTemplateProperties & { letterVersion: 'PDF'; }; +export type CreateProofingRequest = { + testPatientNhsNumber: string; + contactDetailId?: string; + personalisation?: { + [key: string]: string; + }; +}; + export type CreateRoutingConfig = { campaignId: string; cascade: Array; @@ -310,27 +318,17 @@ export type PdfLetterProperties = BaseLetterTemplateProperties & { export type PersonalisedRenderRequestVariant = 'long' | 'short'; -export type CreateProofingRequest = { - templateId: string; - testNhsNumber?: string; - contactDetailId?: string; - personalisation?: { - [key: string]: string; - }; +export type ProofFileDetails = { + fileName: string; + supplier: string; + virusScanStatus: VirusScanStatus; }; export type ProofRequest = { id: string; templateId: string; templateType: 'NHS_APP' | 'EMAIL' | 'SMS'; - testPatientNhsNumber?: string; - contactDetails?: - | { - email: string; - } - | { - sms: string; - }; + contactDetailValue: string; personalisation?: { [key: string]: string; }; @@ -342,12 +340,6 @@ export type ProofRequestSuccess = { statusCode: number; }; -export type ProofFileDetails = { - fileName: string; - supplier: string; - virusScanStatus: VirusScanStatus; -}; - export type RenderDetails = { personalisationParameters?: { [key: string]: string; @@ -524,33 +516,6 @@ export type PostV1ContactDetailsResponses = { export type PostV1ContactDetailsResponse = PostV1ContactDetailsResponses[keyof PostV1ContactDetailsResponses]; -export type PostV1ProofingRequestData = { - body: CreateProofingRequest; - path?: never; - query?: never; - url: '/v1/proofing-request'; -}; - -export type PostV1ProofingRequestErrors = { - /** - * Error - */ - default: Failure; -}; - -export type PostV1ProofingRequestError = - PostV1ProofingRequestErrors[keyof PostV1ProofingRequestErrors]; - -export type PostV1ProofingRequestResponses = { - /** - * 201 response - */ - 201: ProofRequestSuccess; -}; - -export type PostV1ProofingRequestResponse = - PostV1ProofingRequestResponses[keyof PostV1ProofingRequestResponses]; - export type PostV1DocxLetterTemplateData = { /** * Letter template to create @@ -643,6 +608,38 @@ export type GetV1LetterVariantByLetterVariantIdResponses = { export type GetV1LetterVariantByLetterVariantIdResponse = GetV1LetterVariantByLetterVariantIdResponses[keyof GetV1LetterVariantByLetterVariantIdResponses]; +export type PostV1TemplateByTemplateIdProofingRequestData = { + body: CreateProofingRequest; + path: { + /** + * ID of the template to create a proofing request for + */ + templateId: string; + }; + query?: never; + url: '/v1/template/{templateId}/proofing-request'; +}; + +export type PostV1TemplateByTemplateIdProofingRequestErrors = { + /** + * Error + */ + default: Failure; +}; + +export type PostV1TemplateByTemplateIdProofingRequestError = + PostV1TemplateByTemplateIdProofingRequestErrors[keyof PostV1TemplateByTemplateIdProofingRequestErrors]; + +export type PostV1TemplateByTemplateIdProofingRequestResponses = { + /** + * 201 response + */ + 201: ProofRequestSuccess; +}; + +export type PostV1TemplateByTemplateIdProofingRequestResponse = + PostV1TemplateByTemplateIdProofingRequestResponses[keyof PostV1TemplateByTemplateIdProofingRequestResponses]; + export type PostV1RoutingConfigurationData = { /** * Routing configuration to create diff --git a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts new file mode 100644 index 0000000000..e4a99924c5 --- /dev/null +++ b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts @@ -0,0 +1,78 @@ +import { test, expect } from '@playwright/test'; +import { type TestUser, testUsers } from '../helpers/auth/cognito-auth-helper'; +import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; +import { + isoDateRegExp, + uuidRegExp, +} from 'nhs-notify-web-template-management-test-helper-utils'; +import { TemplateAPIPayloadFactory } from '../helpers/factories/template-api-payload-factory'; +import { getTestContext } from 'helpers/context/context'; + +test.describe('POST /v1/template/:templateId/proofing-request', () => { + const context = getTestContext(); + const templateStorageHelper = new TemplateStorageHelper(); + let user: TestUser; + + test.beforeAll(async () => { + user = await context.auth.getTestUser( + testUsers.UserDigitalProofingEnabled.userId + ); + }); + + test.afterAll(async () => { + await templateStorageHelper.deleteAdHocTemplates(); + }); + + test('returns 201 for NHS_APP template', async ({ request }) => { + const createResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: TemplateAPIPayloadFactory.getCreateTemplatePayload({ + templateType: 'NHS_APP', + }), + } + ); + + expect(createResponse.status()).toBe(201); + const created = await createResponse.json(); + templateStorageHelper.addAdHocTemplateKey({ + templateId: created.data.id, + clientId: user.clientId, + }); + + const start = new Date(); + + const proofingResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template/${created.data.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + }, + } + ); + + const result = await proofingResponse.json(); + const debug = JSON.stringify(result, null, 2); + + expect(proofingResponse.status(), debug).toBe(201); + + expect(result).toEqual({ + statusCode: 201, + data: { + id: expect.stringMatching(uuidRegExp), + templateId: created.data.id, + templateType: 'NHS_APP', + contactDetailValue: '9000000009', + createdAt: expect.stringMatching(isoDateRegExp), + }, + }); + + expect(result.data.createdAt).toBeDateRoughlyBetween([start, new Date()]); + }); +}); From 30da0f52ce2e718518641656d5dff676ea2eda16 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Tue, 12 May 2026 11:42:39 +0100 Subject: [PATCH 03/28] add nhs number to proof request validator --- .../iam_role_api_gateway_execution_role.tf | 1 + .../modules/backend-api/spec.tmpl.json | 4 + .../src/api/create-proofing-request.ts | 2 + .../src/schemas/proof-request.ts | 1 + packages/types/src/types.gen.ts | 75 +++--- tests/test-team/helpers/types.ts | 5 +- .../create-proofing-request.api.spec.ts | 129 ++++++++++ .../proof-requests.event.spec.ts | 225 ++++++++++++++++-- 8 files changed, 376 insertions(+), 66 deletions(-) diff --git a/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf index 3edbd9dd00..bdd1ad2a10 100644 --- a/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf +++ b/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf @@ -54,6 +54,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" { module.upload_letter_template_lambda.function_arn, module.count_routing_configs_lambda.function_arn, module.create_contact_details_lambda.function_arn, + module.create_proofing_request_lambda.function_arn, module.create_template_lambda.function_arn, module.create_routing_config_lambda.function_arn, module.delete_routing_config_lambda.function_arn, diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index 431f7f8c33..74515434eb 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -1090,6 +1090,9 @@ "SMS" ], "type": "string" + }, + "testPatientNhsNumber": { + "type": "string" } }, "required": [ @@ -1097,6 +1100,7 @@ "templateId", "templateType", "contactDetailValue", + "testPatientNhsNumber", "createdAt" ], "type": "object" diff --git a/lambdas/backend-api/src/api/create-proofing-request.ts b/lambdas/backend-api/src/api/create-proofing-request.ts index 2f2e11f9db..919d61e451 100644 --- a/lambdas/backend-api/src/api/create-proofing-request.ts +++ b/lambdas/backend-api/src/api/create-proofing-request.ts @@ -54,6 +54,7 @@ async function handleNhsApp( templateId, templateType: 'NHS_APP', contactDetailValue: body.testPatientNhsNumber, + testPatientNhsNumber: body.testPatientNhsNumber, personalisation: body.personalisation, }, user @@ -98,6 +99,7 @@ async function handleEmailOrSms( templateId, templateType, contactDetailValue: contactDetailResult.data.value, + testPatientNhsNumber: body.testPatientNhsNumber, personalisation: body.personalisation, }, user diff --git a/lambdas/backend-client/src/schemas/proof-request.ts b/lambdas/backend-client/src/schemas/proof-request.ts index d6ed979c66..bf84356005 100644 --- a/lambdas/backend-client/src/schemas/proof-request.ts +++ b/lambdas/backend-client/src/schemas/proof-request.ts @@ -6,6 +6,7 @@ export const $ProofRequest: z.ZodType = z.object({ templateId: z.string(), templateType: z.enum(['NHS_APP', 'EMAIL', 'SMS']), contactDetailValue: z.string(), + testPatientNhsNumber: z.string(), personalisation: z.record(z.string(), z.string()).optional(), createdAt: z.string(), }); diff --git a/packages/types/src/types.gen.ts b/packages/types/src/types.gen.ts index 18119d1416..d3af8c205b 100644 --- a/packages/types/src/types.gen.ts +++ b/packages/types/src/types.gen.ts @@ -174,11 +174,11 @@ export type CreatePdfLetterProperties = BaseLetterTemplateProperties & { }; export type CreateProofingRequest = { - testPatientNhsNumber: string; contactDetailId?: string; personalisation?: { [key: string]: string; }; + testPatientNhsNumber: string; }; export type CreateRoutingConfig = { @@ -325,14 +325,15 @@ export type ProofFileDetails = { }; export type ProofRequest = { - id: string; - templateId: string; - templateType: 'NHS_APP' | 'EMAIL' | 'SMS'; contactDetailValue: string; + createdAt: string; + id: string; personalisation?: { [key: string]: string; }; - createdAt: string; + templateId: string; + templateType: 'NHS_APP' | 'EMAIL' | 'SMS'; + testPatientNhsNumber: string; }; export type ProofRequestSuccess = { @@ -608,38 +609,6 @@ export type GetV1LetterVariantByLetterVariantIdResponses = { export type GetV1LetterVariantByLetterVariantIdResponse = GetV1LetterVariantByLetterVariantIdResponses[keyof GetV1LetterVariantByLetterVariantIdResponses]; -export type PostV1TemplateByTemplateIdProofingRequestData = { - body: CreateProofingRequest; - path: { - /** - * ID of the template to create a proofing request for - */ - templateId: string; - }; - query?: never; - url: '/v1/template/{templateId}/proofing-request'; -}; - -export type PostV1TemplateByTemplateIdProofingRequestErrors = { - /** - * Error - */ - default: Failure; -}; - -export type PostV1TemplateByTemplateIdProofingRequestError = - PostV1TemplateByTemplateIdProofingRequestErrors[keyof PostV1TemplateByTemplateIdProofingRequestErrors]; - -export type PostV1TemplateByTemplateIdProofingRequestResponses = { - /** - * 201 response - */ - 201: ProofRequestSuccess; -}; - -export type PostV1TemplateByTemplateIdProofingRequestResponse = - PostV1TemplateByTemplateIdProofingRequestResponses[keyof PostV1TemplateByTemplateIdProofingRequestResponses]; - export type PostV1RoutingConfigurationData = { /** * Routing configuration to create @@ -1214,6 +1183,38 @@ export type PostV1TemplateByTemplateIdProofResponses = { export type PostV1TemplateByTemplateIdProofResponse = PostV1TemplateByTemplateIdProofResponses[keyof PostV1TemplateByTemplateIdProofResponses]; +export type PostV1TemplateByTemplateIdProofingRequestData = { + body: CreateProofingRequest; + path: { + /** + * ID of the template to create a proofing request for + */ + templateId: string; + }; + query?: never; + url: '/v1/template/{templateId}/proofing-request'; +}; + +export type PostV1TemplateByTemplateIdProofingRequestErrors = { + /** + * Error + */ + default: Failure; +}; + +export type PostV1TemplateByTemplateIdProofingRequestError = + PostV1TemplateByTemplateIdProofingRequestErrors[keyof PostV1TemplateByTemplateIdProofingRequestErrors]; + +export type PostV1TemplateByTemplateIdProofingRequestResponses = { + /** + * 201 response + */ + 201: ProofRequestSuccess; +}; + +export type PostV1TemplateByTemplateIdProofingRequestResponse = + PostV1TemplateByTemplateIdProofingRequestResponses[keyof PostV1TemplateByTemplateIdProofingRequestResponses]; + export type GetV1TemplateByTemplateIdRoutingConfigurationsData = { body?: never; path: { diff --git a/tests/test-team/helpers/types.ts b/tests/test-team/helpers/types.ts index e68317d208..63812824d6 100644 --- a/tests/test-team/helpers/types.ts +++ b/tests/test-team/helpers/types.ts @@ -141,10 +141,7 @@ export type DigitalProofRequest = { owner: string; createdAt: string; personalisation: Record; - contactDetails?: { - sms?: string; - email?: string; - }; + contactDetailValue: string; templateId: string; templateType: DigitalTemplateType; testPatientNhsNumber: string; diff --git a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts index e4a99924c5..b1f4d68c7a 100644 --- a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts @@ -1,6 +1,8 @@ import { test, expect } from '@playwright/test'; import { type TestUser, testUsers } from '../helpers/auth/cognito-auth-helper'; import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; +import { ContactDetailHelper } from '../helpers/db/contact-details-helper'; +import { makeVerifiedContactDetail } from '../helpers/factories/contact-details-factory'; import { isoDateRegExp, uuidRegExp, @@ -11,6 +13,7 @@ import { getTestContext } from 'helpers/context/context'; test.describe('POST /v1/template/:templateId/proofing-request', () => { const context = getTestContext(); const templateStorageHelper = new TemplateStorageHelper(); + const contactDetailHelper = new ContactDetailHelper(); let user: TestUser; test.beforeAll(async () => { @@ -21,6 +24,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { test.afterAll(async () => { await templateStorageHelper.deleteAdHocTemplates(); + await contactDetailHelper.cleanup(); }); test('returns 201 for NHS_APP template', async ({ request }) => { @@ -69,6 +73,131 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { templateId: created.data.id, templateType: 'NHS_APP', contactDetailValue: '9000000009', + testPatientNhsNumber: '9000000009', + createdAt: expect.stringMatching(isoDateRegExp), + }, + }); + + expect(result.data.createdAt).toBeDateRoughlyBetween([start, new Date()]); + }); + + test('returns 201 for EMAIL template', async ({ request }) => { + const createResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: TemplateAPIPayloadFactory.getCreateTemplatePayload({ + templateType: 'EMAIL', + }), + } + ); + + expect(createResponse.status()).toBe(201); + const created = await createResponse.json(); + templateStorageHelper.addAdHocTemplateKey({ + templateId: created.data.id, + clientId: user.clientId, + }); + + const contactDetail = makeVerifiedContactDetail({ + type: 'EMAIL', + value: 'test@example.com', + owner: user.internalUserId, + }); + await contactDetailHelper.seed([contactDetail]); + + const start = new Date(); + + const proofingResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template/${created.data.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + contactDetailId: contactDetail.id, + }, + } + ); + + const result = await proofingResponse.json(); + const debug = JSON.stringify(result, null, 2); + + expect(proofingResponse.status(), debug).toBe(201); + + expect(result).toEqual({ + statusCode: 201, + data: { + id: expect.stringMatching(uuidRegExp), + templateId: created.data.id, + templateType: 'EMAIL', + contactDetailValue: 'test@example.com', + testPatientNhsNumber: '9000000009', + createdAt: expect.stringMatching(isoDateRegExp), + }, + }); + + expect(result.data.createdAt).toBeDateRoughlyBetween([start, new Date()]); + }); + + test('returns 201 for SMS template', async ({ request }) => { + const createResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: TemplateAPIPayloadFactory.getCreateTemplatePayload({ + templateType: 'SMS', + }), + } + ); + + expect(createResponse.status()).toBe(201); + const created = await createResponse.json(); + templateStorageHelper.addAdHocTemplateKey({ + templateId: created.data.id, + clientId: user.clientId, + }); + + const contactDetail = makeVerifiedContactDetail({ + type: 'SMS', + value: '+447700900000', + owner: user.internalUserId, + }); + await contactDetailHelper.seed([contactDetail]); + + const start = new Date(); + + const proofingResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template/${created.data.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + contactDetailId: contactDetail.id, + }, + } + ); + + const result = await proofingResponse.json(); + const debug = JSON.stringify(result, null, 2); + + expect(proofingResponse.status(), debug).toBe(201); + + expect(result).toEqual({ + statusCode: 201, + data: { + id: expect.stringMatching(uuidRegExp), + templateId: created.data.id, + templateType: 'SMS', + contactDetailValue: '+447700900000', + testPatientNhsNumber: '9000000009', createdAt: expect.stringMatching(isoDateRegExp), }, }); diff --git a/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts b/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts index fda2ac701c..ab4829a42a 100644 --- a/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto'; import { templateManagementEventSubscriber as test, expect, @@ -6,46 +5,146 @@ import { import { testUsers, type TestUser } from '../helpers/auth/cognito-auth-helper'; import { getTestContext } from 'helpers/context/context'; import { eventWithId } from '../helpers/events/matchers'; -import { ProofRequestsStorageHelper } from 'helpers/db/proof-requests-storage-helper'; +import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; +import { ContactDetailHelper } from '../helpers/db/contact-details-helper'; +import { makeVerifiedContactDetail } from '../helpers/factories/contact-details-factory'; +import { TemplateAPIPayloadFactory } from '../helpers/factories/template-api-payload-factory'; test.describe('ProofRequestedEvent', () => { const context = getTestContext(); - const proofRequestsStorageHelper = new ProofRequestsStorageHelper(); + const templateStorageHelper = new TemplateStorageHelper(); + const contactDetailHelper = new ContactDetailHelper(); let user: TestUser; test.beforeAll(async () => { - user = await context.auth.getTestUser(testUsers.User1.userId); + user = await context.auth.getTestUser( + testUsers.UserDigitalProofingEnabled.userId + ); }); test.afterAll(async () => { - await proofRequestsStorageHelper.deleteSeeded(); + await templateStorageHelper.deleteAdHocTemplates(); + await contactDetailHelper.cleanup(); }); - test('Expect a ProofRequestedEventv1 to be published when a proof request is created', async ({ + test('publishes ProofRequested.v1 event for NHS_APP template', async ({ + request, eventSubscriber, }) => { + const createResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: TemplateAPIPayloadFactory.getCreateTemplatePayload({ + templateType: 'NHS_APP', + }), + } + ); + + expect(createResponse.status()).toBe(201); + const created = await createResponse.json(); + templateStorageHelper.addAdHocTemplateKey({ + templateId: created.data.id, + clientId: user.clientId, + }); + const start = new Date(); - const proofRequestId = randomUUID(); + const proofingResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template/${created.data.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + }, + } + ); + + const result = await proofingResponse.json(); + const debug = JSON.stringify(result, null, 2); + expect(proofingResponse.status(), debug).toBe(201); + + const proofRequestId = result.data.id; + + await expect(async () => { + const events = await eventSubscriber.receive({ + since: start, + match: eventWithId(proofRequestId), + }); + + expect(events).toHaveLength(1); + + expect(events).toContainEqual( + expect.objectContaining({ + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.ProofRequested.v1', + data: expect.objectContaining({ + id: proofRequestId, + templateId: created.data.id, + templateType: 'NHS_APP', + testPatientNhsNumber: '9000000009', + }), + }), + }) + ); + }).toPass({ timeout: 60_000, intervals: [1000, 3000, 5000] }); + }); + + test('publishes ProofRequested.v1 event for SMS template', async ({ + request, + eventSubscriber, + }) => { + const createResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: TemplateAPIPayloadFactory.getCreateTemplatePayload({ + templateType: 'SMS', + }), + } + ); + + expect(createResponse.status()).toBe(201); + const created = await createResponse.json(); + templateStorageHelper.addAdHocTemplateKey({ + templateId: created.data.id, + clientId: user.clientId, + }); + + const contactDetail = makeVerifiedContactDetail({ + type: 'SMS', + value: '+447700900000', + owner: user.internalUserId, + }); + await contactDetailHelper.seed([contactDetail]); + + const start = new Date(); - // TODO: CCM-7941 - use API rather than directly into DB. - await proofRequestsStorageHelper.seed([ + const proofingResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template/${created.data.id}/proofing-request`, { - id: proofRequestId, - owner: `CLIENT#${user.clientId}`, - createdAt: new Date().toISOString(), - personalisation: { - gpSurgery: 'Test GP Surgery', + headers: { + Authorization: await user.getAccessToken(), }, - contactDetails: { - sms: '07999999999', + data: { + testPatientNhsNumber: '9000000009', + contactDetailId: contactDetail.id, }, - templateId: randomUUID(), - templateType: 'SMS', - testPatientNhsNumber: '9999999999', - }, - ]); + } + ); + + const result = await proofingResponse.json(); + const debug = JSON.stringify(result, null, 2); + expect(proofingResponse.status(), debug).toBe(201); + + const proofRequestId = result.data.id; await expect(async () => { const events = await eventSubscriber.receive({ @@ -61,13 +160,89 @@ test.describe('ProofRequestedEvent', () => { type: 'uk.nhs.notify.template-management.ProofRequested.v1', data: expect.objectContaining({ id: proofRequestId, - testPatientNhsNumber: '9999999999', + templateId: created.data.id, templateType: 'SMS', - personalisation: { - gpSurgery: 'Test GP Surgery', + testPatientNhsNumber: '9000000009', + contactDetails: { + sms: '+447700900000', }, + }), + }), + }) + ); + }).toPass({ timeout: 60_000, intervals: [1000, 3000, 5000] }); + }); + + test('publishes ProofRequested.v1 event for EMAIL template', async ({ + request, + eventSubscriber, + }) => { + const createResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: TemplateAPIPayloadFactory.getCreateTemplatePayload({ + templateType: 'EMAIL', + }), + } + ); + + expect(createResponse.status()).toBe(201); + const created = await createResponse.json(); + templateStorageHelper.addAdHocTemplateKey({ + templateId: created.data.id, + clientId: user.clientId, + }); + + const contactDetail = makeVerifiedContactDetail({ + type: 'EMAIL', + value: 'test@example.com', + owner: user.internalUserId, + }); + await contactDetailHelper.seed([contactDetail]); + + const start = new Date(); + + const proofingResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template/${created.data.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + contactDetailId: contactDetail.id, + }, + } + ); + + const result = await proofingResponse.json(); + const debug = JSON.stringify(result, null, 2); + expect(proofingResponse.status(), debug).toBe(201); + + const proofRequestId = result.data.id; + + await expect(async () => { + const events = await eventSubscriber.receive({ + since: start, + match: eventWithId(proofRequestId), + }); + + expect(events).toHaveLength(1); + + expect(events).toContainEqual( + expect.objectContaining({ + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.ProofRequested.v1', + data: expect.objectContaining({ + id: proofRequestId, + templateId: created.data.id, + templateType: 'EMAIL', + testPatientNhsNumber: '9000000009', contactDetails: { - sms: '07999999999', + email: 'test@example.com', }, }), }), From 53be4a938da4167160db2c8836bcb9b728f0c8b8 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Tue, 12 May 2026 12:34:45 +0100 Subject: [PATCH 04/28] unhappy path api tests --- .../helpers/auth/cognito-auth-helper.ts | 9 + .../create-proofing-request.api.spec.ts | 614 ++++++++++++++---- 2 files changed, 499 insertions(+), 124 deletions(-) diff --git a/tests/test-team/helpers/auth/cognito-auth-helper.ts b/tests/test-team/helpers/auth/cognito-auth-helper.ts index c2a20dcf81..02dd5c3a05 100644 --- a/tests/test-team/helpers/auth/cognito-auth-helper.ts +++ b/tests/test-team/helpers/auth/cognito-auth-helper.ts @@ -170,6 +170,15 @@ export const testUsers: Record< internalUserId: 'InternalUserDigitalProofingEnabled', clientKey: 'ClientDigitalProofingEnabled', }, + + /** + * UserDigitalProofingEnabledSharedClient shares a client with UserDigitalProofingEnabled + */ + UserDigitalProofingEnabledSharedClient: { + userId: 'UserDigitalProofingEnabledSharedClient', + internalUserId: 'InternalUserDigitalProofingEnabledSharedClient', + clientKey: 'ClientDigitalProofingEnabled', + }, }; export type TestUser = TestUserStaticDetails & diff --git a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts index b1f4d68c7a..3fbd01fb25 100644 --- a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts @@ -2,12 +2,17 @@ import { test, expect } from '@playwright/test'; import { type TestUser, testUsers } from '../helpers/auth/cognito-auth-helper'; import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; import { ContactDetailHelper } from '../helpers/db/contact-details-helper'; -import { makeVerifiedContactDetail } from '../helpers/factories/contact-details-factory'; +import { + makeVerifiedContactDetail, + type FactoryContactDetail, +} from '../helpers/factories/contact-details-factory'; import { isoDateRegExp, uuidRegExp, } from 'nhs-notify-web-template-management-test-helper-utils'; import { TemplateAPIPayloadFactory } from '../helpers/factories/template-api-payload-factory'; +import { TemplateFactory } from '../helpers/factories/template-factory'; +import { randomUUID } from 'node:crypto'; import { getTestContext } from 'helpers/context/context'; test.describe('POST /v1/template/:templateId/proofing-request', () => { @@ -15,27 +20,44 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { const templateStorageHelper = new TemplateStorageHelper(); const contactDetailHelper = new ContactDetailHelper(); let user: TestUser; + let userDifferentClient: TestUser; + let userNoDigitalProofing: TestUser; + let userSharedClient: TestUser; test.beforeAll(async () => { user = await context.auth.getTestUser( testUsers.UserDigitalProofingEnabled.userId ); + userDifferentClient = await context.auth.getTestUser( + testUsers.User2.userId + ); + userNoDigitalProofing = await context.auth.getTestUser( + testUsers.User1.userId + ); + userSharedClient = await context.auth.getTestUser( + testUsers.UserDigitalProofingEnabledSharedClient.userId + ); }); test.afterAll(async () => { await templateStorageHelper.deleteAdHocTemplates(); + await templateStorageHelper.deleteSeededTemplates(); await contactDetailHelper.cleanup(); }); - test('returns 201 for NHS_APP template', async ({ request }) => { + const createTemplate = async ( + request: Parameters[2]>[0]['request'], + templateType: 'NHS_APP' | 'EMAIL' | 'SMS', + asUser: TestUser = user + ) => { const createResponse = await request.post( `${process.env.API_BASE_URL}/v1/template`, { headers: { - Authorization: await user.getAccessToken(), + Authorization: await asUser.getAccessToken(), }, data: TemplateAPIPayloadFactory.getCreateTemplatePayload({ - templateType: 'NHS_APP', + templateType, }), } ); @@ -44,164 +66,508 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { const created = await createResponse.json(); templateStorageHelper.addAdHocTemplateKey({ templateId: created.data.id, - clientId: user.clientId, + clientId: asUser.clientId, }); - const start = new Date(); + return created.data; + }; - const proofingResponse = await request.post( - `${process.env.API_BASE_URL}/v1/template/${created.data.id}/proofing-request`, - { - headers: { - Authorization: await user.getAccessToken(), - }, + test.describe('happy path', () => { + test('returns 201 for NHS_APP template', async ({ request }) => { + const template = await createTemplate(request, 'NHS_APP'); + + const start = new Date(); + + const proofingResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + }, + } + ); + + const result = await proofingResponse.json(); + const debug = JSON.stringify(result, null, 2); + + expect(proofingResponse.status(), debug).toBe(201); + + expect(result).toEqual({ + statusCode: 201, data: { + id: expect.stringMatching(uuidRegExp), + templateId: template.id, + templateType: 'NHS_APP', + contactDetailValue: '9000000009', testPatientNhsNumber: '9000000009', + createdAt: expect.stringMatching(isoDateRegExp), }, - } - ); + }); - const result = await proofingResponse.json(); - const debug = JSON.stringify(result, null, 2); + expect(result.data.createdAt).toBeDateRoughlyBetween([ + start, + new Date(), + ]); + }); - expect(proofingResponse.status(), debug).toBe(201); + test('returns 201 for EMAIL template', async ({ request }) => { + const template = await createTemplate(request, 'EMAIL'); - expect(result).toEqual({ - statusCode: 201, - data: { - id: expect.stringMatching(uuidRegExp), - templateId: created.data.id, - templateType: 'NHS_APP', - contactDetailValue: '9000000009', - testPatientNhsNumber: '9000000009', - createdAt: expect.stringMatching(isoDateRegExp), - }, - }); + const contactDetail = makeVerifiedContactDetail({ + type: 'EMAIL', + value: 'test@example.com', + owner: user.internalUserId, + }); + await contactDetailHelper.seed([contactDetail]); - expect(result.data.createdAt).toBeDateRoughlyBetween([start, new Date()]); - }); + const start = new Date(); - test('returns 201 for EMAIL template', async ({ request }) => { - const createResponse = await request.post( - `${process.env.API_BASE_URL}/v1/template`, - { - headers: { - Authorization: await user.getAccessToken(), - }, - data: TemplateAPIPayloadFactory.getCreateTemplatePayload({ + const proofingResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + contactDetailId: contactDetail.id, + }, + } + ); + + const result = await proofingResponse.json(); + const debug = JSON.stringify(result, null, 2); + + expect(proofingResponse.status(), debug).toBe(201); + + expect(result).toEqual({ + statusCode: 201, + data: { + id: expect.stringMatching(uuidRegExp), + templateId: template.id, templateType: 'EMAIL', - }), - } - ); + contactDetailValue: 'test@example.com', + testPatientNhsNumber: '9000000009', + createdAt: expect.stringMatching(isoDateRegExp), + }, + }); - expect(createResponse.status()).toBe(201); - const created = await createResponse.json(); - templateStorageHelper.addAdHocTemplateKey({ - templateId: created.data.id, - clientId: user.clientId, + expect(result.data.createdAt).toBeDateRoughlyBetween([ + start, + new Date(), + ]); }); - const contactDetail = makeVerifiedContactDetail({ - type: 'EMAIL', - value: 'test@example.com', - owner: user.internalUserId, - }); - await contactDetailHelper.seed([contactDetail]); + test('returns 201 for SMS template', async ({ request }) => { + const template = await createTemplate(request, 'SMS'); - const start = new Date(); + const contactDetail = makeVerifiedContactDetail({ + type: 'SMS', + value: '+447700900000', + owner: user.internalUserId, + }); + await contactDetailHelper.seed([contactDetail]); - const proofingResponse = await request.post( - `${process.env.API_BASE_URL}/v1/template/${created.data.id}/proofing-request`, - { - headers: { - Authorization: await user.getAccessToken(), - }, + const start = new Date(); + + const proofingResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + contactDetailId: contactDetail.id, + }, + } + ); + + const result = await proofingResponse.json(); + const debug = JSON.stringify(result, null, 2); + + expect(proofingResponse.status(), debug).toBe(201); + + expect(result).toEqual({ + statusCode: 201, data: { + id: expect.stringMatching(uuidRegExp), + templateId: template.id, + templateType: 'SMS', + contactDetailValue: '+447700900000', testPatientNhsNumber: '9000000009', - contactDetailId: contactDetail.id, + createdAt: expect.stringMatching(isoDateRegExp), }, - } - ); + }); - const result = await proofingResponse.json(); - const debug = JSON.stringify(result, null, 2); + expect(result.data.createdAt).toBeDateRoughlyBetween([ + start, + new Date(), + ]); + }); + }); - expect(proofingResponse.status(), debug).toBe(201); + test.describe('auth', () => { + test('returns 401 if no auth token', async ({ request }) => { + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/some-template/proofing-request`, + { + data: { + testPatientNhsNumber: '9000000009', + }, + } + ); - expect(result).toEqual({ - statusCode: 201, - data: { - id: expect.stringMatching(uuidRegExp), - templateId: created.data.id, - templateType: 'EMAIL', - contactDetailValue: 'test@example.com', - testPatientNhsNumber: '9000000009', - createdAt: expect.stringMatching(isoDateRegExp), - }, + expect(response.status()).toBe(401); + expect(await response.json()).toEqual({ + message: 'Unauthorized', + }); }); + }); + + test.describe('validation', () => { + test('returns 400 if testPatientNhsNumber is missing', async ({ + request, + }) => { + const template = await createTemplate(request, 'NHS_APP'); + + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: {}, + } + ); + + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); + + expect(response.status(), debug).toBe(400); + expect(result).toEqual({ + statusCode: 400, + technicalMessage: 'testPatientNhsNumber is required', + }); + }); + + test('returns 400 if contactDetailId is provided for NHS_APP template', async ({ + request, + }) => { + const template = await createTemplate(request, 'NHS_APP'); + + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + contactDetailId: randomUUID(), + }, + } + ); + + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); - expect(result.data.createdAt).toBeDateRoughlyBetween([start, new Date()]); + expect(response.status(), debug).toBe(400); + expect(result).toEqual({ + statusCode: 400, + technicalMessage: + 'contactDetailId is not accepted for NHS_APP templates', + }); + }); + + test('returns 400 if contactDetailId is missing for EMAIL template', async ({ + request, + }) => { + const template = await createTemplate(request, 'EMAIL'); + + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + }, + } + ); + + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); + + expect(response.status(), debug).toBe(400); + expect(result).toEqual({ + statusCode: 400, + technicalMessage: 'contactDetailId is required for EMAIL templates', + }); + }); + + test('returns 400 if contactDetailId is missing for SMS template', async ({ + request, + }) => { + const template = await createTemplate(request, 'SMS'); + + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + }, + } + ); + + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); + + expect(response.status(), debug).toBe(400); + expect(result).toEqual({ + statusCode: 400, + technicalMessage: 'contactDetailId is required for SMS templates', + }); + }); }); - test('returns 201 for SMS template', async ({ request }) => { - const createResponse = await request.post( - `${process.env.API_BASE_URL}/v1/template`, - { - headers: { - Authorization: await user.getAccessToken(), - }, - data: TemplateAPIPayloadFactory.getCreateTemplatePayload({ - templateType: 'SMS', - }), - } - ); + test.describe('template', () => { + test('returns 404 if template does not exist', async ({ request }) => { + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/noexist/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + }, + } + ); - expect(createResponse.status()).toBe(201); - const created = await createResponse.json(); - templateStorageHelper.addAdHocTemplateKey({ - templateId: created.data.id, - clientId: user.clientId, + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); + + expect(response.status(), debug).toBe(404); + expect(result).toEqual({ + statusCode: 404, + technicalMessage: 'Template not found', + }); }); - const contactDetail = makeVerifiedContactDetail({ - type: 'SMS', - value: '+447700900000', - owner: user.internalUserId, + test('returns 404 if template is owned by a different client', async ({ + request, + }) => { + const template = await createTemplate(request, 'NHS_APP'); + + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await userDifferentClient.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + }, + } + ); + + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); + + expect(response.status(), debug).toBe(404); + expect(result).toEqual({ + statusCode: 404, + technicalMessage: 'Template not found', + }); }); - await contactDetailHelper.seed([contactDetail]); - const start = new Date(); + test('returns 400 for LETTER template', async ({ request }) => { + const letterTemplate = TemplateFactory.uploadPdfLetterTemplate( + randomUUID(), + user, + 'Test Letter template' + ); - const proofingResponse = await request.post( - `${process.env.API_BASE_URL}/v1/template/${created.data.id}/proofing-request`, - { - headers: { - Authorization: await user.getAccessToken(), - }, - data: { - testPatientNhsNumber: '9000000009', - contactDetailId: contactDetail.id, - }, - } - ); + await templateStorageHelper.seedTemplateData([letterTemplate]); - const result = await proofingResponse.json(); - const debug = JSON.stringify(result, null, 2); + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/${letterTemplate.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + }, + } + ); - expect(proofingResponse.status(), debug).toBe(201); + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); - expect(result).toEqual({ - statusCode: 201, - data: { - id: expect.stringMatching(uuidRegExp), + expect(response.status(), debug).toBe(400); + expect(result).toEqual({ + statusCode: 400, + technicalMessage: + 'Proofing requests are not supported for LETTER templates', + }); + }); + }); + + test.describe('feature flag', () => { + test('returns 403 if digital proofing is not enabled for template type', async ({ + request, + }) => { + const createResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template`, + { + headers: { + Authorization: await userNoDigitalProofing.getAccessToken(), + }, + data: TemplateAPIPayloadFactory.getCreateTemplatePayload({ + templateType: 'NHS_APP', + }), + } + ); + + expect(createResponse.status()).toBe(201); + const created = await createResponse.json(); + templateStorageHelper.addAdHocTemplateKey({ templateId: created.data.id, - templateType: 'SMS', - contactDetailValue: '+447700900000', - testPatientNhsNumber: '9000000009', - createdAt: expect.stringMatching(isoDateRegExp), - }, + clientId: userNoDigitalProofing.clientId, + }); + + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/${created.data.id}/proofing-request`, + { + headers: { + Authorization: await userNoDigitalProofing.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + }, + } + ); + + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); + + expect(response.status(), debug).toBe(403); + expect(result).toEqual({ + statusCode: 403, + technicalMessage: + 'Digital proofing is not enabled for NHS_APP templates', + }); + }); + }); + + test.describe('contact details', () => { + test('returns 404 if contact detail does not exist', async ({ + request, + }) => { + const template = await createTemplate(request, 'EMAIL'); + + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + contactDetailId: randomUUID(), + }, + } + ); + + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); + + expect(response.status(), debug).toBe(404); + expect(result).toEqual({ + statusCode: 404, + technicalMessage: 'Contact detail not found', + }); }); - expect(result.data.createdAt).toBeDateRoughlyBetween([start, new Date()]); + test('returns 404 if contact detail is owned by a different user', async ({ + request, + }) => { + const template = await createTemplate(request, 'EMAIL'); + + const contactDetail = makeVerifiedContactDetail({ + type: 'EMAIL', + value: 'other-user@example.com', + owner: userSharedClient.internalUserId, + }); + await contactDetailHelper.seed([contactDetail]); + + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + contactDetailId: contactDetail.id, + }, + } + ); + + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); + + expect(response.status(), debug).toBe(404); + expect(result).toEqual({ + statusCode: 404, + technicalMessage: 'Contact detail not found', + }); + }); + + test('returns 400 if contact detail is not verified', async ({ + request, + }) => { + const template = await createTemplate(request, 'EMAIL'); + + const contactDetail: FactoryContactDetail = { + id: randomUUID(), + status: 'PENDING_VERIFICATION', + type: 'EMAIL', + value: 'unverified@example.com', + owner: user.internalUserId, + }; + await contactDetailHelper.seed([contactDetail]); + + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + contactDetailId: contactDetail.id, + }, + } + ); + + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); + + expect(response.status(), debug).toBe(400); + expect(result).toEqual({ + statusCode: 400, + technicalMessage: 'Contact detail is not verified', + }); + }); }); }); From 670b4ddecdeb8224ea4ca6df0fd43e675f5404d8 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Tue, 12 May 2026 13:24:56 +0100 Subject: [PATCH 05/28] coverage --- .../api/create-proofing-request.test.ts | 208 +++++++++ .../app/proofing-request-client.test.ts | 401 ++++++++++++++++++ .../src/api/create-proofing-request.ts | 202 ++------- .../src/app/proofing-request-client.ts | 152 +++++++ .../src/container/proofing-request.ts | 9 +- .../__tests__/schemas/proof-request.test.ts | 89 ++++ .../src/schemas/proof-request.ts | 12 +- .../__tests__/domain/event-builder.test.ts | 87 ++++ 8 files changed, 979 insertions(+), 181 deletions(-) create mode 100644 lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts create mode 100644 lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts create mode 100644 lambdas/backend-api/src/app/proofing-request-client.ts create mode 100644 lambdas/backend-client/src/__tests__/schemas/proof-request.test.ts diff --git a/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts b/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts new file mode 100644 index 0000000000..52f95c54da --- /dev/null +++ b/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts @@ -0,0 +1,208 @@ +import type { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import { mock } from 'jest-mock-extended'; +import type { ProofRequest } from 'nhs-notify-web-template-management-types'; +import { createHandler } from '@backend-api/api/create-proofing-request'; +import type { ProofingRequestClient } from '@backend-api/app/proofing-request-client'; + +function setup() { + const proofingRequestClient = mock(); + const mocks = { proofingRequestClient }; + const handler = createHandler(mocks); + + return { handler, mocks }; +} + +describe('Create Proofing Request Handler', () => { + beforeEach(jest.resetAllMocks); + + test.each([ + ['undefined', undefined], + ['missing user', { clientId: 'client-id', internalUserId: undefined }], + ['missing client', { clientId: undefined, internalUserId: 'user-1234' }], + ])( + 'should return 400 - Invalid request when requestContext is %s', + async (_, ctx) => { + const { handler, mocks } = setup(); + + const event = mock({ + requestContext: { authorizer: ctx }, + body: JSON.stringify({ testPatientNhsNumber: '9000000009' }), + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Invalid request', + }), + }); + + expect(mocks.proofingRequestClient.create).not.toHaveBeenCalled(); + } + ); + + test('should return 400 when templateId is missing', async () => { + const { handler, mocks } = setup(); + + const event = mock({ + requestContext: { + authorizer: { internalUserId: 'user-1234', clientId: 'client-id' }, + }, + pathParameters: { templateId: undefined }, + body: JSON.stringify({ testPatientNhsNumber: '9000000009' }), + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'templateId is required', + }), + }); + + expect(mocks.proofingRequestClient.create).not.toHaveBeenCalled(); + }); + + test('should return 400 when body is invalid JSON', async () => { + const { handler, mocks } = setup(); + + const event = mock({ + requestContext: { + authorizer: { internalUserId: 'user-1234', clientId: 'client-id' }, + }, + pathParameters: { templateId: 'template-123' }, + body: '{invalid-json', + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Invalid JSON body', + }), + }); + + expect(mocks.proofingRequestClient.create).not.toHaveBeenCalled(); + }); + + test('should return error when client returns error', async () => { + const { handler, mocks } = setup(); + + mocks.proofingRequestClient.create.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 500, + description: 'Internal server error', + }, + }, + }); + + const event = mock({ + requestContext: { + authorizer: { internalUserId: 'user-1234', clientId: 'client-id' }, + }, + pathParameters: { templateId: 'template-123' }, + body: JSON.stringify({ testPatientNhsNumber: '9000000009' }), + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + statusCode: 500, + technicalMessage: 'Internal server error', + }), + }); + + expect(mocks.proofingRequestClient.create).toHaveBeenCalledWith( + 'template-123', + { testPatientNhsNumber: '9000000009' }, + { internalUserId: 'user-1234', clientId: 'client-id' } + ); + }); + + test('should pass validation error details from client', async () => { + const { handler, mocks } = setup(); + + mocks.proofingRequestClient.create.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 400, + description: 'Request failed validation', + details: { + testPatientNhsNumber: 'Required', + }, + }, + }, + }); + + const event = mock({ + requestContext: { + authorizer: { internalUserId: 'user-1234', clientId: 'client-id' }, + }, + pathParameters: { templateId: 'template-123' }, + body: JSON.stringify({}), + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Request failed validation', + details: { + testPatientNhsNumber: 'Required', + }, + }), + }); + }); + + test('should return 201 with created proof request', async () => { + const { handler, mocks } = setup(); + + const proofRequest: ProofRequest = { + id: 'proof-req-id', + templateId: 'template-123', + templateType: 'NHS_APP', + contactDetailValue: '9000000009', + testPatientNhsNumber: '9000000009', + createdAt: '2024-01-01T00:00:00.000Z', + }; + + mocks.proofingRequestClient.create.mockResolvedValueOnce({ + data: proofRequest, + }); + + const event = mock({ + requestContext: { + authorizer: { internalUserId: 'user-1234', clientId: 'client-id' }, + }, + pathParameters: { templateId: 'template-123' }, + body: JSON.stringify({ + testPatientNhsNumber: '9000000009', + contactDetailId: 'cd-id', + }), + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 201, + body: JSON.stringify({ statusCode: 201, data: proofRequest }), + }); + + expect(mocks.proofingRequestClient.create).toHaveBeenCalledWith( + 'template-123', + { testPatientNhsNumber: '9000000009', contactDetailId: 'cd-id' }, + { internalUserId: 'user-1234', clientId: 'client-id' } + ); + }); +}); diff --git a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts new file mode 100644 index 0000000000..3795be0e6a --- /dev/null +++ b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts @@ -0,0 +1,401 @@ +import { mock } from 'jest-mock-extended'; +import type { FailureResult } from 'nhs-notify-backend-client/types'; +import type { + ClientConfiguration, + ContactDetail, + ProofRequest, +} from 'nhs-notify-web-template-management-types'; +import type { DatabaseTemplate, User } from 'nhs-notify-web-template-management-utils'; +import { ProofingRequestClient } from '@backend-api/app/proofing-request-client'; +import type { ClientConfigRepository } from '@backend-api/infra/client-config-repository'; +import type { ContactDetailsRepository } from '@backend-api/infra/contact-details-repository'; +import type { ProofRequestRepository } from '@backend-api/infra/proof-request-repository'; +import type { TemplateRepository } from '@backend-api/infra/template-repository'; + +const USER: User = { + internalUserId: 'user-id', + clientId: 'client-id', +}; + +const TEMPLATE_ID = 'template-123'; + +const NHS_NUMBER = '9000000009'; + +const ENABLED_CONFIG: ClientConfiguration = { + features: { + digitalProofingNhsApp: true, + digitalProofingEmail: true, + digitalProofingSms: true, + }, +} as ClientConfiguration; + +const PROOF_REQUEST: ProofRequest = { + id: 'proof-req-id', + templateId: TEMPLATE_ID, + templateType: 'NHS_APP', + contactDetailValue: NHS_NUMBER, + testPatientNhsNumber: NHS_NUMBER, + createdAt: '2024-01-01T00:00:00.000Z', +}; + +const VERIFIED_CONTACT: ContactDetail = { + id: 'contact-id', + type: 'EMAIL', + value: 'test@nhs.net', + status: 'VERIFIED', +}; + +const ERROR: FailureResult = { + error: { + errorMeta: { + code: 500, + description: 'Something went wrong', + }, + }, +}; + +function setup() { + const templateRepository = mock(); + const contactDetailsRepository = mock(); + const proofRequestRepository = mock(); + const clientConfigRepository = mock(); + + const client = new ProofingRequestClient( + templateRepository, + contactDetailsRepository, + proofRequestRepository, + clientConfigRepository + ); + + return { + client, + mocks: { + templateRepository, + contactDetailsRepository, + proofRequestRepository, + clientConfigRepository, + }, + }; +} + +describe('ProofingRequestClient', () => { + beforeEach(jest.resetAllMocks); + + describe('create', () => { + it('returns validation error when payload is invalid', async () => { + const { client, mocks } = setup(); + + const result = await client.create(TEMPLATE_ID, {}, USER); + + expect(result.data).toBeUndefined(); + expect(result.error?.errorMeta.code).toBe(400); + expect(result.error?.errorMeta.description).toBe( + 'Request failed validation' + ); + expect(mocks.templateRepository.get).not.toHaveBeenCalled(); + }); + + it('strips unknown fields from payload', async () => { + const { client, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: { templateType: 'NHS_APP' } as DatabaseTemplate, + }); + mocks.clientConfigRepository.get.mockResolvedValueOnce({ + data: ENABLED_CONFIG, + }); + mocks.proofRequestRepository.put.mockResolvedValueOnce({ + data: PROOF_REQUEST, + }); + + await client.create( + TEMPLATE_ID, + { testPatientNhsNumber: NHS_NUMBER, unknownField: 'value' }, + USER + ); + + expect(mocks.proofRequestRepository.put).toHaveBeenCalledWith( + expect.objectContaining({ testPatientNhsNumber: NHS_NUMBER }), + USER + ); + }); + + it('returns error when template lookup fails', async () => { + const { client, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce(ERROR); + + const result = await client.create( + TEMPLATE_ID, + { testPatientNhsNumber: NHS_NUMBER }, + USER + ); + + expect(result).toBe(ERROR); + expect(mocks.clientConfigRepository.get).not.toHaveBeenCalled(); + }); + + it('returns 400 for LETTER templates', async () => { + const { client, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: { templateType: 'LETTER' } as DatabaseTemplate, + }); + + const result = await client.create( + TEMPLATE_ID, + { testPatientNhsNumber: NHS_NUMBER }, + USER + ); + + expect(result.error?.errorMeta.code).toBe(400); + expect(result.error?.errorMeta.description).toContain('LETTER'); + expect(mocks.clientConfigRepository.get).not.toHaveBeenCalled(); + }); + + it('returns error when client config lookup fails', async () => { + const { client, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: { templateType: 'NHS_APP' } as DatabaseTemplate, + }); + mocks.clientConfigRepository.get.mockResolvedValueOnce(ERROR); + + const result = await client.create( + TEMPLATE_ID, + { testPatientNhsNumber: NHS_NUMBER }, + USER + ); + + expect(result).toBe(ERROR); + }); + + it.each([ + { templateType: 'NHS_APP' as const, flag: 'digitalProofingNhsApp' }, + { templateType: 'EMAIL' as const, flag: 'digitalProofingEmail' }, + { templateType: 'SMS' as const, flag: 'digitalProofingSms' }, + ])( + 'returns 403 when $flag is disabled for $templateType', + async ({ templateType, flag }) => { + const { client, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: { templateType } as DatabaseTemplate, + }); + mocks.clientConfigRepository.get.mockResolvedValueOnce({ + data: { + features: { [flag]: false }, + } as ClientConfiguration, + }); + + const result = await client.create( + TEMPLATE_ID, + { + testPatientNhsNumber: NHS_NUMBER, + contactDetailId: templateType !== 'NHS_APP' ? 'cd-id' : undefined, + }, + USER + ); + + expect(result.error?.errorMeta.code).toBe(403); + expect(result.error?.errorMeta.description).toContain(templateType); + } + ); + + describe('NHS_APP', () => { + function setupNhsApp() { + const s = setup(); + s.mocks.templateRepository.get.mockResolvedValue({ + data: { templateType: 'NHS_APP' } as DatabaseTemplate, + }); + s.mocks.clientConfigRepository.get.mockResolvedValue({ + data: ENABLED_CONFIG, + }); + return s; + } + + it('returns 400 when contactDetailId is provided', async () => { + const { client } = setupNhsApp(); + + const result = await client.create( + TEMPLATE_ID, + { testPatientNhsNumber: NHS_NUMBER, contactDetailId: 'cd-id' }, + USER + ); + + expect(result.error?.errorMeta.code).toBe(400); + expect(result.error?.errorMeta.description).toContain( + 'contactDetailId is not accepted' + ); + }); + + it('persists proof request with contactDetailValue = testPatientNhsNumber', async () => { + const { client, mocks } = setupNhsApp(); + + mocks.proofRequestRepository.put.mockResolvedValueOnce({ + data: PROOF_REQUEST, + }); + + const result = await client.create( + TEMPLATE_ID, + { testPatientNhsNumber: NHS_NUMBER }, + USER + ); + + expect(result).toEqual({ data: PROOF_REQUEST }); + expect(mocks.proofRequestRepository.put).toHaveBeenCalledWith( + { + templateId: TEMPLATE_ID, + templateType: 'NHS_APP', + contactDetailValue: NHS_NUMBER, + testPatientNhsNumber: NHS_NUMBER, + personalisation: undefined, + }, + USER + ); + }); + + it('returns error when repository put fails', async () => { + const { client, mocks } = setupNhsApp(); + + mocks.proofRequestRepository.put.mockResolvedValueOnce(ERROR); + + const result = await client.create( + TEMPLATE_ID, + { testPatientNhsNumber: NHS_NUMBER }, + USER + ); + + expect(result).toBe(ERROR); + }); + }); + + describe('EMAIL / SMS', () => { + function setupEmailSms(templateType: 'EMAIL' | 'SMS') { + const s = setup(); + s.mocks.templateRepository.get.mockResolvedValue({ + data: { templateType } as DatabaseTemplate, + }); + s.mocks.clientConfigRepository.get.mockResolvedValue({ + data: ENABLED_CONFIG, + }); + return s; + } + + it.each(['EMAIL' as const, 'SMS' as const])( + 'returns 400 when contactDetailId is missing for %s', + async (templateType) => { + const { client } = setupEmailSms(templateType); + + const result = await client.create( + TEMPLATE_ID, + { testPatientNhsNumber: NHS_NUMBER }, + USER + ); + + expect(result.error?.errorMeta.code).toBe(400); + expect(result.error?.errorMeta.description).toContain( + 'contactDetailId is required' + ); + } + ); + + it.each(['EMAIL' as const, 'SMS' as const])( + 'returns error when contact detail lookup fails for %s', + async (templateType) => { + const { client, mocks } = setupEmailSms(templateType); + + mocks.contactDetailsRepository.getById.mockResolvedValueOnce(ERROR); + + const result = await client.create( + TEMPLATE_ID, + { testPatientNhsNumber: NHS_NUMBER, contactDetailId: 'cd-id' }, + USER + ); + + expect(result).toBe(ERROR); + } + ); + + it.each(['EMAIL' as const, 'SMS' as const])( + 'returns 400 when contact detail is not verified for %s', + async (templateType) => { + const { client, mocks } = setupEmailSms(templateType); + + mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ + data: { + ...VERIFIED_CONTACT, + status: 'PENDING_VERIFICATION', + }, + }); + + const result = await client.create( + TEMPLATE_ID, + { testPatientNhsNumber: NHS_NUMBER, contactDetailId: 'cd-id' }, + USER + ); + + expect(result.error?.errorMeta.code).toBe(400); + expect(result.error?.errorMeta.description).toContain('not verified'); + } + ); + + it('persists EMAIL proof request with resolved contact value', async () => { + const { client, mocks } = setupEmailSms('EMAIL'); + + mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ + data: VERIFIED_CONTACT, + }); + + const expectedProof: ProofRequest = { + ...PROOF_REQUEST, + templateType: 'EMAIL', + contactDetailValue: 'test@nhs.net', + }; + + mocks.proofRequestRepository.put.mockResolvedValueOnce({ + data: expectedProof, + }); + + const result = await client.create( + TEMPLATE_ID, + { testPatientNhsNumber: NHS_NUMBER, contactDetailId: 'cd-id' }, + USER + ); + + expect(result).toEqual({ data: expectedProof }); + expect(mocks.proofRequestRepository.put).toHaveBeenCalledWith( + { + templateId: TEMPLATE_ID, + templateType: 'EMAIL', + contactDetailValue: 'test@nhs.net', + testPatientNhsNumber: NHS_NUMBER, + personalisation: undefined, + }, + USER + ); + expect(mocks.contactDetailsRepository.getById).toHaveBeenCalledWith( + 'cd-id', + USER + ); + }); + + it('returns error when repository put fails for SMS', async () => { + const { client, mocks } = setupEmailSms('SMS'); + + mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ + data: { ...VERIFIED_CONTACT, type: 'SMS', value: '+447890123456' }, + }); + mocks.proofRequestRepository.put.mockResolvedValueOnce(ERROR); + + const result = await client.create( + TEMPLATE_ID, + { testPatientNhsNumber: NHS_NUMBER, contactDetailId: 'cd-id' }, + USER + ); + + expect(result).toBe(ERROR); + }); + }); + }); +}); diff --git a/lambdas/backend-api/src/api/create-proofing-request.ts b/lambdas/backend-api/src/api/create-proofing-request.ts index 919d61e451..3e732ee37a 100644 --- a/lambdas/backend-api/src/api/create-proofing-request.ts +++ b/lambdas/backend-api/src/api/create-proofing-request.ts @@ -1,129 +1,16 @@ -import type { APIGatewayProxyHandler, APIGatewayProxyResult } from 'aws-lambda'; -import type { - ClientFeatures, - CreateProofingRequest, - ProofRequest, - TemplateType, -} from 'nhs-notify-web-template-management-types'; -import type { User } from 'nhs-notify-web-template-management-utils'; +import type { APIGatewayProxyHandler } from 'aws-lambda'; +import { logger } from 'nhs-notify-web-template-management-utils/logger'; +import type { ProofingRequestClient } from '@backend-api/app/proofing-request-client'; import { apiFailure } from '@backend-api/api/responses'; -import type { ClientConfigRepository } from '@backend-api/infra/client-config-repository'; -import type { ContactDetailsRepository } from '@backend-api/infra/contact-details-repository'; -import type { ProofRequestRepository } from '@backend-api/infra/proof-request-repository'; -import type { TemplateRepository } from '@backend-api/infra/template-repository'; -import type { ApplicationResult } from '@backend-api/utils'; type Dependencies = { - templateRepository: TemplateRepository; - contactDetailsRepository: ContactDetailsRepository; - proofRequestRepository: ProofRequestRepository; - clientConfigRepository: ClientConfigRepository; -}; - -function resultToResponse( - result: ApplicationResult -): APIGatewayProxyResult { - if (result.error) { - return apiFailure( - result.error.errorMeta.code, - result.error.errorMeta.description - ); - } - - return { - statusCode: 201, - body: JSON.stringify({ statusCode: 201, data: result.data }), - }; -} - -async function handleNhsApp( - body: CreateProofingRequest, - templateId: string, - proofRequestRepository: ProofRequestRepository, - user: User -): Promise { - if (body.contactDetailId) { - return apiFailure( - 400, - 'contactDetailId is not accepted for NHS_APP templates' - ); - } - - const result = await proofRequestRepository.put( - { - templateId, - templateType: 'NHS_APP', - contactDetailValue: body.testPatientNhsNumber, - testPatientNhsNumber: body.testPatientNhsNumber, - personalisation: body.personalisation, - }, - user - ); - - return resultToResponse(result); -} - -async function handleEmailOrSms( - body: CreateProofingRequest, - templateId: string, - templateType: 'EMAIL' | 'SMS', - contactDetailsRepository: ContactDetailsRepository, - proofRequestRepository: ProofRequestRepository, - user: User -): Promise { - if (!body.contactDetailId) { - return apiFailure( - 400, - `contactDetailId is required for ${templateType} templates` - ); - } - - const contactDetailResult = await contactDetailsRepository.getById( - body.contactDetailId, - user - ); - - if (contactDetailResult.error) { - return apiFailure( - contactDetailResult.error.errorMeta.code, - contactDetailResult.error.errorMeta.description - ); - } - - if (contactDetailResult.data.status !== 'VERIFIED') { - return apiFailure(400, 'Contact detail is not verified'); - } - - const result = await proofRequestRepository.put( - { - templateId, - templateType, - contactDetailValue: contactDetailResult.data.value, - testPatientNhsNumber: body.testPatientNhsNumber, - personalisation: body.personalisation, - }, - user - ); - - return resultToResponse(result); -} - -const digitalProofingFeatureFlag: Record< - Exclude, - keyof ClientFeatures -> = { - NHS_APP: 'digitalProofingNhsApp', - EMAIL: 'digitalProofingEmail', - SMS: 'digitalProofingSms', + proofingRequestClient: ProofingRequestClient; }; export function createHandler({ - templateRepository, - contactDetailsRepository, - proofRequestRepository, - clientConfigRepository, + proofingRequestClient, }: Dependencies): APIGatewayProxyHandler { - return async function (event) { + return async function handler(event) { const { internalUserId, clientId } = event.requestContext.authorizer ?? {}; if (!internalUserId || !clientId) { @@ -138,76 +25,37 @@ export function createHandler({ return apiFailure(400, 'templateId is required'); } - let body: Partial; + let payload: unknown; try { - body = JSON.parse(event.body ?? '{}'); + payload = JSON.parse(event.body ?? '{}'); } catch { return apiFailure(400, 'Invalid JSON body'); } - if (!body.testPatientNhsNumber) { - return apiFailure(400, 'testPatientNhsNumber is required'); - } - - const validatedBody = body as CreateProofingRequest; - - const templateResult = await templateRepository.get(templateId, clientId); + const log = logger.child(user); - if (templateResult.error) { - return apiFailure( - templateResult.error.errorMeta.code, - templateResult.error.errorMeta.description - ); - } - - const { templateType } = templateResult.data; - - if (templateType === 'LETTER') { - return apiFailure( - 400, - 'Proofing requests are not supported for LETTER templates' - ); - } - - const clientConfigResult = await clientConfigRepository.get(clientId); - - if (clientConfigResult.error) { - return apiFailure( - clientConfigResult.error.errorMeta.code, - clientConfigResult.error.errorMeta.description - ); - } + const { data, error } = await proofingRequestClient.create( + templateId, + payload, + user + ); - const featureFlag = digitalProofingFeatureFlag[templateType]; + if (error) { + log + .child(error.errorMeta) + .error('Failed to create proofing request', error.actualError); - if (!clientConfigResult.data?.features[featureFlag]) { return apiFailure( - 403, - `Digital proofing is not enabled for ${templateType} templates` + error.errorMeta.code, + error.errorMeta.description, + error.errorMeta.details ); } - switch (templateType) { - case 'NHS_APP': { - return handleNhsApp( - validatedBody, - templateId, - proofRequestRepository, - user - ); - } - case 'EMAIL': - case 'SMS': { - return handleEmailOrSms( - validatedBody, - templateId, - templateType, - contactDetailsRepository, - proofRequestRepository, - user - ); - } - } + return { + statusCode: 201, + body: JSON.stringify({ statusCode: 201, data }), + }; }; } diff --git a/lambdas/backend-api/src/app/proofing-request-client.ts b/lambdas/backend-api/src/app/proofing-request-client.ts new file mode 100644 index 0000000000..f0cc41aa6a --- /dev/null +++ b/lambdas/backend-api/src/app/proofing-request-client.ts @@ -0,0 +1,152 @@ +import { $CreateProofingRequest } from 'nhs-notify-backend-client/schemas'; +import type { + ClientFeatures, + CreateProofingRequest, + ProofRequest, + TemplateType, +} from 'nhs-notify-web-template-management-types'; +import type { User } from 'nhs-notify-web-template-management-utils'; +import type { ClientConfigRepository } from '@backend-api/infra/client-config-repository'; +import type { ContactDetailsRepository } from '@backend-api/infra/contact-details-repository'; +import type { ProofRequestRepository } from '@backend-api/infra/proof-request-repository'; +import type { TemplateRepository } from '@backend-api/infra/template-repository'; +import { type ApplicationResult, failure, validate } from '@backend-api/utils'; + +const digitalProofingFeatureFlag: Record< + Exclude, + keyof ClientFeatures +> = { + NHS_APP: 'digitalProofingNhsApp', + EMAIL: 'digitalProofingEmail', + SMS: 'digitalProofingSms', +}; + +export class ProofingRequestClient { + constructor( + private templateRepository: TemplateRepository, + private contactDetailsRepository: ContactDetailsRepository, + private proofRequestRepository: ProofRequestRepository, + private clientConfigRepository: ClientConfigRepository + ) {} + + async create( + templateId: string, + payload: unknown, + user: User + ): Promise> { + const validation = await validate($CreateProofingRequest, payload); + + if (validation.error) { + return validation; + } + + const body = validation.data; + + const templateResult = await this.templateRepository.get( + templateId, + user.clientId + ); + + if (templateResult.error) { + return templateResult; + } + + const { templateType } = templateResult.data; + + if (templateType === 'LETTER') { + return failure( + 400, + 'Proofing requests are not supported for LETTER templates' + ); + } + + const clientConfigResult = await this.clientConfigRepository.get( + user.clientId + ); + + if (clientConfigResult.error) { + return clientConfigResult; + } + + const featureFlag = digitalProofingFeatureFlag[templateType]; + + if (!clientConfigResult.data?.features[featureFlag]) { + return failure( + 403, + `Digital proofing is not enabled for ${templateType} templates` + ); + } + + switch (templateType) { + case 'NHS_APP': { + return this.handleNhsApp(body, templateId, user); + } + case 'EMAIL': + case 'SMS': { + return this.handleEmailOrSms(body, templateId, templateType, user); + } + } + } + + private async handleNhsApp( + body: CreateProofingRequest, + templateId: string, + user: User + ): Promise> { + if (body.contactDetailId) { + return failure( + 400, + 'contactDetailId is not accepted for NHS_APP templates' + ); + } + + return this.proofRequestRepository.put( + { + templateId, + templateType: 'NHS_APP', + contactDetailValue: body.testPatientNhsNumber, + testPatientNhsNumber: body.testPatientNhsNumber, + personalisation: body.personalisation, + }, + user + ); + } + + private async handleEmailOrSms( + body: CreateProofingRequest, + templateId: string, + templateType: 'EMAIL' | 'SMS', + user: User + ): Promise> { + if (!body.contactDetailId) { + return failure( + 400, + `contactDetailId is required for ${templateType} templates` + ); + } + + const contactDetailResult = await this.contactDetailsRepository.getById( + body.contactDetailId, + user + ); + + if (contactDetailResult.error) { + return contactDetailResult; + } + + if (contactDetailResult.data.status !== 'VERIFIED') { + return failure(400, 'Contact detail is not verified'); + } + + return this.proofRequestRepository.put( + { + templateId, + templateType, + contactDetailValue: contactDetailResult.data.value, + testPatientNhsNumber: body.testPatientNhsNumber, + personalisation: body.personalisation, + }, + user + ); + } +} diff --git a/lambdas/backend-api/src/container/proofing-request.ts b/lambdas/backend-api/src/container/proofing-request.ts index bdc2fb7158..340c0eb292 100644 --- a/lambdas/backend-api/src/container/proofing-request.ts +++ b/lambdas/backend-api/src/container/proofing-request.ts @@ -2,6 +2,7 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { SSMClient } from '@aws-sdk/client-ssm'; import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import NodeCache from 'node-cache'; +import { ProofingRequestClient } from '@backend-api/app/proofing-request-client'; import { loadConfig } from '@backend-api/infra/config'; import { ClientConfigRepository } from '@backend-api/infra/client-config-repository'; import { ContactDetailsRepository } from '@backend-api/infra/contact-details-repository'; @@ -41,10 +42,12 @@ export function proofingRequestContainer() { new NodeCache({ stdTTL: config.clientConfigTtlSeconds }) ); - return { + const proofingRequestClient = new ProofingRequestClient( templateRepository, contactDetailsRepository, proofRequestRepository, - clientConfigRepository, - }; + clientConfigRepository + ); + + return { proofingRequestClient }; } diff --git a/lambdas/backend-client/src/__tests__/schemas/proof-request.test.ts b/lambdas/backend-client/src/__tests__/schemas/proof-request.test.ts new file mode 100644 index 0000000000..c2dedbd9b1 --- /dev/null +++ b/lambdas/backend-client/src/__tests__/schemas/proof-request.test.ts @@ -0,0 +1,89 @@ +import { + $CreateProofingRequest, + $ProofRequest, +} from '../../schemas/proof-request'; + +describe('$CreateProofingRequest', () => { + test('passes validation with required fields only', () => { + const input = { testPatientNhsNumber: '9000000009' }; + + const result = $CreateProofingRequest.safeParse(input); + + expect(result.success).toBe(true); + expect(result.data).toEqual(input); + }); + + test('passes validation with all fields', () => { + const input = { + testPatientNhsNumber: '9000000009', + contactDetailId: 'cd-123', + personalisation: { firstName: 'Jane' }, + }; + + const result = $CreateProofingRequest.safeParse(input); + + expect(result.success).toBe(true); + expect(result.data).toEqual(input); + }); + + test('fails validation when testPatientNhsNumber is missing', () => { + const result = $CreateProofingRequest.safeParse({}); + + expect(result.success).toBe(false); + }); + + test('fails validation when testPatientNhsNumber is not a string', () => { + const result = $CreateProofingRequest.safeParse({ + testPatientNhsNumber: 123, + }); + + expect(result.success).toBe(false); + }); +}); + +describe('$ProofRequest', () => { + const validProofRequest = { + id: 'proof-req-id', + templateId: 'template-123', + templateType: 'NHS_APP', + contactDetailValue: '9000000009', + testPatientNhsNumber: '9000000009', + createdAt: '2024-01-01T00:00:00.000Z', + }; + + test('passes validation with required fields', () => { + const result = $ProofRequest.safeParse(validProofRequest); + + expect(result.success).toBe(true); + expect(result.data).toEqual(validProofRequest); + }); + + test('passes validation with personalisation', () => { + const input = { + ...validProofRequest, + personalisation: { firstName: 'Jane' }, + }; + + const result = $ProofRequest.safeParse(input); + + expect(result.success).toBe(true); + expect(result.data).toEqual(input); + }); + + test('fails validation when required field is missing', () => { + const { id: _, ...missing } = validProofRequest; + + const result = $ProofRequest.safeParse(missing); + + expect(result.success).toBe(false); + }); + + test('fails validation for invalid templateType', () => { + const result = $ProofRequest.safeParse({ + ...validProofRequest, + templateType: 'LETTER', + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/lambdas/backend-client/src/schemas/proof-request.ts b/lambdas/backend-client/src/schemas/proof-request.ts index bf84356005..224f983cdb 100644 --- a/lambdas/backend-client/src/schemas/proof-request.ts +++ b/lambdas/backend-client/src/schemas/proof-request.ts @@ -1,5 +1,15 @@ import { z } from 'zod/v4'; -import type { ProofRequest } from 'nhs-notify-web-template-management-types'; +import type { + CreateProofingRequest, + ProofRequest, +} from 'nhs-notify-web-template-management-types'; + +export const $CreateProofingRequest: z.ZodType = + z.object({ + testPatientNhsNumber: z.string(), + contactDetailId: z.string().optional(), + personalisation: z.record(z.string(), z.string()).optional(), + }); export const $ProofRequest: z.ZodType = z.object({ id: z.string(), diff --git a/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts b/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts index d635202575..d098c6cc28 100644 --- a/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts +++ b/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts @@ -699,6 +699,93 @@ describe('proof request events', () => { expect(event).toEqual(expectedProofRequestedEvent()); }); + test('builds proof requested event for EMAIL template', () => { + const valid = publishableProofRequestEventRecord(); + + const emailRecord: PublishableEventRecord = { + ...valid, + dynamodb: { + ...valid.dynamodb, + NewImage: { + ...valid.dynamodb.NewImage!, + templateType: { S: 'EMAIL' }, + contactDetailValue: { S: 'test@nhs.net' }, + }, + }, + }; + + const event = eventBuilder.buildEvent(emailRecord); + + expect(event).toEqual({ + event: expect.objectContaining({ + data: expect.objectContaining({ + templateType: 'EMAIL', + contactDetails: { email: 'test@nhs.net' }, + }), + }), + }); + }); + + test('builds proof requested event for NHS_APP template', () => { + const valid = publishableProofRequestEventRecord(); + + const nhsAppRecord: PublishableEventRecord = { + ...valid, + dynamodb: { + ...valid.dynamodb, + NewImage: { + ...valid.dynamodb.NewImage!, + templateType: { S: 'NHS_APP' }, + }, + }, + }; + + const event = eventBuilder.buildEvent(nhsAppRecord); + + expect(event).toEqual({ + event: expect.objectContaining({ + data: expect.objectContaining({ + templateType: 'NHS_APP', + testPatientNhsNumber: '9000000009', + }), + }), + }); + + expect( + (event as { event: { data: Record } }).event.data + ).not.toHaveProperty('contactDetails'); + }); + + test('errors on unsupported templateType for proof request', () => { + const valid = publishableProofRequestEventRecord(); + + const letterRecord: PublishableEventRecord = { + ...valid, + dynamodb: { + ...valid.dynamodb, + NewImage: { + ...valid.dynamodb.NewImage!, + templateType: { S: 'LETTER' }, + }, + }, + }; + + expect(() => eventBuilder.buildEvent(letterRecord)).toThrow( + 'Unsupported templateType for proof request: LETTER' + ); + }); + + test('errors on output schema validation failure for proof request', () => { + const valid = publishableProofRequestEventRecord(); + + const invalidRecord: PublishableEventRecord = { + ...valid, + eventID: undefined as unknown as string, + }; + + expect(() => eventBuilder.buildEvent(invalidRecord)).toThrow(); + }); + test('errors on input schema validation failure when contactDetailValue is missing', () => { const valid = publishableProofRequestEventRecord(); From e3cc439e296cd4dcc64b94ded981bfb5d4a2f93f Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Tue, 12 May 2026 13:56:46 +0100 Subject: [PATCH 06/28] fmt --- lambdas/backend-api/README.md | 32 +++++++++++++++++++ .../src/domain/event-builder.ts | 12 ++++--- .../create-proofing-request.api.spec.ts | 15 ++------- .../proof-requests.event.spec.ts | 3 ++ 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/lambdas/backend-api/README.md b/lambdas/backend-api/README.md index 1fb5e8f5a2..4707334aa9 100644 --- a/lambdas/backend-api/README.md +++ b/lambdas/backend-api/README.md @@ -301,4 +301,36 @@ curl -X POST --location "${APIG_STAGE}/v1/contact-details" \ --data '{ "type": "SMS", "value": "07890123456" }' ``` +### POST - /v1/template/:templateId/proofing-request - Create a digital proofing request + +Creates a digital proof request for a non-LETTER template. Requires the digital proofing feature flag to be enabled for the template type. For NHS_APP templates, `testPatientNhsNumber` is used as the contact detail value. For EMAIL/SMS templates, a verified `contactDetailId` owned by the caller must be provided. + +**NHS_APP example:** + +```bash +curl -X POST --location "${APIG_STAGE}/v1/template/${TEMPLATE_ID}/proofing-request" \ +--header 'Content-Type: application/json' \ +--header 'Accept: application/json' \ +--header "Authorization: $SANDBOX_TOKEN" \ +--data '{ + "testPatientNhsNumber": "9000000009" +}' +``` + +**EMAIL/SMS example:** + +```bash +curl -X POST --location "${APIG_STAGE}/v1/template/${TEMPLATE_ID}/proofing-request" \ +--header 'Content-Type: application/json' \ +--header 'Accept: application/json' \ +--header "Authorization: $SANDBOX_TOKEN" \ +--data '{ + "testPatientNhsNumber": "9000000009", + "contactDetailId": "", + "personalisation": { + "firstName": "Jane" + } +}' +``` + diff --git a/lambdas/event-publisher/src/domain/event-builder.ts b/lambdas/event-publisher/src/domain/event-builder.ts index b0a0acefac..0b8d6479e3 100644 --- a/lambdas/event-publisher/src/domain/event-builder.ts +++ b/lambdas/event-publisher/src/domain/event-builder.ts @@ -269,19 +269,23 @@ export class EventBuilder extends NHSNotifyEventBuilder { }; switch (record.templateType) { - case 'SMS': + case 'SMS': { return { ...base, contactDetails: { sms: record.contactDetailValue } }; - case 'EMAIL': + } + case 'EMAIL': { return { ...base, contactDetails: { email: record.contactDetailValue }, }; - case 'NHS_APP': + } + case 'NHS_APP': { return base; - default: + } + default: { throw new Error( `Unsupported templateType for proof request: ${record.templateType}` ); + } } } diff --git a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts index 3fbd01fb25..71eb442aed 100644 --- a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts @@ -107,10 +107,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { }, }); - expect(result.data.createdAt).toBeDateRoughlyBetween([ - start, - new Date(), - ]); + expect(result.data.createdAt).toBeDateRoughlyBetween([start, new Date()]); }); test('returns 201 for EMAIL template', async ({ request }) => { @@ -155,10 +152,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { }, }); - expect(result.data.createdAt).toBeDateRoughlyBetween([ - start, - new Date(), - ]); + expect(result.data.createdAt).toBeDateRoughlyBetween([start, new Date()]); }); test('returns 201 for SMS template', async ({ request }) => { @@ -203,10 +197,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { }, }); - expect(result.data.createdAt).toBeDateRoughlyBetween([ - start, - new Date(), - ]); + expect(result.data.createdAt).toBeDateRoughlyBetween([start, new Date()]); }); }); diff --git a/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts b/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts index ab4829a42a..f6dbcfdabb 100644 --- a/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts @@ -45,7 +45,9 @@ test.describe('ProofRequestedEvent', () => { ); expect(createResponse.status()).toBe(201); + const created = await createResponse.json(); + templateStorageHelper.addAdHocTemplateKey({ templateId: created.data.id, clientId: user.clientId, @@ -67,6 +69,7 @@ test.describe('ProofRequestedEvent', () => { const result = await proofingResponse.json(); const debug = JSON.stringify(result, null, 2); + expect(proofingResponse.status(), debug).toBe(201); const proofRequestId = result.data.id; From 12f8824fe9c141676e43bbc10bfea620f029a869 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Tue, 12 May 2026 14:10:51 +0100 Subject: [PATCH 07/28] add nhs number validation --- .../schemas/nhs-number-validation.test.ts | 103 ++++++++++++++++++ .../__tests__/schemas/proof-request.test.ts | 30 ++++- lambdas/backend-client/src/schemas/index.ts | 1 + .../src/schemas/nhs-number-validation.ts | 51 +++++++++ .../src/schemas/proof-request.ts | 10 +- 5 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 lambdas/backend-client/src/__tests__/schemas/nhs-number-validation.test.ts create mode 100644 lambdas/backend-client/src/schemas/nhs-number-validation.ts diff --git a/lambdas/backend-client/src/__tests__/schemas/nhs-number-validation.test.ts b/lambdas/backend-client/src/__tests__/schemas/nhs-number-validation.test.ts new file mode 100644 index 0000000000..43bb0eb759 --- /dev/null +++ b/lambdas/backend-client/src/__tests__/schemas/nhs-number-validation.test.ts @@ -0,0 +1,103 @@ +import { + isValidNHSNumber, + isTestNHSNumber, + isValidTestNHSNumber, +} from '../../schemas/nhs-number-validation'; + +describe('isValidNHSNumber', () => { + describe('valid NHS numbers', () => { + test('accepts valid NHS number 9434765919', () => { + expect(isValidNHSNumber('9434765919')).toBe(true); + }); + + test('accepts valid NHS number with spaces', () => { + expect(isValidNHSNumber('943 476 5919')).toBe(true); + }); + + test('accepts valid NHS number with hyphens', () => { + expect(isValidNHSNumber('943-476-5919')).toBe(true); + }); + + test('accepts valid NHS number 4010232137', () => { + expect(isValidNHSNumber('4010232137')).toBe(true); + }); + + test('accepts valid NHS number with check digit 0', () => { + expect(isValidNHSNumber('9990548609')).toBe(true); + }); + + test('accepts valid NHS number when calculated check digit is 11 and final digit is 0', () => { + expect(isValidNHSNumber('0000000000')).toBe(true); + }); + }); + + describe('invalid NHS numbers', () => { + test('rejects empty string', () => { + expect(isValidNHSNumber('')).toBe(false); + }); + + test('rejects number with fewer than 10 digits', () => { + expect(isValidNHSNumber('123456789')).toBe(false); + }); + + test('rejects number with more than 10 digits', () => { + expect(isValidNHSNumber('12345678901')).toBe(false); + }); + + test('rejects number with invalid check digit', () => { + expect(isValidNHSNumber('9434765918')).toBe(false); + }); + + test('rejects number with check digit that would be 10', () => { + expect(isValidNHSNumber('0000000060')).toBe(false); + }); + + test('rejects number with letters', () => { + expect(isValidNHSNumber('943ABC5919')).toBe(false); + }); + + test('rejects number with special characters', () => { + expect(isValidNHSNumber('943@476!919')).toBe(false); + }); + }); +}); + +describe('isTestNHSNumber', () => { + test('returns true for NHS number starting with 9', () => { + expect(isTestNHSNumber('9434765919')).toBe(true); + }); + + test('returns true for NHS number starting with 9 with spaces', () => { + expect(isTestNHSNumber('943 476 5919')).toBe(true); + }); + + test('returns true for NHS number starting with 9 with hyphens', () => { + expect(isTestNHSNumber('943-476-5919')).toBe(true); + }); + + test('returns false for NHS number not starting with 9', () => { + expect(isTestNHSNumber('4010232137')).toBe(false); + }); + + test('returns false for empty string', () => { + expect(isTestNHSNumber('')).toBe(false); + }); +}); + +describe('isValidTestNHSNumber', () => { + test('returns true for valid test NHS number', () => { + expect(isValidTestNHSNumber('9434765919')).toBe(true); + }); + + test('returns false for valid non-test NHS number', () => { + expect(isValidTestNHSNumber('4010232137')).toBe(false); + }); + + test('returns false for invalid NHS number starting with 9', () => { + expect(isValidTestNHSNumber('9434765918')).toBe(false); + }); + + test('returns false for empty string', () => { + expect(isValidTestNHSNumber('')).toBe(false); + }); +}); diff --git a/lambdas/backend-client/src/__tests__/schemas/proof-request.test.ts b/lambdas/backend-client/src/__tests__/schemas/proof-request.test.ts index c2dedbd9b1..617e3b17a9 100644 --- a/lambdas/backend-client/src/__tests__/schemas/proof-request.test.ts +++ b/lambdas/backend-client/src/__tests__/schemas/proof-request.test.ts @@ -15,7 +15,7 @@ describe('$CreateProofingRequest', () => { test('passes validation with all fields', () => { const input = { - testPatientNhsNumber: '9000000009', + testPatientNhsNumber: '9434765919', contactDetailId: 'cd-123', personalisation: { firstName: 'Jane' }, }; @@ -39,6 +39,34 @@ describe('$CreateProofingRequest', () => { expect(result.success).toBe(false); }); + + test('fails validation for invalid NHS number (bad check digit)', () => { + const result = $CreateProofingRequest.safeParse({ + testPatientNhsNumber: '9434765918', + }); + + expect(result.success).toBe(false); + expect(result.error?.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: 'Invalid NHS number' }), + ]) + ); + }); + + test('fails validation for non-test NHS number (does not start with 9)', () => { + const result = $CreateProofingRequest.safeParse({ + testPatientNhsNumber: '4010232137', + }); + + expect(result.success).toBe(false); + expect(result.error?.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: 'NHS number must be a test number (starting with 9)', + }), + ]) + ); + }); }); describe('$ProofRequest', () => { diff --git a/lambdas/backend-client/src/schemas/index.ts b/lambdas/backend-client/src/schemas/index.ts index 1924d1517e..c7ebaf4ff9 100644 --- a/lambdas/backend-client/src/schemas/index.ts +++ b/lambdas/backend-client/src/schemas/index.ts @@ -2,6 +2,7 @@ export * from './client'; export * from './constants'; export * from './contact-details'; export * from './letter-variant'; +export * from './nhs-number-validation'; export * from './proof-request'; export * from './render-request'; export * from './routing-config'; diff --git a/lambdas/backend-client/src/schemas/nhs-number-validation.ts b/lambdas/backend-client/src/schemas/nhs-number-validation.ts new file mode 100644 index 0000000000..cb3a6f67a0 --- /dev/null +++ b/lambdas/backend-client/src/schemas/nhs-number-validation.ts @@ -0,0 +1,51 @@ +/** + * Validates an NHS Number using the Modulus 11 algorithm. + * NHS numbers are 10 digits long, with the 10th digit being a check digit. + * + * @see https://www.datadictionary.nhs.uk/attributes/nhs_number.html + */ +export function isValidNHSNumber(nhsNumber: string): boolean { + const cleaned = nhsNumber.replaceAll(/[\s-]/gu, ''); + + if (!/^\d{10}$/.test(cleaned)) { + return false; + } + + const digits = [...cleaned].map(Number); + const checkDigit = digits[9]; + + let sum = 0; + for (let i = 0; i < 9; i++) { + sum += digits[i] * (10 - i); + } + + const remainder = sum % 11; + const calculatedCheckDigit = 11 - remainder; + + if (calculatedCheckDigit === 11) { + return checkDigit === 0; + } + + if (calculatedCheckDigit === 10) { + return false; + } + + return checkDigit === calculatedCheckDigit; +} + +/** + * Checks if an NHS Number is a test number. + * Test NHS numbers start with 9. + */ +export function isTestNHSNumber(nhsNumber: string): boolean { + const cleaned = nhsNumber.replaceAll(/[\s-]/gu, ''); + return cleaned.startsWith('9'); +} + +/** + * Validates that an NHS Number is both structurally valid (Modulus 11) + * and a test number (starts with 9). + */ +export function isValidTestNHSNumber(nhsNumber: string): boolean { + return isValidNHSNumber(nhsNumber) && isTestNHSNumber(nhsNumber); +} diff --git a/lambdas/backend-client/src/schemas/proof-request.ts b/lambdas/backend-client/src/schemas/proof-request.ts index 224f983cdb..0cd9776486 100644 --- a/lambdas/backend-client/src/schemas/proof-request.ts +++ b/lambdas/backend-client/src/schemas/proof-request.ts @@ -3,10 +3,18 @@ import type { CreateProofingRequest, ProofRequest, } from 'nhs-notify-web-template-management-types'; +import { isValidNHSNumber, isTestNHSNumber } from './nhs-number-validation'; export const $CreateProofingRequest: z.ZodType = z.object({ - testPatientNhsNumber: z.string(), + testPatientNhsNumber: z.string().check( + z.refine((val) => isValidNHSNumber(val), { + message: 'Invalid NHS number', + }), + z.refine((val) => isTestNHSNumber(val), { + message: 'NHS number must be a test number (starting with 9)', + }) + ), contactDetailId: z.string().optional(), personalisation: z.record(z.string(), z.string()).optional(), }); From eeb31b02abcd0352b6a9ab9eb0fbd3b6365f76a7 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Tue, 12 May 2026 16:49:13 +0100 Subject: [PATCH 08/28] add personalisation to tests --- .../api/create-proofing-request.test.ts | 24 --- .../app/proofing-request-client.test.ts | 2 - .../src/api/create-proofing-request.ts | 8 +- .../src/app/proofing-request-client.ts | 65 +++---- .../src/infra/proof-request-repository.ts | 1 - .../create-proofing-request.api.spec.ts | 174 ++++++++++++++++-- ...s => request-pdf-letter-proof.api.spec.ts} | 1 + .../proof-requests.event.spec.ts | 12 ++ 8 files changed, 211 insertions(+), 76 deletions(-) rename tests/test-team/template-mgmt-api-tests/{request-proof.api.spec.ts => request-pdf-letter-proof.api.spec.ts} (99%) diff --git a/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts b/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts index 52f95c54da..8fe8fa772d 100644 --- a/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts +++ b/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts @@ -67,30 +67,6 @@ describe('Create Proofing Request Handler', () => { expect(mocks.proofingRequestClient.create).not.toHaveBeenCalled(); }); - test('should return 400 when body is invalid JSON', async () => { - const { handler, mocks } = setup(); - - const event = mock({ - requestContext: { - authorizer: { internalUserId: 'user-1234', clientId: 'client-id' }, - }, - pathParameters: { templateId: 'template-123' }, - body: '{invalid-json', - }); - - const result = await handler(event, mock(), jest.fn()); - - expect(result).toEqual({ - statusCode: 400, - body: JSON.stringify({ - statusCode: 400, - technicalMessage: 'Invalid JSON body', - }), - }); - - expect(mocks.proofingRequestClient.create).not.toHaveBeenCalled(); - }); - test('should return error when client returns error', async () => { const { handler, mocks } = setup(); diff --git a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts index 3795be0e6a..c31e342400 100644 --- a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts @@ -132,7 +132,6 @@ describe('ProofingRequestClient', () => { ); expect(result).toBe(ERROR); - expect(mocks.clientConfigRepository.get).not.toHaveBeenCalled(); }); it('returns 400 for LETTER templates', async () => { @@ -150,7 +149,6 @@ describe('ProofingRequestClient', () => { expect(result.error?.errorMeta.code).toBe(400); expect(result.error?.errorMeta.description).toContain('LETTER'); - expect(mocks.clientConfigRepository.get).not.toHaveBeenCalled(); }); it('returns error when client config lookup fails', async () => { diff --git a/lambdas/backend-api/src/api/create-proofing-request.ts b/lambdas/backend-api/src/api/create-proofing-request.ts index 3e732ee37a..2ad2a01763 100644 --- a/lambdas/backend-api/src/api/create-proofing-request.ts +++ b/lambdas/backend-api/src/api/create-proofing-request.ts @@ -25,13 +25,7 @@ export function createHandler({ return apiFailure(400, 'templateId is required'); } - let payload: unknown; - - try { - payload = JSON.parse(event.body ?? '{}'); - } catch { - return apiFailure(400, 'Invalid JSON body'); - } + const payload = JSON.parse(event.body ?? '{}'); const log = logger.child(user); diff --git a/lambdas/backend-api/src/app/proofing-request-client.ts b/lambdas/backend-api/src/app/proofing-request-client.ts index f0cc41aa6a..57a6e6950e 100644 --- a/lambdas/backend-api/src/app/proofing-request-client.ts +++ b/lambdas/backend-api/src/app/proofing-request-client.ts @@ -42,10 +42,10 @@ export class ProofingRequestClient { const body = validation.data; - const templateResult = await this.templateRepository.get( - templateId, - user.clientId - ); + const [templateResult, clientConfigResult] = await Promise.all([ + this.templateRepository.get(templateId, user.clientId), + this.clientConfigRepository.get(user.clientId), + ]); if (templateResult.error) { return templateResult; @@ -56,47 +56,46 @@ export class ProofingRequestClient { if (templateType === 'LETTER') { return failure( 400, - 'Proofing requests are not supported for LETTER templates' + 'Proofing requests are not supported for letter templates' ); } - const clientConfigResult = await this.clientConfigRepository.get( - user.clientId - ); - if (clientConfigResult.error) { return clientConfigResult; } - const featureFlag = digitalProofingFeatureFlag[templateType]; - - if (!clientConfigResult.data?.features[featureFlag]) { - return failure( - 403, - `Digital proofing is not enabled for ${templateType} templates` - ); + if ( + !clientConfigResult.data?.features[ + digitalProofingFeatureFlag[templateType] + ] + ) { + return failure(403, 'Digital proofing is not enabled'); } switch (templateType) { case 'NHS_APP': { - return this.handleNhsApp(body, templateId, user); + return this.handleApp(body, templateId, user); } case 'EMAIL': case 'SMS': { - return this.handleEmailOrSms(body, templateId, templateType, user); + return this.handleEmailSms(body, templateId, templateType, user); } } } - private async handleNhsApp( - body: CreateProofingRequest, + private async handleApp( + { + contactDetailId, + personalisation, + testPatientNhsNumber, + }: CreateProofingRequest, templateId: string, user: User ): Promise> { - if (body.contactDetailId) { + if (contactDetailId) { return failure( 400, - 'contactDetailId is not accepted for NHS_APP templates' + 'contactDetailId is not accepted for NHS app templates' ); } @@ -104,21 +103,25 @@ export class ProofingRequestClient { { templateId, templateType: 'NHS_APP', - contactDetailValue: body.testPatientNhsNumber, - testPatientNhsNumber: body.testPatientNhsNumber, - personalisation: body.personalisation, + contactDetailValue: testPatientNhsNumber, + testPatientNhsNumber, + personalisation, }, user ); } - private async handleEmailOrSms( - body: CreateProofingRequest, + private async handleEmailSms( + { + contactDetailId, + personalisation, + testPatientNhsNumber, + }: CreateProofingRequest, templateId: string, templateType: 'EMAIL' | 'SMS', user: User ): Promise> { - if (!body.contactDetailId) { + if (!contactDetailId) { return failure( 400, `contactDetailId is required for ${templateType} templates` @@ -126,7 +129,7 @@ export class ProofingRequestClient { } const contactDetailResult = await this.contactDetailsRepository.getById( - body.contactDetailId, + contactDetailId, user ); @@ -143,8 +146,8 @@ export class ProofingRequestClient { templateId, templateType, contactDetailValue: contactDetailResult.data.value, - testPatientNhsNumber: body.testPatientNhsNumber, - personalisation: body.personalisation, + testPatientNhsNumber, + personalisation, }, user ); diff --git a/lambdas/backend-api/src/infra/proof-request-repository.ts b/lambdas/backend-api/src/infra/proof-request-repository.ts index c7428c19c5..d37b467843 100644 --- a/lambdas/backend-api/src/infra/proof-request-repository.ts +++ b/lambdas/backend-api/src/infra/proof-request-repository.ts @@ -31,7 +31,6 @@ export class ProofRequestRepository { Item: { ...record, owner: `INTERNAL_USER#${user.internalUserId}`, - createdBy: `INTERNAL_USER#${user.internalUserId}`, }, }) ); diff --git a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts index 71eb442aed..41c0c087e0 100644 --- a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, APIRequestContext } from '@playwright/test'; import { type TestUser, testUsers } from '../helpers/auth/cognito-auth-helper'; import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; import { ContactDetailHelper } from '../helpers/db/contact-details-helper'; @@ -46,15 +46,15 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { }); const createTemplate = async ( - request: Parameters[2]>[0]['request'], + request: APIRequestContext, templateType: 'NHS_APP' | 'EMAIL' | 'SMS', - asUser: TestUser = user + testUser: TestUser = user ) => { const createResponse = await request.post( `${process.env.API_BASE_URL}/v1/template`, { headers: { - Authorization: await asUser.getAccessToken(), + Authorization: await testUser.getAccessToken(), }, data: TemplateAPIPayloadFactory.getCreateTemplatePayload({ templateType, @@ -64,9 +64,10 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { expect(createResponse.status()).toBe(201); const created = await createResponse.json(); + templateStorageHelper.addAdHocTemplateKey({ templateId: created.data.id, - clientId: asUser.clientId, + clientId: testUser.clientId, }); return created.data; @@ -199,6 +200,154 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { expect(result.data.createdAt).toBeDateRoughlyBetween([start, new Date()]); }); + + test('returns 201 for NHS_APP template with personalisation', async ({ + request, + }) => { + const template = await createTemplate(request, 'NHS_APP'); + + const personalisation = { + firstName: 'Jane', + appointmentDate: '2025-01-15', + }; + + const start = new Date(); + + const proofingResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + personalisation, + }, + } + ); + + const result = await proofingResponse.json(); + const debug = JSON.stringify(result, null, 2); + + expect(proofingResponse.status(), debug).toBe(201); + + expect(result).toEqual({ + statusCode: 201, + data: { + id: expect.stringMatching(uuidRegExp), + templateId: template.id, + templateType: 'NHS_APP', + contactDetailValue: '9000000009', + testPatientNhsNumber: '9000000009', + personalisation, + createdAt: expect.stringMatching(isoDateRegExp), + }, + }); + + expect(result.data.createdAt).toBeDateRoughlyBetween([start, new Date()]); + }); + + test('returns 201 for EMAIL template with personalisation', async ({ + request, + }) => { + const template = await createTemplate(request, 'EMAIL'); + + const contactDetail = makeVerifiedContactDetail({ + type: 'EMAIL', + value: 'personalisation@example.com', + owner: user.internalUserId, + }); + await contactDetailHelper.seed([contactDetail]); + + const personalisation = { subject: 'Appointment reminder' }; + + const start = new Date(); + + const proofingResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + contactDetailId: contactDetail.id, + personalisation, + }, + } + ); + + const result = await proofingResponse.json(); + const debug = JSON.stringify(result, null, 2); + + expect(proofingResponse.status(), debug).toBe(201); + + expect(result).toEqual({ + statusCode: 201, + data: { + id: expect.stringMatching(uuidRegExp), + templateId: template.id, + templateType: 'EMAIL', + contactDetailValue: 'personalisation@example.com', + testPatientNhsNumber: '9000000009', + personalisation, + createdAt: expect.stringMatching(isoDateRegExp), + }, + }); + + expect(result.data.createdAt).toBeDateRoughlyBetween([start, new Date()]); + }); + + test('returns 201 for SMS template with personalisation', async ({ + request, + }) => { + const template = await createTemplate(request, 'SMS'); + + const contactDetail = makeVerifiedContactDetail({ + type: 'SMS', + value: '+447700900001', + owner: user.internalUserId, + }); + await contactDetailHelper.seed([contactDetail]); + + const personalisation = { clinicName: 'GP Surgery' }; + + const start = new Date(); + + const proofingResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + contactDetailId: contactDetail.id, + personalisation, + }, + } + ); + + const result = await proofingResponse.json(); + const debug = JSON.stringify(result, null, 2); + + expect(proofingResponse.status(), debug).toBe(201); + + expect(result).toEqual({ + statusCode: 201, + data: { + id: expect.stringMatching(uuidRegExp), + templateId: template.id, + templateType: 'SMS', + contactDetailValue: '+447700900001', + testPatientNhsNumber: '9000000009', + personalisation, + createdAt: expect.stringMatching(isoDateRegExp), + }, + }); + + expect(result.data.createdAt).toBeDateRoughlyBetween([start, new Date()]); + }); }); test.describe('auth', () => { @@ -241,7 +390,11 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { expect(response.status(), debug).toBe(400); expect(result).toEqual({ statusCode: 400, - technicalMessage: 'testPatientNhsNumber is required', + technicalMessage: 'Request failed validation', + details: { + testPatientNhsNumber: + 'Invalid input: expected string, received undefined', + }, }); }); @@ -270,7 +423,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { expect(result).toEqual({ statusCode: 400, technicalMessage: - 'contactDetailId is not accepted for NHS_APP templates', + 'contactDetailId is not accepted for NHS app templates', }); }); @@ -329,7 +482,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { }); }); - test.describe('template', () => { + test.describe('template related error responses', () => { test('returns 404 if template does not exist', async ({ request }) => { const response = await request.post( `${process.env.API_BASE_URL}/v1/template/noexist/proofing-request`, @@ -408,7 +561,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { expect(result).toEqual({ statusCode: 400, technicalMessage: - 'Proofing requests are not supported for LETTER templates', + 'Proofing requests are not supported for letter templates', }); }); }); @@ -454,8 +607,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { expect(response.status(), debug).toBe(403); expect(result).toEqual({ statusCode: 403, - technicalMessage: - 'Digital proofing is not enabled for NHS_APP templates', + technicalMessage: 'Digital proofing is not enabled', }); }); }); diff --git a/tests/test-team/template-mgmt-api-tests/request-proof.api.spec.ts b/tests/test-team/template-mgmt-api-tests/request-pdf-letter-proof.api.spec.ts similarity index 99% rename from tests/test-team/template-mgmt-api-tests/request-proof.api.spec.ts rename to tests/test-team/template-mgmt-api-tests/request-pdf-letter-proof.api.spec.ts index 3e72291c70..43368c2679 100644 --- a/tests/test-team/template-mgmt-api-tests/request-proof.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/request-pdf-letter-proof.api.spec.ts @@ -5,6 +5,7 @@ import { TemplateFactory } from '../helpers/factories/template-factory'; import { randomUUID } from 'node:crypto'; import { getTestContext } from 'helpers/context/context'; +// deprecated letter proof endpoint, not to be confused with 'POST /v1/template/:templateId/proofing-request' test.describe('POST /v1/template/:templateId/proof', () => { const context = getTestContext(); const templateStorageHelper = new TemplateStorageHelper(); diff --git a/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts b/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts index f6dbcfdabb..cad3e5a97f 100644 --- a/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts @@ -116,6 +116,7 @@ test.describe('ProofRequestedEvent', () => { expect(createResponse.status()).toBe(201); const created = await createResponse.json(); + templateStorageHelper.addAdHocTemplateKey({ templateId: created.data.id, clientId: user.clientId, @@ -128,6 +129,8 @@ test.describe('ProofRequestedEvent', () => { }); await contactDetailHelper.seed([contactDetail]); + const personalisation = { clinicName: 'GP Surgery' }; + const start = new Date(); const proofingResponse = await request.post( @@ -139,6 +142,7 @@ test.describe('ProofRequestedEvent', () => { data: { testPatientNhsNumber: '9000000009', contactDetailId: contactDetail.id, + personalisation, }, } ); @@ -169,6 +173,7 @@ test.describe('ProofRequestedEvent', () => { contactDetails: { sms: '+447700900000', }, + personalisation, }), }), }) @@ -206,6 +211,11 @@ test.describe('ProofRequestedEvent', () => { }); await contactDetailHelper.seed([contactDetail]); + const personalisation = { + firstName: 'Jane', + appointmentDate: '2025-01-15', + }; + const start = new Date(); const proofingResponse = await request.post( @@ -217,6 +227,7 @@ test.describe('ProofRequestedEvent', () => { data: { testPatientNhsNumber: '9000000009', contactDetailId: contactDetail.id, + personalisation, }, } ); @@ -247,6 +258,7 @@ test.describe('ProofRequestedEvent', () => { contactDetails: { email: 'test@example.com', }, + personalisation, }), }), }) From 9f7e4440113029f8401738dd29c0be14432858e7 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Tue, 12 May 2026 17:53:22 +0100 Subject: [PATCH 09/28] coverage --- .../api/create-proofing-request.test.ts | 2 +- .../app/proofing-request-client.test.ts | 15 ++- .../infra/contact-details-repository.test.ts | 81 +++++++++++++- .../infra/proof-request-repository.test.ts | 101 ++++++++++++++++++ .../schemas/contact-details/index.test.ts | 34 +++++- 5 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts diff --git a/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts b/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts index 8fe8fa772d..0805187e47 100644 --- a/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts +++ b/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts @@ -124,7 +124,7 @@ describe('Create Proofing Request Handler', () => { authorizer: { internalUserId: 'user-1234', clientId: 'client-id' }, }, pathParameters: { templateId: 'template-123' }, - body: JSON.stringify({}), + body: undefined, }); const result = await handler(event, mock(), jest.fn()); diff --git a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts index c31e342400..c17d5d11a4 100644 --- a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts @@ -5,7 +5,10 @@ import type { ContactDetail, ProofRequest, } from 'nhs-notify-web-template-management-types'; -import type { DatabaseTemplate, User } from 'nhs-notify-web-template-management-utils'; +import type { + DatabaseTemplate, + User, +} from 'nhs-notify-web-template-management-utils'; import { ProofingRequestClient } from '@backend-api/app/proofing-request-client'; import type { ClientConfigRepository } from '@backend-api/infra/client-config-repository'; import type { ContactDetailsRepository } from '@backend-api/infra/contact-details-repository'; @@ -148,7 +151,9 @@ describe('ProofingRequestClient', () => { ); expect(result.error?.errorMeta.code).toBe(400); - expect(result.error?.errorMeta.description).toContain('LETTER'); + expect(result.error?.errorMeta.description).toBe( + 'Proofing requests are not supported for letter templates' + ); }); it('returns error when client config lookup fails', async () => { @@ -190,13 +195,15 @@ describe('ProofingRequestClient', () => { TEMPLATE_ID, { testPatientNhsNumber: NHS_NUMBER, - contactDetailId: templateType !== 'NHS_APP' ? 'cd-id' : undefined, + contactDetailId: templateType === 'NHS_APP' ? undefined : 'cd-id', }, USER ); expect(result.error?.errorMeta.code).toBe(403); - expect(result.error?.errorMeta.description).toContain(templateType); + expect(result.error?.errorMeta.description).toBe( + 'Digital proofing is not enabled' + ); } ); diff --git a/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts b/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts index e06b126b04..b9e3134f34 100644 --- a/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts @@ -1,7 +1,11 @@ import crypto from 'node:crypto'; import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm'; -import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { + DynamoDBDocumentClient, + PutCommand, + QueryCommand, +} from '@aws-sdk/lib-dynamodb'; import { mockClient } from 'aws-sdk-client-mock'; import 'aws-sdk-client-mock-jest'; import type { ContactDetailInputNormalized } from 'nhs-notify-web-template-management-types'; @@ -280,4 +284,79 @@ describe('ContactDetailsRepository', () => { ); }); }); + + describe('getById', () => { + it('returns contact detail when found', async () => { + const { repo, mocks } = setup(); + + const contactDetail = { + id: 'contact-id', + type: 'EMAIL', + value: 'test@nhs.net', + status: 'VERIFIED', + }; + + mocks.dynamodb.on(QueryCommand).resolvesOnce({ + Items: [contactDetail], + }); + + const result = await repo.getById('contact-id', USER); + + expect(result.data).toEqual(contactDetail); + expect(mocks.dynamodb).toHaveReceivedCommandWith(QueryCommand, { + TableName: TABLE_NAME, + IndexName: 'ById', + KeyConditionExpression: '#id = :id AND #owner = :owner', + ExpressionAttributeNames: { + '#id': 'id', + '#owner': 'owner', + }, + ExpressionAttributeValues: { + ':id': 'contact-id', + ':owner': `INTERNAL_USER#${USER.internalUserId}`, + }, + }); + }); + + it('returns not found when no items returned', async () => { + const { repo, mocks } = setup(); + + mocks.dynamodb.on(QueryCommand).resolvesOnce({ + Items: [], + }); + + const result = await repo.getById('missing-id', USER); + + expect(result.data).toBeUndefined(); + expect(result.error?.errorMeta.code).toBe(404); + expect(result.error?.errorMeta.description).toBe( + 'Contact detail not found' + ); + }); + + it('returns not found when Items is undefined', async () => { + const { repo, mocks } = setup(); + + mocks.dynamodb.on(QueryCommand).resolvesOnce({}); + + const result = await repo.getById('missing-id', USER); + + expect(result.data).toBeUndefined(); + expect(result.error?.errorMeta.code).toBe(404); + }); + + it('returns internal error when query fails', async () => { + const { repo, mocks } = setup(); + + mocks.dynamodb.on(QueryCommand).rejects(new Error('DynamoDB error')); + + const result = await repo.getById('contact-id', USER); + + expect(result.data).toBeUndefined(); + expect(result.error?.errorMeta.code).toBe(500); + expect(result.error?.errorMeta.description).toBe( + 'Failed to get contact detail' + ); + }); + }); }); diff --git a/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts b/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts new file mode 100644 index 0000000000..728e15d71f --- /dev/null +++ b/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts @@ -0,0 +1,101 @@ +import crypto from 'node:crypto'; +import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import type { User } from 'nhs-notify-web-template-management-utils'; +import { ProofRequestRepository } from '@backend-api/infra/proof-request-repository'; + +const TABLE_NAME = 'proof-requests-table'; +const RANDOM_UUID = 'test-uuid-1234-5678-9abc-def012345678'; +const NOW = new Date('2026-01-01T09:00:00.000Z'); + +const USER: User = { + internalUserId: 'user-id', + clientId: 'client-id', +}; + +const PARAMS = { + templateId: 'template-123', + templateType: 'NHS_APP' as const, + contactDetailValue: '9000000009', + testPatientNhsNumber: '9000000009', +}; + +beforeEach(() => { + jest.useFakeTimers({ now: NOW }); + jest.spyOn(crypto, 'randomUUID').mockReturnValue(RANDOM_UUID); +}); + +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +function setup() { + const dynamodb = mockClient(DynamoDBDocumentClient); + + const repo = new ProofRequestRepository( + dynamodb as unknown as DynamoDBDocumentClient, + TABLE_NAME + ); + + return { repo, mocks: { dynamodb } }; +} + +describe('ProofRequestRepository', () => { + describe('put', () => { + it('persists proof request and returns parsed record', async () => { + const { repo, mocks } = setup(); + + mocks.dynamodb.on(PutCommand).resolvesOnce({}); + + const result = await repo.put(PARAMS, USER); + + expect(result.data).toEqual({ + ...PARAMS, + id: RANDOM_UUID, + createdAt: NOW.toISOString(), + }); + + expect(mocks.dynamodb).toHaveReceivedCommandWith(PutCommand, { + TableName: TABLE_NAME, + Item: { + ...PARAMS, + id: RANDOM_UUID, + createdAt: NOW.toISOString(), + owner: `INTERNAL_USER#${USER.internalUserId}`, + }, + }); + }); + + it('persists proof request with personalisation', async () => { + const { repo, mocks } = setup(); + + mocks.dynamodb.on(PutCommand).resolvesOnce({}); + + const params = { ...PARAMS, personalisation: { firstName: 'Jane' } }; + + const result = await repo.put(params, USER); + + expect(result.data).toEqual({ + ...params, + id: RANDOM_UUID, + createdAt: NOW.toISOString(), + }); + }); + + it('returns internal error when DynamoDB write fails', async () => { + const { repo, mocks } = setup(); + + mocks.dynamodb.on(PutCommand).rejects(new Error('DynamoDB error')); + + const result = await repo.put(PARAMS, USER); + + expect(result.data).toBeUndefined(); + expect(result.error?.errorMeta.code).toBe(500); + expect(result.error?.errorMeta.description).toBe( + 'Failed to create proof request' + ); + }); + }); +}); diff --git a/lambdas/backend-client/src/__tests__/schemas/contact-details/index.test.ts b/lambdas/backend-client/src/__tests__/schemas/contact-details/index.test.ts index 0eef1c0ca2..a79a8c57da 100644 --- a/lambdas/backend-client/src/__tests__/schemas/contact-details/index.test.ts +++ b/lambdas/backend-client/src/__tests__/schemas/contact-details/index.test.ts @@ -1,4 +1,7 @@ -import { $ContactDetailInputNormalized } from '../../../schemas'; +import { + $ContactDetail, + $ContactDetailInputNormalized, +} from '../../../schemas'; describe('$ContactDetailInputNormalized', () => { describe('email', () => { @@ -64,3 +67,32 @@ describe('$ContactDetailInputNormalized', () => { expect(result.error).toMatchSnapshot(); }); }); + +describe('$ContactDetail', () => { + test('parses a valid contact detail', () => { + expect( + $ContactDetail.parse({ + id: 'contact-1', + status: 'VERIFIED', + type: 'EMAIL', + value: 'test@nhs.net', + }) + ).toEqual({ + id: 'contact-1', + status: 'VERIFIED', + type: 'EMAIL', + value: 'test@nhs.net', + }); + }); + + test('rejects an invalid status', () => { + const result = $ContactDetail.safeParse({ + id: 'contact-1', + status: 'INVALID', + type: 'SMS', + value: '+447700900000', + }); + + expect(result.success).toBe(false); + }); +}); From 09bd5af29e252bf0b24a7f1557df111785f4a7d1 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Wed, 13 May 2026 10:01:40 +0100 Subject: [PATCH 10/28] cleanup --- .../modules/backend-api/spec.tmpl.json | 2 +- lambdas/backend-api/README.md | 7 +- .../app/proofing-request-client.test.ts | 430 +++++++++++------- .../src/__tests__/fixtures/template.ts | 55 ++- .../infra/contact-details-repository.test.ts | 5 +- .../infra/proof-request-repository.test.ts | 39 +- .../infra/template-repository/query.test.ts | 40 +- .../template-repository/repository.test.ts | 36 +- .../src/app/proofing-request-client.ts | 18 +- .../schemas/contact-details/index.test.ts | 2 +- .../schemas/contact-details/contact-detail.ts | 9 - .../src/schemas/contact-details/index.ts | 12 +- .../__tests__/domain/event-builder.test.ts | 107 ++--- .../src/domain/event-builder.ts | 5 - .../src/domain/input-schemas.ts | 2 +- .../proof-requests.event.spec.ts | 12 +- 16 files changed, 479 insertions(+), 302 deletions(-) delete mode 100644 lambdas/backend-client/src/schemas/contact-details/contact-detail.ts diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index 74515434eb..e0dd01357a 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -3044,7 +3044,7 @@ }, "/v1/template/{templateId}/proofing-request": { "post": { - "description": "Create a proofing request for a template", + "description": "Create a proofing request for a digital-channel template", "parameters": [ { "description": "ID of the template to create a proofing request for", diff --git a/lambdas/backend-api/README.md b/lambdas/backend-api/README.md index 4707334aa9..e3185c0ea3 100644 --- a/lambdas/backend-api/README.md +++ b/lambdas/backend-api/README.md @@ -303,7 +303,7 @@ curl -X POST --location "${APIG_STAGE}/v1/contact-details" \ ### POST - /v1/template/:templateId/proofing-request - Create a digital proofing request -Creates a digital proof request for a non-LETTER template. Requires the digital proofing feature flag to be enabled for the template type. For NHS_APP templates, `testPatientNhsNumber` is used as the contact detail value. For EMAIL/SMS templates, a verified `contactDetailId` owned by the caller must be provided. +Creates a digital proof request for a non-LETTER template **NHS_APP example:** @@ -314,6 +314,9 @@ curl -X POST --location "${APIG_STAGE}/v1/template/${TEMPLATE_ID}/proofing-reque --header "Authorization: $SANDBOX_TOKEN" \ --data '{ "testPatientNhsNumber": "9000000009" + "personalisation": { + "firstName": "Ignatius" + } }' ``` @@ -328,7 +331,7 @@ curl -X POST --location "${APIG_STAGE}/v1/template/${TEMPLATE_ID}/proofing-reque "testPatientNhsNumber": "9000000009", "contactDetailId": "", "personalisation": { - "firstName": "Jane" + "firstName": "Hieronymus" } }' ``` diff --git a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts index c17d5d11a4..be73dcf300 100644 --- a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts @@ -1,61 +1,59 @@ import { mock } from 'jest-mock-extended'; -import type { FailureResult } from 'nhs-notify-backend-client/types'; import type { ClientConfiguration, ContactDetail, ProofRequest, } from 'nhs-notify-web-template-management-types'; -import type { - DatabaseTemplate, - User, -} from 'nhs-notify-web-template-management-utils'; +import type { User } from 'nhs-notify-web-template-management-utils'; import { ProofingRequestClient } from '@backend-api/app/proofing-request-client'; import type { ClientConfigRepository } from '@backend-api/infra/client-config-repository'; import type { ContactDetailsRepository } from '@backend-api/infra/contact-details-repository'; import type { ProofRequestRepository } from '@backend-api/infra/proof-request-repository'; import type { TemplateRepository } from '@backend-api/infra/template-repository'; - -const USER: User = { +import { + makeAppTemplate, + makeEmailTemplate, + makeLetterTemplate, + makeSmsTemplate, +} from '../fixtures/template'; + +const user: User = { internalUserId: 'user-id', clientId: 'client-id', }; -const TEMPLATE_ID = 'template-123'; +const templateId = 'template-123'; -const NHS_NUMBER = '9000000009'; +const nhsNumber = '9000000009'; -const ENABLED_CONFIG: ClientConfiguration = { +const clientConfigEnabled: ClientConfiguration = { features: { digitalProofingNhsApp: true, digitalProofingEmail: true, digitalProofingSms: true, }, -} as ClientConfiguration; +}; -const PROOF_REQUEST: ProofRequest = { +const proofRequest: ProofRequest = { id: 'proof-req-id', - templateId: TEMPLATE_ID, + templateId, templateType: 'NHS_APP', - contactDetailValue: NHS_NUMBER, - testPatientNhsNumber: NHS_NUMBER, + contactDetailValue: nhsNumber, + testPatientNhsNumber: nhsNumber, createdAt: '2024-01-01T00:00:00.000Z', }; -const VERIFIED_CONTACT: ContactDetail = { +const contactDetailVerified: ContactDetail = { id: 'contact-id', type: 'EMAIL', value: 'test@nhs.net', status: 'VERIFIED', }; -const ERROR: FailureResult = { - error: { - errorMeta: { - code: 500, - description: 'Something went wrong', - }, - }, -}; +const appTemplate = makeAppTemplate().databaseTemplate; +const emailTemplate = makeEmailTemplate().databaseTemplate; +const smsTemplate = makeSmsTemplate().databaseTemplate; +const letterTemplate = makeLetterTemplate().databaseTemplate; function setup() { const templateRepository = mock(); @@ -63,6 +61,10 @@ function setup() { const proofRequestRepository = mock(); const clientConfigRepository = mock(); + clientConfigRepository.get.mockResolvedValue({ + data: clientConfigEnabled, + }); + const client = new ProofingRequestClient( templateRepository, contactDetailsRepository, @@ -88,7 +90,7 @@ describe('ProofingRequestClient', () => { it('returns validation error when payload is invalid', async () => { const { client, mocks } = setup(); - const result = await client.create(TEMPLATE_ID, {}, USER); + const result = await client.create(templateId, {}, user); expect(result.data).toBeUndefined(); expect(result.error?.errorMeta.code).toBe(400); @@ -98,56 +100,65 @@ describe('ProofingRequestClient', () => { expect(mocks.templateRepository.get).not.toHaveBeenCalled(); }); - it('strips unknown fields from payload', async () => { + it('discards unknown fields from payload', async () => { const { client, mocks } = setup(); mocks.templateRepository.get.mockResolvedValueOnce({ - data: { templateType: 'NHS_APP' } as DatabaseTemplate, + data: appTemplate, }); mocks.clientConfigRepository.get.mockResolvedValueOnce({ - data: ENABLED_CONFIG, + data: clientConfigEnabled, }); mocks.proofRequestRepository.put.mockResolvedValueOnce({ - data: PROOF_REQUEST, + data: proofRequest, }); await client.create( - TEMPLATE_ID, - { testPatientNhsNumber: NHS_NUMBER, unknownField: 'value' }, - USER + templateId, + { testPatientNhsNumber: nhsNumber, unknownField: 'value' }, + user ); expect(mocks.proofRequestRepository.put).toHaveBeenCalledWith( - expect.objectContaining({ testPatientNhsNumber: NHS_NUMBER }), - USER + expect.objectContaining({ testPatientNhsNumber: nhsNumber }), + user ); }); it('returns error when template lookup fails', async () => { const { client, mocks } = setup(); - mocks.templateRepository.get.mockResolvedValueOnce(ERROR); + const failure = { + error: { + errorMeta: { + code: 500, + description: 'DDB error', + }, + }, + }; + + mocks.templateRepository.get.mockResolvedValueOnce(failure); const result = await client.create( - TEMPLATE_ID, - { testPatientNhsNumber: NHS_NUMBER }, - USER + templateId, + { testPatientNhsNumber: nhsNumber }, + user ); - expect(result).toBe(ERROR); + expect(result).toBe(failure); }); it('returns 400 for LETTER templates', async () => { const { client, mocks } = setup(); mocks.templateRepository.get.mockResolvedValueOnce({ - data: { templateType: 'LETTER' } as DatabaseTemplate, + data: letterTemplate, }); const result = await client.create( - TEMPLATE_ID, - { testPatientNhsNumber: NHS_NUMBER }, - USER + templateId, + { testPatientNhsNumber: nhsNumber }, + user ); expect(result.error?.errorMeta.code).toBe(400); @@ -160,44 +171,66 @@ describe('ProofingRequestClient', () => { const { client, mocks } = setup(); mocks.templateRepository.get.mockResolvedValueOnce({ - data: { templateType: 'NHS_APP' } as DatabaseTemplate, + data: appTemplate, }); - mocks.clientConfigRepository.get.mockResolvedValueOnce(ERROR); + + const failure = { + error: { + errorMeta: { + code: 500, + description: 'SSM error', + }, + }, + }; + + mocks.clientConfigRepository.get.mockResolvedValueOnce(failure); const result = await client.create( - TEMPLATE_ID, - { testPatientNhsNumber: NHS_NUMBER }, - USER + templateId, + { testPatientNhsNumber: nhsNumber }, + user ); - expect(result).toBe(ERROR); + expect(result).toBe(failure); }); it.each([ - { templateType: 'NHS_APP' as const, flag: 'digitalProofingNhsApp' }, - { templateType: 'EMAIL' as const, flag: 'digitalProofingEmail' }, - { templateType: 'SMS' as const, flag: 'digitalProofingSms' }, + { + template: appTemplate, + flag: 'digitalProofingNhsApp', + }, + { + template: emailTemplate, + flag: 'digitalProofingEmail', + contactDetailId: 'cd-id', + }, + { + template: smsTemplate, + flag: 'digitalProofingSms', + contactDetailId: 'cd-id', + }, ])( 'returns 403 when $flag is disabled for $templateType', - async ({ templateType, flag }) => { + async ({ template, flag, contactDetailId }) => { const { client, mocks } = setup(); mocks.templateRepository.get.mockResolvedValueOnce({ - data: { templateType } as DatabaseTemplate, + data: template, }); + mocks.clientConfigRepository.get.mockResolvedValueOnce({ data: { features: { [flag]: false }, - } as ClientConfiguration, + }, }); const result = await client.create( - TEMPLATE_ID, + templateId, { - testPatientNhsNumber: NHS_NUMBER, - contactDetailId: templateType === 'NHS_APP' ? undefined : 'cd-id', + testPatientNhsNumber: nhsNumber, + contactDetailId, }, - USER + user ); expect(result.error?.errorMeta.code).toBe(403); @@ -208,24 +241,17 @@ describe('ProofingRequestClient', () => { ); describe('NHS_APP', () => { - function setupNhsApp() { - const s = setup(); - s.mocks.templateRepository.get.mockResolvedValue({ - data: { templateType: 'NHS_APP' } as DatabaseTemplate, - }); - s.mocks.clientConfigRepository.get.mockResolvedValue({ - data: ENABLED_CONFIG, - }); - return s; - } - it('returns 400 when contactDetailId is provided', async () => { - const { client } = setupNhsApp(); + const { client, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: appTemplate, + }); const result = await client.create( - TEMPLATE_ID, - { testPatientNhsNumber: NHS_NUMBER, contactDetailId: 'cd-id' }, - USER + templateId, + { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, + user ); expect(result.error?.errorMeta.code).toBe(400); @@ -234,68 +260,78 @@ describe('ProofingRequestClient', () => { ); }); - it('persists proof request with contactDetailValue = testPatientNhsNumber', async () => { - const { client, mocks } = setupNhsApp(); + it('persists proof request with contactDetailValue set to testPatientNhsNumber', async () => { + const { client, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: appTemplate, + }); mocks.proofRequestRepository.put.mockResolvedValueOnce({ - data: PROOF_REQUEST, + data: proofRequest, }); const result = await client.create( - TEMPLATE_ID, - { testPatientNhsNumber: NHS_NUMBER }, - USER + templateId, + { testPatientNhsNumber: nhsNumber }, + user ); - expect(result).toEqual({ data: PROOF_REQUEST }); + expect(result).toEqual({ data: proofRequest }); expect(mocks.proofRequestRepository.put).toHaveBeenCalledWith( { - templateId: TEMPLATE_ID, + templateId: templateId, templateType: 'NHS_APP', - contactDetailValue: NHS_NUMBER, - testPatientNhsNumber: NHS_NUMBER, + contactDetailValue: nhsNumber, + testPatientNhsNumber: nhsNumber, personalisation: undefined, }, - USER + user ); }); it('returns error when repository put fails', async () => { - const { client, mocks } = setupNhsApp(); + const { client, mocks } = setup(); - mocks.proofRequestRepository.put.mockResolvedValueOnce(ERROR); + mocks.templateRepository.get.mockResolvedValueOnce({ + data: appTemplate, + }); + + const failure = { + error: { + errorMeta: { + code: 500, + description: 'DDB error', + }, + }, + }; + + mocks.proofRequestRepository.put.mockResolvedValueOnce(failure); const result = await client.create( - TEMPLATE_ID, - { testPatientNhsNumber: NHS_NUMBER }, - USER + templateId, + { testPatientNhsNumber: nhsNumber }, + user ); - expect(result).toBe(ERROR); + expect(result).toBe(failure); }); }); describe('EMAIL / SMS', () => { - function setupEmailSms(templateType: 'EMAIL' | 'SMS') { - const s = setup(); - s.mocks.templateRepository.get.mockResolvedValue({ - data: { templateType } as DatabaseTemplate, - }); - s.mocks.clientConfigRepository.get.mockResolvedValue({ - data: ENABLED_CONFIG, - }); - return s; - } - - it.each(['EMAIL' as const, 'SMS' as const])( + it.each([emailTemplate, smsTemplate])( 'returns 400 when contactDetailId is missing for %s', - async (templateType) => { - const { client } = setupEmailSms(templateType); + async (template) => { + const { client, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: template, + }); const result = await client.create( - TEMPLATE_ID, - { testPatientNhsNumber: NHS_NUMBER }, - USER + templateId, + { testPatientNhsNumber: nhsNumber }, + user ); expect(result.error?.errorMeta.code).toBe(400); @@ -305,55 +341,125 @@ describe('ProofingRequestClient', () => { } ); - it.each(['EMAIL' as const, 'SMS' as const])( - 'returns error when contact detail lookup fails for %s', - async (templateType) => { - const { client, mocks } = setupEmailSms(templateType); + it.each([emailTemplate, smsTemplate])( + 'returns error when contact detail lookup fails for $templateType', + async (template) => { + const { client, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: template, + }); + + const failure = { + error: { + errorMeta: { + code: 500, + description: 'DDB error', + }, + }, + }; - mocks.contactDetailsRepository.getById.mockResolvedValueOnce(ERROR); + mocks.contactDetailsRepository.getById.mockResolvedValueOnce(failure); const result = await client.create( - TEMPLATE_ID, - { testPatientNhsNumber: NHS_NUMBER, contactDetailId: 'cd-id' }, - USER + templateId, + { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, + user ); - expect(result).toBe(ERROR); + expect(result).toBe(failure); } ); - it.each(['EMAIL' as const, 'SMS' as const])( - 'returns 400 when contact detail is not verified for %s', - async (templateType) => { - const { client, mocks } = setupEmailSms(templateType); + it('returns 400 when contact detail is not verified for EMAIL', async () => { + const { client, mocks } = setup(); - mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ - data: { - ...VERIFIED_CONTACT, - status: 'PENDING_VERIFICATION', - }, - }); + mocks.templateRepository.get.mockResolvedValueOnce({ + data: emailTemplate, + }); - const result = await client.create( - TEMPLATE_ID, - { testPatientNhsNumber: NHS_NUMBER, contactDetailId: 'cd-id' }, - USER - ); + mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ + data: { + ...contactDetailVerified, + status: 'PENDING_VERIFICATION', + }, + }); - expect(result.error?.errorMeta.code).toBe(400); - expect(result.error?.errorMeta.description).toContain('not verified'); - } - ); + const result = await client.create( + templateId, + { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, + user + ); + + expect(result.error?.errorMeta.code).toBe(400); + expect(result.error?.errorMeta.description).toContain('not verified'); + }); + + it('returns 400 when contact detail is not verified for SMS', async () => { + const { client, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: smsTemplate, + }); + + mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ + data: { + ...contactDetailVerified, + type: 'SMS', + status: 'PENDING_VERIFICATION', + }, + }); + + const result = await client.create( + templateId, + { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, + user + ); + + expect(result.error?.errorMeta.code).toBe(400); + expect(result.error?.errorMeta.description).toContain('not verified'); + }); + + it('returns 500 when contact detail type does not match template type', async () => { + const { client, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: emailTemplate, + }); + + mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ + data: { + ...contactDetailVerified, + type: 'SMS', + }, + }); + + const result = await client.create( + templateId, + { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, + user + ); + + expect(result.error?.errorMeta.code).toBe(500); + expect(result.error?.errorMeta.description).toBe( + 'Contact detail is not verified' + ); + expect(mocks.proofRequestRepository.put).not.toHaveBeenCalled(); + }); it('persists EMAIL proof request with resolved contact value', async () => { - const { client, mocks } = setupEmailSms('EMAIL'); + const { client, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: emailTemplate, + }); mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ - data: VERIFIED_CONTACT, + data: contactDetailVerified, }); const expectedProof: ProofRequest = { - ...PROOF_REQUEST, + ...proofRequest, templateType: 'EMAIL', contactDetailValue: 'test@nhs.net', }; @@ -363,43 +469,61 @@ describe('ProofingRequestClient', () => { }); const result = await client.create( - TEMPLATE_ID, - { testPatientNhsNumber: NHS_NUMBER, contactDetailId: 'cd-id' }, - USER + templateId, + { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, + user ); expect(result).toEqual({ data: expectedProof }); expect(mocks.proofRequestRepository.put).toHaveBeenCalledWith( { - templateId: TEMPLATE_ID, + templateId: templateId, templateType: 'EMAIL', contactDetailValue: 'test@nhs.net', - testPatientNhsNumber: NHS_NUMBER, + testPatientNhsNumber: nhsNumber, personalisation: undefined, }, - USER + user ); expect(mocks.contactDetailsRepository.getById).toHaveBeenCalledWith( 'cd-id', - USER + user ); }); it('returns error when repository put fails for SMS', async () => { - const { client, mocks } = setupEmailSms('SMS'); + const { client, mocks } = setup(); + + mocks.templateRepository.get.mockResolvedValueOnce({ + data: smsTemplate, + }); mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ - data: { ...VERIFIED_CONTACT, type: 'SMS', value: '+447890123456' }, + data: { + ...contactDetailVerified, + type: 'SMS', + value: '+447890123456', + }, }); - mocks.proofRequestRepository.put.mockResolvedValueOnce(ERROR); + + const failure = { + error: { + errorMeta: { + code: 500, + description: 'DDB error', + }, + }, + }; + + mocks.proofRequestRepository.put.mockResolvedValueOnce(failure); const result = await client.create( - TEMPLATE_ID, - { testPatientNhsNumber: NHS_NUMBER, contactDetailId: 'cd-id' }, - USER + templateId, + { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, + user ); - expect(result).toBe(ERROR); + expect(result).toBe(failure); }); }); }); diff --git a/lambdas/backend-api/src/__tests__/fixtures/template.ts b/lambdas/backend-api/src/__tests__/fixtures/template.ts index f0ce96bbdf..fb1fa6630d 100644 --- a/lambdas/backend-api/src/__tests__/fixtures/template.ts +++ b/lambdas/backend-api/src/__tests__/fixtures/template.ts @@ -5,6 +5,8 @@ import type { SmsProperties, TemplateDto, CreatePdfLetterProperties, + AuthoringLetterProperties, + CreateAuthoringLetterProperties, } from 'nhs-notify-web-template-management-types'; import { WithAttachments } from '../../infra/template-repository'; import { DatabaseTemplate } from 'nhs-notify-web-template-management-utils'; @@ -30,7 +32,7 @@ const nhsAppProperties: NhsAppProperties = { templateType: 'NHS_APP', }; -const letterProperties: WithAttachments = { +const pdfLetterProperties: WithAttachments = { templateType: 'LETTER', letterType: 'x0', language: 'en', @@ -50,6 +52,30 @@ const letterProperties: WithAttachments = { campaignId: 'campaign-id', }; +const letterProperties: WithAttachments = { + templateType: 'LETTER', + clientId: 'client1', + letterType: 'x0', + language: 'en', + letterVersion: 'AUTHORING', + files: { + docxTemplate: { + fileName: 'template.docx', + currentVersion: 'a', + virusScanStatus: 'PENDING', + }, + initialRender: { + fileName: 'render.pdf', + currentVersion: 'a', + pageCount: 1, + status: 'RENDERED', + }, + }, + customPersonalisation: [], + systemPersonalisation: [], + campaignId: 'campaign-id', +}; + const createTemplateProperties = { name: 'name' }; const dtoProperties = { @@ -134,14 +160,14 @@ export const makeSmsTemplate = ( return { createUpdateTemplate, dtoTemplate, databaseTemplate }; }; -export const makeLetterTemplate = ( +export const makePdfLetterTemplate = ( overrides: Partial< TemplateDto & WithAttachments > = {} ): TemplateFixture => { const createUpdateTemplate = { ...createTemplateProperties, - ...letterProperties, + ...pdfLetterProperties, ...overrides, }; const dtoTemplate = { @@ -156,3 +182,26 @@ export const makeLetterTemplate = ( return { createUpdateTemplate, dtoTemplate, databaseTemplate }; }; + +export const makeLetterTemplate = ( + overrides: Partial< + TemplateDto & WithAttachments + > = {} +): TemplateFixture => { + const createUpdateTemplate = { + ...createTemplateProperties, + ...letterProperties, + ...overrides, + }; + const dtoTemplate = { + ...createUpdateTemplate, + ...dtoProperties, + letterVersion: 'AUTHORING' as const, + }; + const databaseTemplate = { + ...dtoTemplate, + ...databaseTemplateProperties, + }; + + return { createUpdateTemplate, dtoTemplate, databaseTemplate }; +}; diff --git a/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts b/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts index b9e3134f34..d068427f82 100644 --- a/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts @@ -286,7 +286,7 @@ describe('ContactDetailsRepository', () => { }); describe('getById', () => { - it('returns contact detail when found', async () => { + it('returns contact detail when present', async () => { const { repo, mocks } = setup(); const contactDetail = { @@ -303,6 +303,7 @@ describe('ContactDetailsRepository', () => { const result = await repo.getById('contact-id', USER); expect(result.data).toEqual(contactDetail); + expect(mocks.dynamodb).toHaveReceivedCommandWith(QueryCommand, { TableName: TABLE_NAME, IndexName: 'ById', @@ -329,6 +330,7 @@ describe('ContactDetailsRepository', () => { expect(result.data).toBeUndefined(); expect(result.error?.errorMeta.code).toBe(404); + expect(result.error?.errorMeta.description).toBe( 'Contact detail not found' ); @@ -354,6 +356,7 @@ describe('ContactDetailsRepository', () => { expect(result.data).toBeUndefined(); expect(result.error?.errorMeta.code).toBe(500); + expect(result.error?.errorMeta.description).toBe( 'Failed to get contact detail' ); diff --git a/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts b/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts index 728e15d71f..defe11130f 100644 --- a/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts @@ -5,16 +5,16 @@ import 'aws-sdk-client-mock-jest'; import type { User } from 'nhs-notify-web-template-management-utils'; import { ProofRequestRepository } from '@backend-api/infra/proof-request-repository'; -const TABLE_NAME = 'proof-requests-table'; -const RANDOM_UUID = 'test-uuid-1234-5678-9abc-def012345678'; +const tableName = 'proof-requests-table'; +const randomUuid = 'test-uuid-1234-5678-9abc-def012345678'; const NOW = new Date('2026-01-01T09:00:00.000Z'); -const USER: User = { +const user: User = { internalUserId: 'user-id', clientId: 'client-id', }; -const PARAMS = { +const requestParams = { templateId: 'template-123', templateType: 'NHS_APP' as const, contactDetailValue: '9000000009', @@ -23,7 +23,7 @@ const PARAMS = { beforeEach(() => { jest.useFakeTimers({ now: NOW }); - jest.spyOn(crypto, 'randomUUID').mockReturnValue(RANDOM_UUID); + jest.spyOn(crypto, 'randomUUID').mockReturnValue(randomUuid); }); afterEach(() => { @@ -36,7 +36,7 @@ function setup() { const repo = new ProofRequestRepository( dynamodb as unknown as DynamoDBDocumentClient, - TABLE_NAME + tableName ); return { repo, mocks: { dynamodb } }; @@ -49,21 +49,21 @@ describe('ProofRequestRepository', () => { mocks.dynamodb.on(PutCommand).resolvesOnce({}); - const result = await repo.put(PARAMS, USER); + const result = await repo.put(requestParams, user); expect(result.data).toEqual({ - ...PARAMS, - id: RANDOM_UUID, + ...requestParams, + id: randomUuid, createdAt: NOW.toISOString(), }); expect(mocks.dynamodb).toHaveReceivedCommandWith(PutCommand, { - TableName: TABLE_NAME, + TableName: tableName, Item: { - ...PARAMS, - id: RANDOM_UUID, + ...requestParams, + id: randomUuid, createdAt: NOW.toISOString(), - owner: `INTERNAL_USER#${USER.internalUserId}`, + owner: `INTERNAL_USER#${user.internalUserId}`, }, }); }); @@ -73,13 +73,16 @@ describe('ProofRequestRepository', () => { mocks.dynamodb.on(PutCommand).resolvesOnce({}); - const params = { ...PARAMS, personalisation: { firstName: 'Jane' } }; + const paramsPersonalised = { + ...requestParams, + personalisation: { firstName: 'Jane' }, + }; - const result = await repo.put(params, USER); + const result = await repo.put(paramsPersonalised, user); expect(result.data).toEqual({ - ...params, - id: RANDOM_UUID, + ...paramsPersonalised, + id: randomUuid, createdAt: NOW.toISOString(), }); }); @@ -89,7 +92,7 @@ describe('ProofRequestRepository', () => { mocks.dynamodb.on(PutCommand).rejects(new Error('DynamoDB error')); - const result = await repo.put(PARAMS, USER); + const result = await repo.put(requestParams, user); expect(result.data).toBeUndefined(); expect(result.error?.errorMeta.code).toBe(500); diff --git a/lambdas/backend-api/src/__tests__/infra/template-repository/query.test.ts b/lambdas/backend-api/src/__tests__/infra/template-repository/query.test.ts index f3398d98c1..b5f3830ad0 100644 --- a/lambdas/backend-api/src/__tests__/infra/template-repository/query.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/template-repository/query.test.ts @@ -18,10 +18,10 @@ const TABLE_NAME = 'template-table-name'; const clientId = '89077697-ca6d-47fc-b233-3281fbd15579'; const clientOwnerKey = `CLIENT#${clientId}`; -const appTemplates = makeAppTemplate(); -const emailTemplates = makeEmailTemplate(); -const smsTemplates = makeSmsTemplate(); -const letterTemplates = makeLetterTemplate(); +const appTemplate = makeAppTemplate(); +const emailTemplate = makeEmailTemplate(); +const smsTemplate = makeSmsTemplate(); +const letterTemplate = makeLetterTemplate(); function setup() { const dynamo = mockClient(DynamoDBDocumentClient); @@ -44,12 +44,12 @@ describe('TemplateRepo#query', () => { const { repo, mocks } = setup(); const page1: DatabaseTemplate[] = [ - appTemplates.databaseTemplate, - emailTemplates.databaseTemplate, + appTemplate.databaseTemplate, + emailTemplate.databaseTemplate, ]; const page2: DatabaseTemplate[] = [ - smsTemplates.databaseTemplate, - letterTemplates.databaseTemplate, + smsTemplate.databaseTemplate, + letterTemplate.databaseTemplate, ]; mocks.dynamo @@ -58,7 +58,7 @@ describe('TemplateRepo#query', () => { Items: page1, LastEvaluatedKey: { owner: clientOwnerKey, - id: emailTemplates.databaseTemplate.id, + id: emailTemplate.databaseTemplate.id, }, }) .resolvesOnce({ @@ -79,7 +79,7 @@ describe('TemplateRepo#query', () => { }, ExclusiveStartKey: { owner: clientOwnerKey, - id: emailTemplates.databaseTemplate.id, + id: emailTemplate.databaseTemplate.id, }, }); expect(mocks.dynamo).toHaveReceivedNthCommandWith(2, QueryCommand, { @@ -94,10 +94,10 @@ describe('TemplateRepo#query', () => { }); expect(result.data).toEqual([ - appTemplates.dtoTemplate, - emailTemplates.dtoTemplate, - smsTemplates.dtoTemplate, - letterTemplates.dtoTemplate, + appTemplate.dtoTemplate, + emailTemplate.dtoTemplate, + smsTemplate.dtoTemplate, + letterTemplate.dtoTemplate, ]); }); @@ -442,17 +442,17 @@ describe('TemplateRepo#query', () => { mocks.dynamo.on(QueryCommand).resolvesOnce({ Items: [ - appTemplates.databaseTemplate, + appTemplate.databaseTemplate, { owner: clientOwnerKey, id: '2eb0b8f5-63f0-4512-8a95-5b82e7c4b07b' }, - emailTemplates.databaseTemplate, + emailTemplate.databaseTemplate, ], }); const result = await repo.query(clientId).list(); expect(result.data).toEqual([ - appTemplates.dtoTemplate, - emailTemplates.dtoTemplate, + appTemplate.dtoTemplate, + emailTemplate.dtoTemplate, ]); }); @@ -493,7 +493,7 @@ describe('TemplateRepo#query', () => { Count: 2, LastEvaluatedKey: { owner: clientOwnerKey, - id: emailTemplates.databaseTemplate.id, + id: emailTemplate.databaseTemplate.id, }, }) .resolvesOnce({ @@ -515,7 +515,7 @@ describe('TemplateRepo#query', () => { Select: 'COUNT', ExclusiveStartKey: { owner: clientOwnerKey, - id: emailTemplates.databaseTemplate.id, + id: emailTemplate.databaseTemplate.id, }, }); expect(mocks.dynamo).toHaveReceivedNthCommandWith(2, QueryCommand, { diff --git a/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts b/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts index dca64dd4a5..0d8609ac67 100644 --- a/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/template-repository/repository.test.ts @@ -23,6 +23,7 @@ import { makeAppTemplate, makeEmailTemplate, makeLetterTemplate, + makePdfLetterTemplate, makeSmsTemplate, } from '@backend-api/__tests__/fixtures/template'; @@ -33,10 +34,11 @@ jest.mock('@backend-api/utils/calculate-ttl'); const templateId = 'abc-def-ghi-jkl-123'; const templatesTableName = 'templates'; -const appTemplates = makeAppTemplate(); -const emailTemplates = makeEmailTemplate(); -const smsTemplates = makeSmsTemplate(); -const letterTemplates = makeLetterTemplate(); +const appTemplate = makeAppTemplate(); +const emailTemplate = makeEmailTemplate(); +const smsTemplate = makeSmsTemplate(); +const pdfLetterTemplate = makePdfLetterTemplate(); +const letterTemplate = makeLetterTemplate(); const setup = () => { const ddbDocClient = mockClient(DynamoDBDocumentClient); @@ -153,11 +155,11 @@ describe('templateRepository', () => { TableName: templatesTableName, Key: { id: templateId, owner: ownerWithClientPrefix }, }) - .resolves({ Item: emailTemplates.databaseTemplate }); + .resolves({ Item: emailTemplate.databaseTemplate }); const response = await templateRepository.get(templateId, clientId); - expect(response).toEqual({ data: emailTemplates.databaseTemplate }); + expect(response).toEqual({ data: emailTemplate.databaseTemplate }); }); }); @@ -190,8 +192,14 @@ describe('templateRepository', () => { }); }); - test.each([emailTemplates, smsTemplates, appTemplates, letterTemplates])( - 'should create template of type $templateType', + test.each([ + emailTemplate, + smsTemplate, + appTemplate, + letterTemplate, + pdfLetterTemplate, + ])( + 'should create template of type $templateType (letterVersion: $letterVersion)', async ({ createUpdateTemplate, databaseTemplate }) => { const { templateRepository, mocks } = setup(); @@ -227,12 +235,12 @@ describe('templateRepository', () => { const { templateRepository, mocks } = setup(); const requestedUpdate: CreateUpdateEmailTemplate = { - ...emailTemplates.createUpdateTemplate, + ...emailTemplate.createUpdateTemplate, name: 'updated-name', }; const updated: DatabaseTemplate = { - ...emailTemplates.databaseTemplate, + ...emailTemplate.databaseTemplate, ...requestedUpdate, lockNumber: 2, }; @@ -298,12 +306,12 @@ describe('templateRepository', () => { const { templateRepository, mocks } = setup(); const requestedUpdate: CreateUpdateSMSTemplate = { - ...smsTemplates.createUpdateTemplate, + ...smsTemplate.createUpdateTemplate, name: 'updated-name', }; const updated: DatabaseTemplate = { - ...smsTemplates.databaseTemplate, + ...smsTemplate.databaseTemplate, ...requestedUpdate, lockNumber: 2, }; @@ -367,12 +375,12 @@ describe('templateRepository', () => { const { templateRepository, mocks } = setup(); const requestedUpdate: CreateUpdateNHSAppTemplate = { - ...appTemplates.createUpdateTemplate, + ...appTemplate.createUpdateTemplate, name: 'updated-name', }; const updated: DatabaseTemplate = { - ...appTemplates.databaseTemplate, + ...appTemplate.databaseTemplate, ...requestedUpdate, lockNumber: 2, }; diff --git a/lambdas/backend-api/src/app/proofing-request-client.ts b/lambdas/backend-api/src/app/proofing-request-client.ts index 57a6e6950e..f0c7aa6cf7 100644 --- a/lambdas/backend-api/src/app/proofing-request-client.ts +++ b/lambdas/backend-api/src/app/proofing-request-client.ts @@ -11,8 +11,9 @@ import type { ContactDetailsRepository } from '@backend-api/infra/contact-detail import type { ProofRequestRepository } from '@backend-api/infra/proof-request-repository'; import type { TemplateRepository } from '@backend-api/infra/template-repository'; import { type ApplicationResult, failure, validate } from '@backend-api/utils'; +import { ErrorCase } from 'nhs-notify-backend-client/types'; -const digitalProofingFeatureFlag: Record< +const digitalProofingFeatureFlagMap: Record< Exclude, keyof ClientFeatures > = { @@ -66,7 +67,7 @@ export class ProofingRequestClient { if ( !clientConfigResult.data?.features[ - digitalProofingFeatureFlag[templateType] + digitalProofingFeatureFlagMap[templateType] ] ) { return failure(403, 'Digital proofing is not enabled'); @@ -122,10 +123,7 @@ export class ProofingRequestClient { user: User ): Promise> { if (!contactDetailId) { - return failure( - 400, - `contactDetailId is required for ${templateType} templates` - ); + return failure(400, 'contactDetailId is required'); } const contactDetailResult = await this.contactDetailsRepository.getById( @@ -137,7 +135,13 @@ export class ProofingRequestClient { return contactDetailResult; } - if (contactDetailResult.data.status !== 'VERIFIED') { + const contactDetail = contactDetailResult.data; + + if (contactDetail.type !== templateType) { + return failure(ErrorCase.INTERNAL, 'Contact detail is not verified'); + } + + if (contactDetail.status !== 'VERIFIED') { return failure(400, 'Contact detail is not verified'); } diff --git a/lambdas/backend-client/src/__tests__/schemas/contact-details/index.test.ts b/lambdas/backend-client/src/__tests__/schemas/contact-details/index.test.ts index a79a8c57da..55eb7cc683 100644 --- a/lambdas/backend-client/src/__tests__/schemas/contact-details/index.test.ts +++ b/lambdas/backend-client/src/__tests__/schemas/contact-details/index.test.ts @@ -85,7 +85,7 @@ describe('$ContactDetail', () => { }); }); - test('rejects an invalid status', () => { + test('rejects an invalid contact detail', () => { const result = $ContactDetail.safeParse({ id: 'contact-1', status: 'INVALID', diff --git a/lambdas/backend-client/src/schemas/contact-details/contact-detail.ts b/lambdas/backend-client/src/schemas/contact-details/contact-detail.ts deleted file mode 100644 index eb403382a2..0000000000 --- a/lambdas/backend-client/src/schemas/contact-details/contact-detail.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from 'zod/v4'; -import type { ContactDetail } from 'nhs-notify-web-template-management-types'; - -export const $ContactDetail: z.ZodType = z.object({ - id: z.string(), - status: z.enum(['PENDING_VERIFICATION', 'VERIFIED']), - type: z.enum(['EMAIL', 'SMS']), - value: z.string(), -}); diff --git a/lambdas/backend-client/src/schemas/contact-details/index.ts b/lambdas/backend-client/src/schemas/contact-details/index.ts index dd0fe0819a..824412a92b 100644 --- a/lambdas/backend-client/src/schemas/contact-details/index.ts +++ b/lambdas/backend-client/src/schemas/contact-details/index.ts @@ -1,5 +1,8 @@ import { z } from 'zod/v4'; -import type { ContactDetailInputNormalized } from 'nhs-notify-web-template-management-types'; +import type { + ContactDetail, + ContactDetailInputNormalized, +} from 'nhs-notify-web-template-management-types'; import { parsePhoneNumber } from './phone-number'; import { parseEmailAddress } from './email'; @@ -65,4 +68,9 @@ export const $ContactDetailInputNormalized: z.ZodType = z.object({ + id: z.string(), + status: z.enum(['PENDING_VERIFICATION', 'VERIFIED']), + type: z.enum(['EMAIL', 'SMS']), + value: z.string(), +}); diff --git a/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts b/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts index d098c6cc28..2378b8aa5b 100644 --- a/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts +++ b/lambdas/event-publisher/src/__tests__/domain/event-builder.test.ts @@ -317,7 +317,26 @@ const expectedRoutingConfigEvent = ( }, }); -const publishableProofRequestEventRecord = (): PublishableEventRecord => ({ +const proofRequestContactDetails: Record< + string, + { contactDetailValue: string; expectedContactDetails?: object } +> = { + SMS: { + contactDetailValue: '07700900000', + expectedContactDetails: { sms: '07700900000' }, + }, + EMAIL: { + contactDetailValue: 'test@nhs.net', + expectedContactDetails: { email: 'test@nhs.net' }, + }, + NHS_APP: { + contactDetailValue: '9000000009', + }, +}; + +const publishableProofRequestEventRecord = ( + templateType = 'SMS' +): PublishableEventRecord => ({ dynamodb: { SequenceNumber: '4', NewImage: { @@ -328,13 +347,13 @@ const publishableProofRequestEventRecord = (): PublishableEventRecord => ({ S: 'bed3398c-bbe3-435d-80c1-58154d4bf7dd', }, templateType: { - S: 'SMS', + S: templateType, }, testPatientNhsNumber: { S: '9000000009', }, contactDetailValue: { - S: '07700900000', + S: proofRequestContactDetails[templateType].contactDetailValue, }, personalisation: { M: { @@ -349,7 +368,7 @@ const publishableProofRequestEventRecord = (): PublishableEventRecord => ({ tableName: tables.proofRequests, }); -const expectedProofRequestedEvent = () => ({ +const expectedProofRequestedEvent = (templateType = 'SMS') => ({ event: { id: '7f2ae4b0-82c2-4911-9b84-8997d7f3f40d', datacontenttype: 'application/json', @@ -364,11 +383,14 @@ const expectedProofRequestedEvent = () => ({ data: { id: '92b676e9-470f-4d04-ab14-965ef145e15d', templateId: 'bed3398c-bbe3-435d-80c1-58154d4bf7dd', - templateType: 'SMS', + templateType, testPatientNhsNumber: '9000000009', - contactDetails: { - sms: '07700900000', - }, + ...(proofRequestContactDetails[templateType].expectedContactDetails + ? { + contactDetails: + proofRequestContactDetails[templateType].expectedContactDetails, + } + : {}), personalisation: { firstName: 'Jane', }, @@ -693,67 +715,26 @@ describe('routing config events', () => { }); describe('proof request events', () => { - test('builds proof requested event', () => { + test('builds proof requested event for SMS template', () => { const event = eventBuilder.buildEvent(publishableProofRequestEventRecord()); expect(event).toEqual(expectedProofRequestedEvent()); }); test('builds proof requested event for EMAIL template', () => { - const valid = publishableProofRequestEventRecord(); - - const emailRecord: PublishableEventRecord = { - ...valid, - dynamodb: { - ...valid.dynamodb, - NewImage: { - ...valid.dynamodb.NewImage!, - templateType: { S: 'EMAIL' }, - contactDetailValue: { S: 'test@nhs.net' }, - }, - }, - }; - - const event = eventBuilder.buildEvent(emailRecord); + const event = eventBuilder.buildEvent( + publishableProofRequestEventRecord('EMAIL') + ); - expect(event).toEqual({ - event: expect.objectContaining({ - data: expect.objectContaining({ - templateType: 'EMAIL', - contactDetails: { email: 'test@nhs.net' }, - }), - }), - }); + expect(event).toEqual(expectedProofRequestedEvent('EMAIL')); }); test('builds proof requested event for NHS_APP template', () => { - const valid = publishableProofRequestEventRecord(); - - const nhsAppRecord: PublishableEventRecord = { - ...valid, - dynamodb: { - ...valid.dynamodb, - NewImage: { - ...valid.dynamodb.NewImage!, - templateType: { S: 'NHS_APP' }, - }, - }, - }; - - const event = eventBuilder.buildEvent(nhsAppRecord); - - expect(event).toEqual({ - event: expect.objectContaining({ - data: expect.objectContaining({ - templateType: 'NHS_APP', - testPatientNhsNumber: '9000000009', - }), - }), - }); + const event = eventBuilder.buildEvent( + publishableProofRequestEventRecord('NHS_APP') + ); - expect( - (event as { event: { data: Record } }).event.data - ).not.toHaveProperty('contactDetails'); + expect(event).toEqual(expectedProofRequestedEvent('NHS_APP')); }); test('errors on unsupported templateType for proof request', () => { @@ -771,7 +752,15 @@ describe('proof request events', () => { }; expect(() => eventBuilder.buildEvent(letterRecord)).toThrow( - 'Unsupported templateType for proof request: LETTER' + expect.objectContaining({ + name: 'ZodError', + issues: [ + expect.objectContaining({ + code: 'invalid_value', + path: ['templateType'], + }), + ], + }) ); }); diff --git a/lambdas/event-publisher/src/domain/event-builder.ts b/lambdas/event-publisher/src/domain/event-builder.ts index 0b8d6479e3..d43ca514c6 100644 --- a/lambdas/event-publisher/src/domain/event-builder.ts +++ b/lambdas/event-publisher/src/domain/event-builder.ts @@ -281,11 +281,6 @@ export class EventBuilder extends NHSNotifyEventBuilder { case 'NHS_APP': { return base; } - default: { - throw new Error( - `Unsupported templateType for proof request: ${record.templateType}` - ); - } } } diff --git a/lambdas/event-publisher/src/domain/input-schemas.ts b/lambdas/event-publisher/src/domain/input-schemas.ts index 0da582a0c4..87bbf75181 100644 --- a/lambdas/event-publisher/src/domain/input-schemas.ts +++ b/lambdas/event-publisher/src/domain/input-schemas.ts @@ -71,7 +71,7 @@ export type DynamoDBRoutingConfig = z.infer; export const $DynamoDBProofRequest = z.object({ id: z.string(), templateId: z.string(), - templateType: z.enum(TEMPLATE_TYPE_LIST), + templateType: z.enum(['NHS_APP', 'EMAIL', 'SMS']), testPatientNhsNumber: z.string(), contactDetailValue: z.string(), personalisation: z.record(z.string(), z.string()).optional(), diff --git a/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts b/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts index cad3e5a97f..fe44bc6660 100644 --- a/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts @@ -86,12 +86,12 @@ test.describe('ProofRequestedEvent', () => { expect.objectContaining({ record: expect.objectContaining({ type: 'uk.nhs.notify.template-management.ProofRequested.v1', - data: expect.objectContaining({ + data: { id: proofRequestId, templateId: created.data.id, templateType: 'NHS_APP', testPatientNhsNumber: '9000000009', - }), + }, }), }) ); @@ -165,7 +165,7 @@ test.describe('ProofRequestedEvent', () => { expect.objectContaining({ record: expect.objectContaining({ type: 'uk.nhs.notify.template-management.ProofRequested.v1', - data: expect.objectContaining({ + data: { id: proofRequestId, templateId: created.data.id, templateType: 'SMS', @@ -174,7 +174,7 @@ test.describe('ProofRequestedEvent', () => { sms: '+447700900000', }, personalisation, - }), + }, }), }) ); @@ -250,7 +250,7 @@ test.describe('ProofRequestedEvent', () => { expect.objectContaining({ record: expect.objectContaining({ type: 'uk.nhs.notify.template-management.ProofRequested.v1', - data: expect.objectContaining({ + data: { id: proofRequestId, templateId: created.data.id, templateType: 'EMAIL', @@ -259,7 +259,7 @@ test.describe('ProofRequestedEvent', () => { email: 'test@example.com', }, personalisation, - }), + }, }), }) ); From 1a70874ab996721efeb747c7b646dc7eb542cf9a Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Wed, 13 May 2026 10:04:09 +0100 Subject: [PATCH 11/28] use latest lambda mod --- infrastructure/terraform/modules/backend-api/README.md | 2 +- .../backend-api/module_create_proofing_request_lambda.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/terraform/modules/backend-api/README.md b/infrastructure/terraform/modules/backend-api/README.md index 3b1abf60c9..7fb6597104 100644 --- a/infrastructure/terraform/modules/backend-api/README.md +++ b/infrastructure/terraform/modules/backend-api/README.md @@ -47,7 +47,7 @@ No requirements. | [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [count\_routing\_configs\_lambda](#module\_count\_routing\_configs\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [create\_contact\_details\_lambda](#module\_create\_contact\_details\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.8/terraform-lambda.zip | n/a | -| [create\_proofing\_request\_lambda](#module\_create\_proofing\_request\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.8/terraform-lambda.zip | n/a | +| [create\_proofing\_request\_lambda](#module\_create\_proofing\_request\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.6/terraform-lambda.zip | n/a | | [create\_routing\_config\_lambda](#module\_create\_routing\_config\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [create\_template\_lambda](#module\_create\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [delete\_routing\_config\_lambda](#module\_delete\_routing\_config\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | diff --git a/infrastructure/terraform/modules/backend-api/module_create_proofing_request_lambda.tf b/infrastructure/terraform/modules/backend-api/module_create_proofing_request_lambda.tf index 489833ca7f..dfd5ba1cc8 100644 --- a/infrastructure/terraform/modules/backend-api/module_create_proofing_request_lambda.tf +++ b/infrastructure/terraform/modules/backend-api/module_create_proofing_request_lambda.tf @@ -1,5 +1,5 @@ module "create_proofing_request_lambda" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.8/terraform-lambda.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.6/terraform-lambda.zip" project = var.project environment = var.environment From 4109b5e51d329e161b21e2cbeaaee8d312545a08 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Wed, 13 May 2026 10:41:37 +0100 Subject: [PATCH 12/28] endpoint summary --- infrastructure/terraform/modules/backend-api/spec.tmpl.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index e0dd01357a..2387c4d0c0 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -3100,7 +3100,7 @@ "authorizer": [] } ], - "summary": "Create a proofing request for a template", + "summary": "Create a proofing request for a digital-channel template", "x-amazon-apigateway-integration": { "contentHandling": "CONVERT_TO_TEXT", "credentials": "${APIG_EXECUTION_ROLE_ARN}", From 06ab5de6d0b66dffd139902446ebb94b02e9ad1c Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Wed, 13 May 2026 11:07:26 +0100 Subject: [PATCH 13/28] fix comp test assertions --- .../create-proofing-request.api.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts index 41c0c087e0..584e4cc151 100644 --- a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts @@ -450,7 +450,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { expect(response.status(), debug).toBe(400); expect(result).toEqual({ statusCode: 400, - technicalMessage: 'contactDetailId is required for EMAIL templates', + technicalMessage: 'contactDetailId is required', }); }); @@ -477,7 +477,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { expect(response.status(), debug).toBe(400); expect(result).toEqual({ statusCode: 400, - technicalMessage: 'contactDetailId is required for SMS templates', + technicalMessage: 'contactDetailId is required', }); }); }); From 872575457751bd1de0697353ab00b8bbfec66e4a Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Wed, 13 May 2026 16:45:27 +0100 Subject: [PATCH 14/28] comments --- .../api/create-proofing-request.test.ts | 52 ++-- .../app/proofing-request-client.test.ts | 263 ++++++++---------- .../infra/proof-request-repository.test.ts | 1 + .../src/api/create-proofing-request.ts | 15 +- .../src/app/proofing-request-client.ts | 26 +- .../src/infra/proof-request-repository.ts | 1 + .../schemas/nhs-number-validation.test.ts | 19 -- .../src/schemas/nhs-number-validation.ts | 8 - .../create-proofing-request.api.spec.ts | 37 ++- 9 files changed, 208 insertions(+), 214 deletions(-) diff --git a/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts b/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts index 0805187e47..fb1ca4beba 100644 --- a/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts +++ b/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts @@ -16,25 +16,39 @@ describe('Create Proofing Request Handler', () => { beforeEach(jest.resetAllMocks); test.each([ - ['undefined', undefined], - ['missing user', { clientId: 'client-id', internalUserId: undefined }], - ['missing client', { clientId: undefined, internalUserId: 'user-1234' }], + ['authorizer is undefined', undefined, { templateId: 'template-123' }], + [ + 'internalUserId is missing', + { clientId: 'client-id', internalUserId: undefined }, + { templateId: 'template-123' }, + ], + [ + 'clientId is missing', + { clientId: undefined, internalUserId: 'user-1234' }, + { templateId: 'template-123' }, + ], + [ + 'templateId is missing', + { clientId: 'client-id', internalUserId: 'user-1234' }, + { templateId: undefined }, + ], ])( - 'should return 400 - Invalid request when requestContext is %s', - async (_, ctx) => { + 'should return 500 - Invalid request when %s', + async (_, ctx, pathParameters) => { const { handler, mocks } = setup(); const event = mock({ requestContext: { authorizer: ctx }, + pathParameters, body: JSON.stringify({ testPatientNhsNumber: '9000000009' }), }); const result = await handler(event, mock(), jest.fn()); expect(result).toEqual({ - statusCode: 400, + statusCode: 500, body: JSON.stringify({ - statusCode: 400, + statusCode: 500, technicalMessage: 'Invalid request', }), }); @@ -43,30 +57,6 @@ describe('Create Proofing Request Handler', () => { } ); - test('should return 400 when templateId is missing', async () => { - const { handler, mocks } = setup(); - - const event = mock({ - requestContext: { - authorizer: { internalUserId: 'user-1234', clientId: 'client-id' }, - }, - pathParameters: { templateId: undefined }, - body: JSON.stringify({ testPatientNhsNumber: '9000000009' }), - }); - - const result = await handler(event, mock(), jest.fn()); - - expect(result).toEqual({ - statusCode: 400, - body: JSON.stringify({ - statusCode: 400, - technicalMessage: 'templateId is required', - }), - }); - - expect(mocks.proofingRequestClient.create).not.toHaveBeenCalled(); - }); - test('should return error when client returns error', async () => { const { handler, mocks } = setup(); diff --git a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts index be73dcf300..a386f12455 100644 --- a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts @@ -43,13 +43,20 @@ const proofRequest: ProofRequest = { createdAt: '2024-01-01T00:00:00.000Z', }; -const contactDetailVerified: ContactDetail = { +const contactDetailVerifiedEmail: ContactDetail = { id: 'contact-id', type: 'EMAIL', value: 'test@nhs.net', status: 'VERIFIED', }; +const contactDetailVerifiedSms: ContactDetail = { + id: 'contact-id', + type: 'SMS', + value: '+447890123456', + status: 'VERIFIED', +}; + const appTemplate = makeAppTemplate().databaseTemplate; const emailTemplate = makeEmailTemplate().databaseTemplate; const smsTemplate = makeSmsTemplate().databaseTemplate; @@ -318,10 +325,21 @@ describe('ProofingRequestClient', () => { }); }); - describe('EMAIL / SMS', () => { - it.each([emailTemplate, smsTemplate])( - 'returns 400 when contactDetailId is missing for %s', - async (template) => { + describe.each([ + { + template: emailTemplate, + contactDetail: contactDetailVerifiedEmail, + mismatchedContactDetail: contactDetailVerifiedSms, + }, + { + template: smsTemplate, + contactDetail: contactDetailVerifiedSms, + mismatchedContactDetail: contactDetailVerifiedEmail, + }, + ])( + '$template.templateType', + ({ template, contactDetail, mismatchedContactDetail }) => { + it('returns 400 when contactDetailId is missing', async () => { const { client, mocks } = setup(); mocks.templateRepository.get.mockResolvedValueOnce({ @@ -338,12 +356,9 @@ describe('ProofingRequestClient', () => { expect(result.error?.errorMeta.description).toContain( 'contactDetailId is required' ); - } - ); + }); - it.each([emailTemplate, smsTemplate])( - 'returns error when contact detail lookup fails for $templateType', - async (template) => { + it('returns error when contact detail lookup fails', async () => { const { client, mocks } = setup(); mocks.templateRepository.get.mockResolvedValueOnce({ @@ -368,163 +383,131 @@ describe('ProofingRequestClient', () => { ); expect(result).toBe(failure); - } - ); - - it('returns 400 when contact detail is not verified for EMAIL', async () => { - const { client, mocks } = setup(); - - mocks.templateRepository.get.mockResolvedValueOnce({ - data: emailTemplate, }); - mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ - data: { - ...contactDetailVerified, - status: 'PENDING_VERIFICATION', - }, - }); - - const result = await client.create( - templateId, - { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, - user - ); + it('returns 400 when contact detail is not verified', async () => { + const { client, mocks } = setup(); - expect(result.error?.errorMeta.code).toBe(400); - expect(result.error?.errorMeta.description).toContain('not verified'); - }); + mocks.templateRepository.get.mockResolvedValueOnce({ + data: template, + }); - it('returns 400 when contact detail is not verified for SMS', async () => { - const { client, mocks } = setup(); + mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ + data: { + ...contactDetail, + status: 'PENDING_VERIFICATION', + }, + }); - mocks.templateRepository.get.mockResolvedValueOnce({ - data: smsTemplate, - }); + const result = await client.create( + templateId, + { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, + user + ); - mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ - data: { - ...contactDetailVerified, - type: 'SMS', - status: 'PENDING_VERIFICATION', - }, + expect(result.error?.errorMeta.code).toBe(400); + expect(result.error?.errorMeta.description).toContain('not verified'); }); - const result = await client.create( - templateId, - { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, - user - ); + it('returns 400 when contact detail type does not match template type', async () => { + const { client, mocks } = setup(); - expect(result.error?.errorMeta.code).toBe(400); - expect(result.error?.errorMeta.description).toContain('not verified'); - }); + mocks.templateRepository.get.mockResolvedValueOnce({ + data: template, + }); - it('returns 500 when contact detail type does not match template type', async () => { - const { client, mocks } = setup(); + mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ + data: mismatchedContactDetail, + }); - mocks.templateRepository.get.mockResolvedValueOnce({ - data: emailTemplate, - }); + const result = await client.create( + templateId, + { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, + user + ); - mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ - data: { - ...contactDetailVerified, - type: 'SMS', - }, + expect(result.error?.errorMeta.code).toBe(400); + expect(result.error?.errorMeta.description).toBe( + 'Contact detail is not valid for this channel' + ); + expect(mocks.proofRequestRepository.put).not.toHaveBeenCalled(); }); - const result = await client.create( - templateId, - { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, - user - ); + it('returns error when repository put fails', async () => { + const { client, mocks } = setup(); - expect(result.error?.errorMeta.code).toBe(500); - expect(result.error?.errorMeta.description).toBe( - 'Contact detail is not verified' - ); - expect(mocks.proofRequestRepository.put).not.toHaveBeenCalled(); - }); + mocks.templateRepository.get.mockResolvedValueOnce({ + data: template, + }); - it('persists EMAIL proof request with resolved contact value', async () => { - const { client, mocks } = setup(); + mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ + data: contactDetail, + }); - mocks.templateRepository.get.mockResolvedValueOnce({ - data: emailTemplate, - }); + const failure = { + error: { + errorMeta: { + code: 500, + description: 'DDB error', + }, + }, + }; - mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ - data: contactDetailVerified, - }); + mocks.proofRequestRepository.put.mockResolvedValueOnce(failure); - const expectedProof: ProofRequest = { - ...proofRequest, - templateType: 'EMAIL', - contactDetailValue: 'test@nhs.net', - }; + const result = await client.create( + templateId, + { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, + user + ); - mocks.proofRequestRepository.put.mockResolvedValueOnce({ - data: expectedProof, + expect(result).toBe(failure); }); - const result = await client.create( - templateId, - { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, - user - ); - - expect(result).toEqual({ data: expectedProof }); - expect(mocks.proofRequestRepository.put).toHaveBeenCalledWith( - { - templateId: templateId, - templateType: 'EMAIL', - contactDetailValue: 'test@nhs.net', - testPatientNhsNumber: nhsNumber, - personalisation: undefined, - }, - user - ); - expect(mocks.contactDetailsRepository.getById).toHaveBeenCalledWith( - 'cd-id', - user - ); - }); - - it('returns error when repository put fails for SMS', async () => { - const { client, mocks } = setup(); + it('persists proof request with resolved contact value', async () => { + const { client, mocks } = setup(); - mocks.templateRepository.get.mockResolvedValueOnce({ - data: smsTemplate, - }); + mocks.templateRepository.get.mockResolvedValueOnce({ + data: template, + }); - mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ - data: { - ...contactDetailVerified, - type: 'SMS', - value: '+447890123456', - }, - }); + mocks.contactDetailsRepository.getById.mockResolvedValueOnce({ + data: contactDetail, + }); - const failure = { - error: { - errorMeta: { - code: 500, - description: 'DDB error', - }, - }, - }; + const expectedProof: ProofRequest = { + ...proofRequest, + templateType: template.templateType, + contactDetailValue: contactDetail.value, + }; - mocks.proofRequestRepository.put.mockResolvedValueOnce(failure); + mocks.proofRequestRepository.put.mockResolvedValueOnce({ + data: expectedProof, + }); - const result = await client.create( - templateId, - { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, - user - ); + const result = await client.create( + templateId, + { testPatientNhsNumber: nhsNumber, contactDetailId: 'cd-id' }, + user + ); - expect(result).toBe(failure); - }); - }); + expect(result).toEqual({ data: expectedProof }); + expect(mocks.proofRequestRepository.put).toHaveBeenCalledWith( + { + templateId: templateId, + templateType: template.templateType, + contactDetailValue: contactDetail.value, + testPatientNhsNumber: nhsNumber, + personalisation: undefined, + }, + user + ); + expect(mocks.contactDetailsRepository.getById).toHaveBeenCalledWith( + 'cd-id', + user + ); + }); + } + ); }); }); diff --git a/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts b/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts index defe11130f..d02202855a 100644 --- a/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts @@ -65,6 +65,7 @@ describe('ProofRequestRepository', () => { createdAt: NOW.toISOString(), owner: `INTERNAL_USER#${user.internalUserId}`, }, + ConditionExpression: 'attribute_not_exists(id)', }); }); diff --git a/lambdas/backend-api/src/api/create-proofing-request.ts b/lambdas/backend-api/src/api/create-proofing-request.ts index 2ad2a01763..b1ef21ee5a 100644 --- a/lambdas/backend-api/src/api/create-proofing-request.ts +++ b/lambdas/backend-api/src/api/create-proofing-request.ts @@ -2,6 +2,7 @@ import type { APIGatewayProxyHandler } from 'aws-lambda'; import { logger } from 'nhs-notify-web-template-management-utils/logger'; import type { ProofingRequestClient } from '@backend-api/app/proofing-request-client'; import { apiFailure } from '@backend-api/api/responses'; +import { ErrorCase } from 'nhs-notify-backend-client/types'; type Dependencies = { proofingRequestClient: ProofingRequestClient; @@ -13,22 +14,20 @@ export function createHandler({ return async function handler(event) { const { internalUserId, clientId } = event.requestContext.authorizer ?? {}; - if (!internalUserId || !clientId) { - return apiFailure(400, 'Invalid request'); - } - const user = { internalUserId, clientId }; const templateId = event.pathParameters?.templateId; - if (!templateId) { - return apiFailure(400, 'templateId is required'); + const log = logger.child({ ...user, templateId }); + + if (!internalUserId || !clientId || !templateId) { + log.error('Invalid event received from API Gateway'); + + return apiFailure(ErrorCase.INTERNAL, 'Invalid request'); } const payload = JSON.parse(event.body ?? '{}'); - const log = logger.child(user); - const { data, error } = await proofingRequestClient.create( templateId, payload, diff --git a/lambdas/backend-api/src/app/proofing-request-client.ts b/lambdas/backend-api/src/app/proofing-request-client.ts index f0c7aa6cf7..167015200b 100644 --- a/lambdas/backend-api/src/app/proofing-request-client.ts +++ b/lambdas/backend-api/src/app/proofing-request-client.ts @@ -56,7 +56,7 @@ export class ProofingRequestClient { if (templateType === 'LETTER') { return failure( - 400, + ErrorCase.VALIDATION_FAILED, 'Proofing requests are not supported for letter templates' ); } @@ -70,7 +70,10 @@ export class ProofingRequestClient { digitalProofingFeatureFlagMap[templateType] ] ) { - return failure(403, 'Digital proofing is not enabled'); + return failure( + ErrorCase.FEATURE_DISABLED, + 'Digital proofing is not enabled' + ); } switch (templateType) { @@ -95,8 +98,8 @@ export class ProofingRequestClient { ): Promise> { if (contactDetailId) { return failure( - 400, - 'contactDetailId is not accepted for NHS app templates' + ErrorCase.VALIDATION_FAILED, + 'contactDetailId is not accepted for NHS App templates' ); } @@ -123,7 +126,10 @@ export class ProofingRequestClient { user: User ): Promise> { if (!contactDetailId) { - return failure(400, 'contactDetailId is required'); + return failure( + ErrorCase.VALIDATION_FAILED, + 'contactDetailId is required' + ); } const contactDetailResult = await this.contactDetailsRepository.getById( @@ -138,11 +144,17 @@ export class ProofingRequestClient { const contactDetail = contactDetailResult.data; if (contactDetail.type !== templateType) { - return failure(ErrorCase.INTERNAL, 'Contact detail is not verified'); + return failure( + ErrorCase.VALIDATION_FAILED, + 'Contact detail is not valid for this channel' + ); } if (contactDetail.status !== 'VERIFIED') { - return failure(400, 'Contact detail is not verified'); + return failure( + ErrorCase.VALIDATION_FAILED, + 'Contact detail is not verified' + ); } return this.proofRequestRepository.put( diff --git a/lambdas/backend-api/src/infra/proof-request-repository.ts b/lambdas/backend-api/src/infra/proof-request-repository.ts index d37b467843..9d94a63442 100644 --- a/lambdas/backend-api/src/infra/proof-request-repository.ts +++ b/lambdas/backend-api/src/infra/proof-request-repository.ts @@ -32,6 +32,7 @@ export class ProofRequestRepository { ...record, owner: `INTERNAL_USER#${user.internalUserId}`, }, + ConditionExpression: 'attribute_not_exists(id)', }) ); diff --git a/lambdas/backend-client/src/__tests__/schemas/nhs-number-validation.test.ts b/lambdas/backend-client/src/__tests__/schemas/nhs-number-validation.test.ts index 43bb0eb759..707cd023fd 100644 --- a/lambdas/backend-client/src/__tests__/schemas/nhs-number-validation.test.ts +++ b/lambdas/backend-client/src/__tests__/schemas/nhs-number-validation.test.ts @@ -1,7 +1,6 @@ import { isValidNHSNumber, isTestNHSNumber, - isValidTestNHSNumber, } from '../../schemas/nhs-number-validation'; describe('isValidNHSNumber', () => { @@ -83,21 +82,3 @@ describe('isTestNHSNumber', () => { expect(isTestNHSNumber('')).toBe(false); }); }); - -describe('isValidTestNHSNumber', () => { - test('returns true for valid test NHS number', () => { - expect(isValidTestNHSNumber('9434765919')).toBe(true); - }); - - test('returns false for valid non-test NHS number', () => { - expect(isValidTestNHSNumber('4010232137')).toBe(false); - }); - - test('returns false for invalid NHS number starting with 9', () => { - expect(isValidTestNHSNumber('9434765918')).toBe(false); - }); - - test('returns false for empty string', () => { - expect(isValidTestNHSNumber('')).toBe(false); - }); -}); diff --git a/lambdas/backend-client/src/schemas/nhs-number-validation.ts b/lambdas/backend-client/src/schemas/nhs-number-validation.ts index cb3a6f67a0..a9133cd95e 100644 --- a/lambdas/backend-client/src/schemas/nhs-number-validation.ts +++ b/lambdas/backend-client/src/schemas/nhs-number-validation.ts @@ -41,11 +41,3 @@ export function isTestNHSNumber(nhsNumber: string): boolean { const cleaned = nhsNumber.replaceAll(/[\s-]/gu, ''); return cleaned.startsWith('9'); } - -/** - * Validates that an NHS Number is both structurally valid (Modulus 11) - * and a test number (starts with 9). - */ -export function isValidTestNHSNumber(nhsNumber: string): boolean { - return isValidNHSNumber(nhsNumber) && isTestNHSNumber(nhsNumber); -} diff --git a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts index 584e4cc151..4e8858d9ef 100644 --- a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts @@ -423,7 +423,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { expect(result).toEqual({ statusCode: 400, technicalMessage: - 'contactDetailId is not accepted for NHS app templates', + 'contactDetailId is not accepted for NHS App templates', }); }); @@ -712,5 +712,40 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { technicalMessage: 'Contact detail is not verified', }); }); + + test('returns 400 if contact detail type does not match template type', async ({ + request, + }) => { + const template = await createTemplate(request, 'EMAIL'); + + const contactDetail = makeVerifiedContactDetail({ + type: 'SMS', + value: '+447700900999', + owner: user.internalUserId, + }); + await contactDetailHelper.seed([contactDetail]); + + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + contactDetailId: contactDetail.id, + }, + } + ); + + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); + + expect(response.status(), debug).toBe(400); + expect(result).toEqual({ + statusCode: 400, + technicalMessage: 'Contact detail is not valid for this channel', + }); + }); }); }); From 047e00f6922eb23bfd9eb1d2d68c90901c7aafef Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Thu, 14 May 2026 07:51:09 +0100 Subject: [PATCH 15/28] nhs number api tests --- .../create-proofing-request.api.spec.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts index 4e8858d9ef..0648a07d43 100644 --- a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts @@ -398,6 +398,67 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { }); }); + test('returns 400 if testPatientNhsNumber fails format check', async ({ + request, + }) => { + const template = await createTemplate(request, 'NHS_APP'); + + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '900000000*', + }, + } + ); + + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); + + expect(response.status(), debug).toBe(400); + expect(result).toEqual({ + statusCode: 400, + technicalMessage: 'Request failed validation', + details: { + testPatientNhsNumber: 'Invalid NHS number', + }, + }); + }); + + test('returns 400 if testPatientNhsNumber is not a test number', async ({ + request, + }) => { + const template = await createTemplate(request, 'NHS_APP'); + + const response = await request.post( + `${process.env.API_BASE_URL}/v1/template/${template.id}/proofing-request`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + testPatientNhsNumber: '4010232137', + }, + } + ); + + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); + + expect(response.status(), debug).toBe(400); + expect(result).toEqual({ + statusCode: 400, + technicalMessage: 'Request failed validation', + details: { + testPatientNhsNumber: + 'NHS number must be a test number (starting with 9)', + }, + }); + }); + test('returns 400 if contactDetailId is provided for NHS_APP template', async ({ request, }) => { From 6ec77e450b87e23a531058c915f1d24ad70090ba Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Thu, 14 May 2026 08:45:04 +0100 Subject: [PATCH 16/28] use normal z.refine --- lambdas/backend-client/src/schemas/proof-request.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lambdas/backend-client/src/schemas/proof-request.ts b/lambdas/backend-client/src/schemas/proof-request.ts index 0cd9776486..66fa468b59 100644 --- a/lambdas/backend-client/src/schemas/proof-request.ts +++ b/lambdas/backend-client/src/schemas/proof-request.ts @@ -7,14 +7,14 @@ import { isValidNHSNumber, isTestNHSNumber } from './nhs-number-validation'; export const $CreateProofingRequest: z.ZodType = z.object({ - testPatientNhsNumber: z.string().check( - z.refine((val) => isValidNHSNumber(val), { + testPatientNhsNumber: z + .string() + .refine((val) => isValidNHSNumber(val), { message: 'Invalid NHS number', - }), - z.refine((val) => isTestNHSNumber(val), { - message: 'NHS number must be a test number (starting with 9)', }) - ), + .refine((val) => isTestNHSNumber(val), { + message: 'NHS number must be a test number (starting with 9)', + }), contactDetailId: z.string().optional(), personalisation: z.record(z.string(), z.string()).optional(), }); From f21203772ec0b0983c0f2184eae071c13afd2c6e Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Thu, 14 May 2026 12:53:38 +0100 Subject: [PATCH 17/28] add createdBy --- .../modules/backend-api/spec.tmpl.json | 6 ++++- .../api/create-proofing-request.test.ts | 1 + .../app/proofing-request-client.test.ts | 1 + .../src/__tests__/fixtures/template.ts | 4 +-- .../infra/proof-request-repository.test.ts | 3 +++ .../src/infra/proof-request-repository.ts | 11 +++++--- .../__tests__/schemas/proof-request.test.ts | 1 + .../src/schemas/proof-request.ts | 26 +++++++++---------- packages/types/src/types.gen.ts | 1 + .../create-proofing-request.api.spec.ts | 6 +++++ 10 files changed, 40 insertions(+), 20 deletions(-) diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index 6884bf8310..1833a0bfca 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -1070,6 +1070,9 @@ "format": "date-time", "type": "string" }, + "createdBy": { + "type": "string" + }, "id": { "format": "uuid", "type": "string" @@ -1102,7 +1105,8 @@ "templateType", "contactDetailValue", "testPatientNhsNumber", - "createdAt" + "createdAt", + "createdBy" ], "type": "object" }, diff --git a/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts b/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts index fb1ca4beba..e84f886665 100644 --- a/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts +++ b/lambdas/backend-api/src/__tests__/api/create-proofing-request.test.ts @@ -141,6 +141,7 @@ describe('Create Proofing Request Handler', () => { contactDetailValue: '9000000009', testPatientNhsNumber: '9000000009', createdAt: '2024-01-01T00:00:00.000Z', + createdBy: 'INTERNAL_USER#user-id', }; mocks.proofingRequestClient.create.mockResolvedValueOnce({ diff --git a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts index a386f12455..7665597339 100644 --- a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts @@ -41,6 +41,7 @@ const proofRequest: ProofRequest = { contactDetailValue: nhsNumber, testPatientNhsNumber: nhsNumber, createdAt: '2024-01-01T00:00:00.000Z', + createdBy: 'INTERNAL_USER#user-id', }; const contactDetailVerifiedEmail: ContactDetail = { diff --git a/lambdas/backend-api/src/__tests__/fixtures/template.ts b/lambdas/backend-api/src/__tests__/fixtures/template.ts index fb1fa6630d..7c2e524405 100644 --- a/lambdas/backend-api/src/__tests__/fixtures/template.ts +++ b/lambdas/backend-api/src/__tests__/fixtures/template.ts @@ -184,9 +184,7 @@ export const makePdfLetterTemplate = ( }; export const makeLetterTemplate = ( - overrides: Partial< - TemplateDto & WithAttachments - > = {} + overrides: Partial = {} ): TemplateFixture => { const createUpdateTemplate = { ...createTemplateProperties, diff --git a/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts b/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts index d02202855a..d073516196 100644 --- a/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts @@ -55,6 +55,7 @@ describe('ProofRequestRepository', () => { ...requestParams, id: randomUuid, createdAt: NOW.toISOString(), + createdBy: `INTERNAL_USER#${user.internalUserId}`, }); expect(mocks.dynamodb).toHaveReceivedCommandWith(PutCommand, { @@ -63,6 +64,7 @@ describe('ProofRequestRepository', () => { ...requestParams, id: randomUuid, createdAt: NOW.toISOString(), + createdBy: `INTERNAL_USER#${user.internalUserId}`, owner: `INTERNAL_USER#${user.internalUserId}`, }, ConditionExpression: 'attribute_not_exists(id)', @@ -85,6 +87,7 @@ describe('ProofRequestRepository', () => { ...paramsPersonalised, id: randomUuid, createdAt: NOW.toISOString(), + createdBy: `INTERNAL_USER#${user.internalUserId}`, }); }); diff --git a/lambdas/backend-api/src/infra/proof-request-repository.ts b/lambdas/backend-api/src/infra/proof-request-repository.ts index 9d94a63442..6f6414aee5 100644 --- a/lambdas/backend-api/src/infra/proof-request-repository.ts +++ b/lambdas/backend-api/src/infra/proof-request-repository.ts @@ -2,7 +2,10 @@ import { randomUUID } from 'node:crypto'; import { PutCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { $ProofRequest } from 'nhs-notify-backend-client/schemas'; import { ErrorCase } from 'nhs-notify-backend-client/types'; -import type { ProofRequest } from 'nhs-notify-web-template-management-types'; +import type { + CreateProofingRequest, + ProofRequest, +} from 'nhs-notify-web-template-management-types'; import type { User } from 'nhs-notify-web-template-management-utils'; import { failure, success, type ApplicationResult } from '@backend-api/utils'; @@ -13,15 +16,17 @@ export class ProofRequestRepository { ) {} async put( - params: Omit, + params: CreateProofingRequest, user: User ): Promise> { const now = new Date().toISOString(); + const userKey = `INTERNAL_USER#${user.internalUserId}`; const record = $ProofRequest.parse({ ...params, id: randomUUID(), createdAt: now, + createdBy: userKey, }); try { @@ -30,7 +35,7 @@ export class ProofRequestRepository { TableName: this.tableName, Item: { ...record, - owner: `INTERNAL_USER#${user.internalUserId}`, + owner: userKey, }, ConditionExpression: 'attribute_not_exists(id)', }) diff --git a/lambdas/backend-client/src/__tests__/schemas/proof-request.test.ts b/lambdas/backend-client/src/__tests__/schemas/proof-request.test.ts index 617e3b17a9..1d97f027f5 100644 --- a/lambdas/backend-client/src/__tests__/schemas/proof-request.test.ts +++ b/lambdas/backend-client/src/__tests__/schemas/proof-request.test.ts @@ -77,6 +77,7 @@ describe('$ProofRequest', () => { contactDetailValue: '9000000009', testPatientNhsNumber: '9000000009', createdAt: '2024-01-01T00:00:00.000Z', + createdBy: 'INTERNAL_USER#user-id', }; test('passes validation with required fields', () => { diff --git a/lambdas/backend-client/src/schemas/proof-request.ts b/lambdas/backend-client/src/schemas/proof-request.ts index 66fa468b59..282848998f 100644 --- a/lambdas/backend-client/src/schemas/proof-request.ts +++ b/lambdas/backend-client/src/schemas/proof-request.ts @@ -5,19 +5,18 @@ import type { } from 'nhs-notify-web-template-management-types'; import { isValidNHSNumber, isTestNHSNumber } from './nhs-number-validation'; -export const $CreateProofingRequest: z.ZodType = - z.object({ - testPatientNhsNumber: z - .string() - .refine((val) => isValidNHSNumber(val), { - message: 'Invalid NHS number', - }) - .refine((val) => isTestNHSNumber(val), { - message: 'NHS number must be a test number (starting with 9)', - }), - contactDetailId: z.string().optional(), - personalisation: z.record(z.string(), z.string()).optional(), - }); +export const $CreateProofingRequest: z.ZodType = z.object({ + testPatientNhsNumber: z + .string() + .refine((val) => isValidNHSNumber(val), { + message: 'Invalid NHS number', + }) + .refine((val) => isTestNHSNumber(val), { + message: 'NHS number must be a test number (starting with 9)', + }), + contactDetailId: z.string().optional(), + personalisation: z.record(z.string(), z.string()).optional(), +}); export const $ProofRequest: z.ZodType = z.object({ id: z.string(), @@ -27,4 +26,5 @@ export const $ProofRequest: z.ZodType = z.object({ testPatientNhsNumber: z.string(), personalisation: z.record(z.string(), z.string()).optional(), createdAt: z.string(), + createdBy: z.string(), }); diff --git a/packages/types/src/types.gen.ts b/packages/types/src/types.gen.ts index 29a66e25f3..a746a2bd66 100644 --- a/packages/types/src/types.gen.ts +++ b/packages/types/src/types.gen.ts @@ -328,6 +328,7 @@ export type ProofFileDetails = { export type ProofRequest = { contactDetailValue: string; createdAt: string; + createdBy: string; id: string; personalisation?: { [key: string]: string; diff --git a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts index 0648a07d43..1b6417b483 100644 --- a/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/create-proofing-request.api.spec.ts @@ -105,6 +105,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { contactDetailValue: '9000000009', testPatientNhsNumber: '9000000009', createdAt: expect.stringMatching(isoDateRegExp), + createdBy: `INTERNAL_USER#${user.internalUserId}`, }, }); @@ -150,6 +151,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { contactDetailValue: 'test@example.com', testPatientNhsNumber: '9000000009', createdAt: expect.stringMatching(isoDateRegExp), + createdBy: `INTERNAL_USER#${user.internalUserId}`, }, }); @@ -195,6 +197,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { contactDetailValue: '+447700900000', testPatientNhsNumber: '9000000009', createdAt: expect.stringMatching(isoDateRegExp), + createdBy: `INTERNAL_USER#${user.internalUserId}`, }, }); @@ -241,6 +244,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { testPatientNhsNumber: '9000000009', personalisation, createdAt: expect.stringMatching(isoDateRegExp), + createdBy: `INTERNAL_USER#${user.internalUserId}`, }, }); @@ -292,6 +296,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { testPatientNhsNumber: '9000000009', personalisation, createdAt: expect.stringMatching(isoDateRegExp), + createdBy: `INTERNAL_USER#${user.internalUserId}`, }, }); @@ -343,6 +348,7 @@ test.describe('POST /v1/template/:templateId/proofing-request', () => { testPatientNhsNumber: '9000000009', personalisation, createdAt: expect.stringMatching(isoDateRegExp), + createdBy: `INTERNAL_USER#${user.internalUserId}`, }, }); From 0dcc0c099a0e7d4a22f45decf6553749ff647613 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Thu, 14 May 2026 13:00:56 +0100 Subject: [PATCH 18/28] type fix --- lambdas/backend-api/src/infra/proof-request-repository.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lambdas/backend-api/src/infra/proof-request-repository.ts b/lambdas/backend-api/src/infra/proof-request-repository.ts index 6f6414aee5..a979db98e1 100644 --- a/lambdas/backend-api/src/infra/proof-request-repository.ts +++ b/lambdas/backend-api/src/infra/proof-request-repository.ts @@ -2,10 +2,7 @@ import { randomUUID } from 'node:crypto'; import { PutCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { $ProofRequest } from 'nhs-notify-backend-client/schemas'; import { ErrorCase } from 'nhs-notify-backend-client/types'; -import type { - CreateProofingRequest, - ProofRequest, -} from 'nhs-notify-web-template-management-types'; +import type { ProofRequest } from 'nhs-notify-web-template-management-types'; import type { User } from 'nhs-notify-web-template-management-utils'; import { failure, success, type ApplicationResult } from '@backend-api/utils'; @@ -16,7 +13,7 @@ export class ProofRequestRepository { ) {} async put( - params: CreateProofingRequest, + params: Omit, user: User ): Promise> { const now = new Date().toISOString(); From 9d4c921b31ac71ae4c696790f02f2d4e418931b7 Mon Sep 17 00:00:00 2001 From: "alex.nuttall1" Date: Thu, 14 May 2026 13:08:34 +0100 Subject: [PATCH 19/28] fmt --- .../src/schemas/proof-request.ts | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/lambdas/backend-client/src/schemas/proof-request.ts b/lambdas/backend-client/src/schemas/proof-request.ts index 282848998f..53051fd078 100644 --- a/lambdas/backend-client/src/schemas/proof-request.ts +++ b/lambdas/backend-client/src/schemas/proof-request.ts @@ -5,18 +5,19 @@ import type { } from 'nhs-notify-web-template-management-types'; import { isValidNHSNumber, isTestNHSNumber } from './nhs-number-validation'; -export const $CreateProofingRequest: z.ZodType = z.object({ - testPatientNhsNumber: z - .string() - .refine((val) => isValidNHSNumber(val), { - message: 'Invalid NHS number', - }) - .refine((val) => isTestNHSNumber(val), { - message: 'NHS number must be a test number (starting with 9)', - }), - contactDetailId: z.string().optional(), - personalisation: z.record(z.string(), z.string()).optional(), -}); +export const $CreateProofingRequest: z.ZodType = + z.object({ + testPatientNhsNumber: z + .string() + .refine((val) => isValidNHSNumber(val), { + message: 'Invalid NHS number', + }) + .refine((val) => isTestNHSNumber(val), { + message: 'NHS number must be a test number (starting with 9)', + }), + contactDetailId: z.string().optional(), + personalisation: z.record(z.string(), z.string()).optional(), + }); export const $ProofRequest: z.ZodType = z.object({ id: z.string(), From af6c0ba67110ea63f2606230d9fc5cf3159a729a Mon Sep 17 00:00:00 2001 From: "muhammed.salaudeen1" Date: Thu, 14 May 2026 16:06:32 +0100 Subject: [PATCH 20/28] CCM-18132: Add get endpoint to spec --- .../app/test-message-confirmation/page.tsx | 25 +++++++ .../iam_role_api_gateway_execution_role.tf | 1 + .../modules/backend-api/spec.tmpl.json | 74 +++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 frontend/src/app/test-message-confirmation/page.tsx diff --git a/frontend/src/app/test-message-confirmation/page.tsx b/frontend/src/app/test-message-confirmation/page.tsx new file mode 100644 index 0000000000..9e6ea7991b --- /dev/null +++ b/frontend/src/app/test-message-confirmation/page.tsx @@ -0,0 +1,25 @@ +'use server'; + +import { ChooseTemplateType } from '@forms/ChooseTemplateType/ChooseTemplateType'; +import { Metadata } from 'next'; +import { TEMPLATE_TYPE_LIST } from 'nhs-notify-backend-client/schemas'; +import content from '@content/content'; +import { NHSNotifyContainer } from '@layouts/container/container'; + +const { pageTitle } = content.components.chooseTemplateType; + +export async function generateMetadata(): Promise { + return { + title: pageTitle, + }; +} + +const NHSAppTestMessageConfirmationPage = async () => { + return ( + + + + ); +}; + +export default NHSAppTestMessageConfirmationPage; diff --git a/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf index bdd1ad2a10..acfecc6fb6 100644 --- a/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf +++ b/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf @@ -65,6 +65,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" { module.get_routing_config_lambda.function_arn, module.get_routing_configs_by_template_id_lambda.function_arn, module.get_template_lambda.function_arn, + module.get_proofing_request_lambda.function_arn, module.get_template_letter_variants_lambda.function_arn, module.list_routing_configs_lambda.function_arn, module.list_template_lambda.function_arn, diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index 1833a0bfca..967b51145d 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -3122,6 +3122,80 @@ } } }, + "/v1/template/{templateId}/proofing-request/{proofingRequestId}": { + "get": { + "description": "Get the status of a proofing request for a digital-channel template", + "parameters": [ + { + "description": "ID of the template the proofing request was made for", + "in": "path", + "name": "templateId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "ID of the proofing request to get", + "in": "path", + "name": "proofingRequestId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProofRequestSuccess" + } + } + }, + "description": "200 response", + "headers": { + "Content-Type": { + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Failure" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authorizer": [] + } + ], + "summary": "Get the status of a proofing request for a digital-channel template", + "x-amazon-apigateway-integration": { + "contentHandling": "CONVERT_TO_TEXT", + "credentials": "${APIG_EXECUTION_ROLE_ARN}", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "responses": { + ".*": { + "statusCode": "200" + } + }, + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${GET_PROOFING_REQUEST_STATUS_LAMBDA_ARN}/invocations" + } + } + }, "/v1/template/{templateId}/routing-configurations": { "get": { "description": "Get all routing configurations that reference a specific template", From 19dd4358c4745a5d812afb67e1d8df745850045c Mon Sep 17 00:00:00 2001 From: "muhammed.salaudeen1" Date: Fri, 15 May 2026 17:21:36 +0100 Subject: [PATCH 21/28] CCM-18132: Add GET by ID endpoint for proofing request --- .../terraform/modules/backend-api/locals.tf | 1 + .../module_get_proofing_request_lambda.tf | 67 +++++++++++++++++++ .../modules/backend-api/spec.tmpl.json | 8 +-- lambdas/backend-api/README.md | 12 ++++ lambdas/backend-api/build.sh | 1 + .../src/api/get-proofing-request.ts | 48 +++++++++++++ lambdas/backend-api/src/api/responses.ts | 2 + .../src/app/proofing-request-client.ts | 7 ++ .../backend-api/src/get-proofing-request.ts | 4 ++ .../src/infra/proof-request-repository.ts | 41 +++++++++++- packages/types/src/index.ts | 5 ++ packages/types/src/types.gen.ts | 36 ++++++++++ 12 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 infrastructure/terraform/modules/backend-api/module_get_proofing_request_lambda.tf create mode 100644 lambdas/backend-api/src/api/get-proofing-request.ts create mode 100644 lambdas/backend-api/src/get-proofing-request.ts diff --git a/infrastructure/terraform/modules/backend-api/locals.tf b/infrastructure/terraform/modules/backend-api/locals.tf index 74b1636edc..fcbd09fa47 100644 --- a/infrastructure/terraform/modules/backend-api/locals.tf +++ b/infrastructure/terraform/modules/backend-api/locals.tf @@ -31,6 +31,7 @@ locals { GET_ROUTING_CONFIGS_BY_TEMPLATE_ID_LAMBDA_ARN = module.get_routing_configs_by_template_id_lambda.function_arn GET_TEMPLATE_LAMBDA_ARN = module.get_template_lambda.function_arn GET_TEMPLATE_LETTER_VARIANTS_LAMBDA_ARN = module.get_template_letter_variants_lambda.function_arn + GET_PROOFING_REQUEST_LAMBDA_ARN = module.get_proofing_request_lambda.function_arn LIST_ROUTING_CONFIGS_LAMBDA_ARN = module.list_routing_configs_lambda.function_arn LIST_TEMPLATES_LAMBDA_ARN = module.list_template_lambda.function_arn PATCH_TEMPLATE_LAMBDA_ARN = module.patch_template_lambda.function_arn diff --git a/infrastructure/terraform/modules/backend-api/module_get_proofing_request_lambda.tf b/infrastructure/terraform/modules/backend-api/module_get_proofing_request_lambda.tf new file mode 100644 index 0000000000..113e262062 --- /dev/null +++ b/infrastructure/terraform/modules/backend-api/module_get_proofing_request_lambda.tf @@ -0,0 +1,67 @@ +module "get_proofing_request_lambda" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.6/terraform-lambda.zip" + + project = var.project + environment = var.environment + component = var.component + aws_account_id = var.aws_account_id + region = var.region + + kms_key_arn = var.kms_key_arn + + function_name = "get_routing_config_lambda-proofing-request" + + function_module_name = "get-proofing-request" + handler_function_name = "handler" + description = "Get proofing request API endpoint" + + memory = 2048 + timeout = 20 + runtime = "nodejs22.x" + + log_retention_in_days = var.log_retention_in_days + iam_policy_document = { + body = data.aws_iam_policy_document.get_proofing_request.json + } + + lambda_env_vars = local.backend_lambda_environment_variables + function_s3_bucket = var.function_s3_bucket + function_code_base_path = local.lambdas_dir + function_code_dir = "backend-api/dist/get-proofing-request" + + send_to_firehose = var.send_to_firehose + log_destination_arn = var.log_destination_arn + log_subscription_role_arn = var.log_subscription_role_arn +} + +data "aws_iam_policy_document" "get_proofing_request" { + statement { + sid = "AllowKMSAccess" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:DescribeKey", + "kms:Encrypt", + "kms:GenerateDataKey*", + "kms:ReEncrypt*", + ] + + resources = [ + var.kms_key_arn + ] + } + + statement { + sid = "AllowProofingRequestsRead" + effect = "Allow" + + actions = [ + "dynamodb:GetItem", + ] + + resources = [ + aws_dynamodb_table.proof_requests.arn, + ] + } +} diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index 967b51145d..0e29a98caf 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -3122,9 +3122,9 @@ } } }, - "/v1/template/{templateId}/proofing-request/{proofingRequestId}": { + "/v1/proofing-request/{proofingRequestId}": { "get": { - "description": "Get the status of a proofing request for a digital-channel template", + "description": "Get the proofing request for a digital-channel template", "parameters": [ { "description": "ID of the template the proofing request was made for", @@ -3179,7 +3179,7 @@ "authorizer": [] } ], - "summary": "Get the status of a proofing request for a digital-channel template", + "summary": "Get the proofing request for a digital-channel template", "x-amazon-apigateway-integration": { "contentHandling": "CONVERT_TO_TEXT", "credentials": "${APIG_EXECUTION_ROLE_ARN}", @@ -3192,7 +3192,7 @@ }, "timeoutInMillis": 29000, "type": "AWS_PROXY", - "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${GET_PROOFING_REQUEST_STATUS_LAMBDA_ARN}/invocations" + "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${GET_PROOFING_REQUEST_LAMBDA_ARN}/invocations" } } }, diff --git a/lambdas/backend-api/README.md b/lambdas/backend-api/README.md index e3185c0ea3..028147077d 100644 --- a/lambdas/backend-api/README.md +++ b/lambdas/backend-api/README.md @@ -320,6 +320,18 @@ curl -X POST --location "${APIG_STAGE}/v1/template/${TEMPLATE_ID}/proofing-reque }' ``` +### GET - /v1/proofing-request/:proofingRequestId - Get a digital proofing request. by id + +Creates a digital proof request for a non-LETTER template + +**NHS_APP example:** + +```bash +curl -X GET --location "${APIG_STAGE}/v1/proofing-request/${PROOF_REQUEST_ID}" \ +--header 'Accept: application/json' \ +--header "Authorization: $SANDBOX_TOKEN" +``` + **EMAIL/SMS example:** ```bash diff --git a/lambdas/backend-api/build.sh b/lambdas/backend-api/build.sh index b709333df1..2d3596ff7a 100755 --- a/lambdas/backend-api/build.sh +++ b/lambdas/backend-api/build.sh @@ -30,6 +30,7 @@ npx esbuild \ src/get-routing-config.ts \ src/get-routing-configs-by-template-id.ts \ src/get-template-letter-variants.ts \ + src/get-proofing-request.ts \ src/get.ts \ src/generate-letter-proof.ts \ src/list-routing-configs.ts \ diff --git a/lambdas/backend-api/src/api/get-proofing-request.ts b/lambdas/backend-api/src/api/get-proofing-request.ts new file mode 100644 index 0000000000..0e1a509a4e --- /dev/null +++ b/lambdas/backend-api/src/api/get-proofing-request.ts @@ -0,0 +1,48 @@ +import type { APIGatewayProxyHandler } from 'aws-lambda'; +import { logger } from 'nhs-notify-web-template-management-utils/logger'; +import type { ProofingRequestClient } from '@backend-api/app/proofing-request-client'; +import { apiFailure, apiSuccess } from '@backend-api/api/responses'; +import { ErrorCase } from 'nhs-notify-backend-client/types'; + +type Dependencies = { + proofingRequestClient: ProofingRequestClient; +}; + +export function createHandler({ + proofingRequestClient, +}: Dependencies): APIGatewayProxyHandler { + return async function handler(event) { + const { internalUserId, clientId } = event.requestContext.authorizer ?? {}; + + const user = { internalUserId, clientId }; + + const proofingRequestId = event.pathParameters?.proofingRequestId; + + const log = logger.child({ ...user, proofingRequestId }); + + if (!internalUserId || !clientId || !proofingRequestId) { + log.error('Invalid event received from API Gateway'); + + return apiFailure(ErrorCase.INTERNAL, 'Invalid request'); + } + + const { data, error } = await proofingRequestClient.get( + proofingRequestId, + user + ); + + if (error) { + log + .child(error.errorMeta) + .error('Failed to fetch proofing request', error.actualError); + + return apiFailure( + error.errorMeta.code, + error.errorMeta.description, + error.errorMeta.details + ); + } + + return apiSuccess(200, data); + }; +} diff --git a/lambdas/backend-api/src/api/responses.ts b/lambdas/backend-api/src/api/responses.ts index d03e10cbcb..a6b0b61993 100644 --- a/lambdas/backend-api/src/api/responses.ts +++ b/lambdas/backend-api/src/api/responses.ts @@ -5,6 +5,7 @@ import type { RoutingConfigReference, LetterVariant, ContactDetail, + ProofRequest, } from 'nhs-notify-web-template-management-types'; type Count = { count: number }; @@ -15,6 +16,7 @@ export const apiSuccess = < | Count | LetterVariant | LetterVariant[] + | ProofRequest | RoutingConfig | RoutingConfig[] | RoutingConfigReference[] diff --git a/lambdas/backend-api/src/app/proofing-request-client.ts b/lambdas/backend-api/src/app/proofing-request-client.ts index 167015200b..a043adbf64 100644 --- a/lambdas/backend-api/src/app/proofing-request-client.ts +++ b/lambdas/backend-api/src/app/proofing-request-client.ts @@ -87,6 +87,13 @@ export class ProofingRequestClient { } } + async get( + proofingRequestId: string, + user: User + ): Promise> { + return await this.proofRequestRepository.getById(proofingRequestId, user); + } + private async handleApp( { contactDetailId, diff --git a/lambdas/backend-api/src/get-proofing-request.ts b/lambdas/backend-api/src/get-proofing-request.ts new file mode 100644 index 0000000000..9399bfb1b7 --- /dev/null +++ b/lambdas/backend-api/src/get-proofing-request.ts @@ -0,0 +1,4 @@ +import { createHandler } from '@backend-api/api/get-proofing-request'; +import { proofingRequestContainer } from '@backend-api/container/proofing-request'; + +export const handler = createHandler(proofingRequestContainer()); diff --git a/lambdas/backend-api/src/infra/proof-request-repository.ts b/lambdas/backend-api/src/infra/proof-request-repository.ts index a979db98e1..15aff63b0d 100644 --- a/lambdas/backend-api/src/infra/proof-request-repository.ts +++ b/lambdas/backend-api/src/infra/proof-request-repository.ts @@ -1,5 +1,9 @@ import { randomUUID } from 'node:crypto'; -import { PutCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { + GetCommand, + PutCommand, + type DynamoDBDocumentClient, +} from '@aws-sdk/lib-dynamodb'; import { $ProofRequest } from 'nhs-notify-backend-client/schemas'; import { ErrorCase } from 'nhs-notify-backend-client/types'; import type { ProofRequest } from 'nhs-notify-web-template-management-types'; @@ -47,4 +51,39 @@ export class ProofRequestRepository { ); } } + + async getById( + proofingRequestId: string, + user: User + ): Promise> { + try { + const response = await this.dynamodb.send( + new GetCommand({ + TableName: this.tableName, + Key: { + id: proofingRequestId, + owner: this.getOwnerKey(user), + }, + }) + ); + + if (!response?.Item) { + return failure(ErrorCase.NOT_FOUND, 'Proofing request not found.'); + } + + const proofRequestItem = response.Item as ProofRequest; + + return success(proofRequestItem); + } catch (error) { + return failure( + ErrorCase.INTERNAL, + 'Error fetching proofing request from database.', + error + ); + } + } + + private getOwnerKey(user: User) { + return `INTERNAL_USER#${user.internalUserId}`; + } } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 687da0295e..e98fad57b4 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -56,6 +56,11 @@ export type { GetV1LetterVariantByLetterVariantIdErrors, GetV1LetterVariantByLetterVariantIdResponse, GetV1LetterVariantByLetterVariantIdResponses, + GetV1ProofingRequestByProofingRequestIdData, + GetV1ProofingRequestByProofingRequestIdError, + GetV1ProofingRequestByProofingRequestIdErrors, + GetV1ProofingRequestByProofingRequestIdResponse, + GetV1ProofingRequestByProofingRequestIdResponses, GetV1RoutingConfigurationByRoutingConfigIdData, GetV1RoutingConfigurationByRoutingConfigIdError, GetV1RoutingConfigurationByRoutingConfigIdErrors, diff --git a/packages/types/src/types.gen.ts b/packages/types/src/types.gen.ts index a746a2bd66..ec642e3b8a 100644 --- a/packages/types/src/types.gen.ts +++ b/packages/types/src/types.gen.ts @@ -1217,6 +1217,42 @@ export type PostV1TemplateByTemplateIdProofingRequestResponses = { export type PostV1TemplateByTemplateIdProofingRequestResponse = PostV1TemplateByTemplateIdProofingRequestResponses[keyof PostV1TemplateByTemplateIdProofingRequestResponses]; +export type GetV1ProofingRequestByProofingRequestIdData = { + body?: never; + path: { + /** + * ID of the template the proofing request was made for + */ + templateId: string; + /** + * ID of the proofing request to get + */ + proofingRequestId: string; + }; + query?: never; + url: '/v1/proofing-request/{proofingRequestId}'; +}; + +export type GetV1ProofingRequestByProofingRequestIdErrors = { + /** + * Error + */ + default: Failure; +}; + +export type GetV1ProofingRequestByProofingRequestIdError = + GetV1ProofingRequestByProofingRequestIdErrors[keyof GetV1ProofingRequestByProofingRequestIdErrors]; + +export type GetV1ProofingRequestByProofingRequestIdResponses = { + /** + * 200 response + */ + 200: ProofRequestSuccess; +}; + +export type GetV1ProofingRequestByProofingRequestIdResponse = + GetV1ProofingRequestByProofingRequestIdResponses[keyof GetV1ProofingRequestByProofingRequestIdResponses]; + export type GetV1TemplateByTemplateIdRoutingConfigurationsData = { body?: never; path: { From 503688c5543fcba1d2dc92d025a57b6496c37097 Mon Sep 17 00:00:00 2001 From: "muhammed.salaudeen1" Date: Mon, 18 May 2026 16:30:12 +0100 Subject: [PATCH 22/28] CCM-18132: Add unit and component tests --- .../app/test-message-confirmation/page.tsx | 25 --- .../modules/backend-api/spec.tmpl.json | 9 - .../api/get-proofing-request.test.ts | 174 ++++++++++++++++ .../app/proofing-request-client.test.ts | 57 ++++++ .../infra/proof-request-repository.test.ts | 62 +++++- .../src/infra/proof-request-repository.ts | 2 +- packages/types/src/types.gen.ts | 4 - .../get-proofing-request.api.spec.ts | 185 ++++++++++++++++++ 8 files changed, 478 insertions(+), 40 deletions(-) delete mode 100644 frontend/src/app/test-message-confirmation/page.tsx create mode 100644 lambdas/backend-api/src/__tests__/api/get-proofing-request.test.ts create mode 100644 tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts diff --git a/frontend/src/app/test-message-confirmation/page.tsx b/frontend/src/app/test-message-confirmation/page.tsx deleted file mode 100644 index 9e6ea7991b..0000000000 --- a/frontend/src/app/test-message-confirmation/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use server'; - -import { ChooseTemplateType } from '@forms/ChooseTemplateType/ChooseTemplateType'; -import { Metadata } from 'next'; -import { TEMPLATE_TYPE_LIST } from 'nhs-notify-backend-client/schemas'; -import content from '@content/content'; -import { NHSNotifyContainer } from '@layouts/container/container'; - -const { pageTitle } = content.components.chooseTemplateType; - -export async function generateMetadata(): Promise { - return { - title: pageTitle, - }; -} - -const NHSAppTestMessageConfirmationPage = async () => { - return ( - - - - ); -}; - -export default NHSAppTestMessageConfirmationPage; diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index af0e465309..b9c781302f 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -3282,15 +3282,6 @@ "get": { "description": "Get the proofing request for a digital-channel template", "parameters": [ - { - "description": "ID of the template the proofing request was made for", - "in": "path", - "name": "templateId", - "required": true, - "schema": { - "type": "string" - } - }, { "description": "ID of the proofing request to get", "in": "path", diff --git a/lambdas/backend-api/src/__tests__/api/get-proofing-request.test.ts b/lambdas/backend-api/src/__tests__/api/get-proofing-request.test.ts new file mode 100644 index 0000000000..02624826d2 --- /dev/null +++ b/lambdas/backend-api/src/__tests__/api/get-proofing-request.test.ts @@ -0,0 +1,174 @@ +import type { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import { mock } from 'jest-mock-extended'; +import type { ProofRequest } from 'nhs-notify-web-template-management-types'; +import { createHandler } from '@backend-api/api/get-proofing-request'; +import type { ProofingRequestClient } from '@backend-api/app/proofing-request-client'; + +function setup() { + const proofingRequestClient = mock(); + const mocks = { proofingRequestClient }; + const handler = createHandler(mocks); + + return { handler, mocks }; +} + +describe('Get Proofing Request Handler', () => { + beforeEach(jest.resetAllMocks); + + test.each([ + [ + 'authorizer is undefined', + undefined, + { proofingRequestId: 'proofing-request-123' }, + ], + [ + 'internalUserId is missing', + { clientId: 'client-id', internalUserId: undefined }, + { proofingRequestId: 'proofing-request-123' }, + ], + [ + 'clientId is missing', + { clientId: undefined, internalUserId: 'user-1234' }, + { proofingRequestId: 'proofing-request-123' }, + ], + [ + 'proofingRequestId is missing', + { clientId: 'client-id', internalUserId: 'user-1234' }, + { proofingRequestId: undefined }, + ], + ])( + 'should return 500 - Invalid request when %s', + async (_, ctx, pathParameters) => { + const { handler, mocks } = setup(); + + const event = mock({ + requestContext: { authorizer: ctx }, + pathParameters, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + statusCode: 500, + technicalMessage: 'Invalid request', + }), + }); + + expect(mocks.proofingRequestClient.get).not.toHaveBeenCalled(); + } + ); + + test('should return error when client returns error', async () => { + const { handler, mocks } = setup(); + + mocks.proofingRequestClient.get.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 500, + description: 'Internal server error', + }, + }, + }); + + const event = mock({ + requestContext: { + authorizer: { internalUserId: 'user-1234', clientId: 'client-id' }, + }, + pathParameters: { proofingRequestId: 'proofing-request-123' }, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + statusCode: 500, + technicalMessage: 'Internal server error', + }), + }); + + expect(mocks.proofingRequestClient.get).toHaveBeenCalledWith( + 'proofing-request-123', + { internalUserId: 'user-1234', clientId: 'client-id' } + ); + }); + + test('should pass validation error details from client', async () => { + const { handler, mocks } = setup(); + + mocks.proofingRequestClient.get.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 400, + description: 'Request failed validation', + details: { + proofingRequestId: 'Required', + }, + }, + }, + }); + + const event = mock({ + requestContext: { + authorizer: { internalUserId: 'user-1234', clientId: 'client-id' }, + }, + pathParameters: { proofingRequestId: 'proofing-request-123' }, + body: undefined, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Request failed validation', + details: { + proofingRequestId: 'Required', + }, + }), + }); + }); + + test('should return 200 with proof request', async () => { + const { handler, mocks } = setup(); + + const proofRequest: ProofRequest = { + contactDetailValue: 'test-patient-nhs-number', + createdAt: new Date().toISOString(), + createdBy: 'user-1234', + id: 'proofing-request-123', + templateId: 'template-123', + templateType: 'NHS_APP', + testPatientNhsNumber: 'test-patient-nhs-number', + }; + + mocks.proofingRequestClient.get.mockResolvedValueOnce({ + data: proofRequest, + }); + + const event = mock({ + requestContext: { + authorizer: { + internalUserId: 'user-1234', + clientId: 'client-id', + }, + }, + pathParameters: { proofingRequestId: 'proofing-request-123' }, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ statusCode: 200, data: proofRequest }), + }); + + expect(mocks.proofingRequestClient.get).toHaveBeenCalledWith( + 'proofing-request-123', + { internalUserId: 'user-1234', clientId: 'client-id' } + ); + }); +}); diff --git a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts index f449029d3e..515e03e68e 100644 --- a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts @@ -513,4 +513,61 @@ describe('ProofingRequestClient', () => { } ); }); + + describe('get', () => { + it('returns error when repository lookup fails', async () => { + const { client, mocks } = setup(); + + const failure = { + error: { + errorMeta: { + code: 500, + description: 'Error fetching proofing request from database.', + }, + }, + }; + + mocks.proofRequestRepository.getById.mockResolvedValueOnce(failure); + + const result = await client.get('proofing-request-123', user); + + expect(result).toBe(failure); + }); + + it('returns proof request for proofingRequestId', async () => { + const { client, mocks } = setup(); + + mocks.proofRequestRepository.getById.mockResolvedValueOnce({ + data: proofRequest, + }); + + const result = await client.get('proofing-request-123', user); + + expect(result).toEqual({ data: proofRequest }); + }); + + it('returns 404 when proofing request is not found', async () => { + const { client, mocks } = setup(); + + mocks.proofRequestRepository.getById.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 404, + description: 'Proofing request not found.', + }, + }, + }); + + const result = await client.get('proofing-request-12', user); + + expect(result).toEqual({ + error: { + errorMeta: { + code: 404, + description: 'Proofing request not found.', + }, + }, + }); + }); + }); }); diff --git a/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts b/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts index d073516196..f691eef966 100644 --- a/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/proof-request-repository.test.ts @@ -1,5 +1,9 @@ import crypto from 'node:crypto'; -import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { + DynamoDBDocumentClient, + GetCommand, + PutCommand, +} from '@aws-sdk/lib-dynamodb'; import { mockClient } from 'aws-sdk-client-mock'; import 'aws-sdk-client-mock-jest'; import type { User } from 'nhs-notify-web-template-management-utils'; @@ -105,4 +109,60 @@ describe('ProofRequestRepository', () => { ); }); }); + + describe('getById', () => { + it('returns proof request when found', async () => { + const { repo, mocks } = setup(); + + const proofRequestItem = { + ...requestParams, + id: randomUuid, + createdAt: NOW.toISOString(), + createdBy: `INTERNAL_USER#${user.internalUserId}`, + }; + + mocks.dynamodb.on(GetCommand).resolvesOnce({ + Item: proofRequestItem, + }); + + const result = await repo.getById(randomUuid, user); + + expect(result.data).toEqual(proofRequestItem); + expect(mocks.dynamodb).toHaveReceivedCommandWith(GetCommand, { + TableName: tableName, + Key: { + id: randomUuid, + owner: `INTERNAL_USER#${user.internalUserId}`, + }, + }); + }); + + it('returns not found error when no record found', async () => { + const { repo, mocks } = setup(); + + mocks.dynamodb.on(GetCommand).resolvesOnce({}); + + const result = await repo.getById(randomUuid, user); + + expect(result.data).toBeUndefined(); + expect(result.error?.errorMeta.code).toBe(404); + expect(result.error?.errorMeta.description).toBe( + 'Proofing request not found.' + ); + }); + + it('returns internal error when DynamoDB read fails', async () => { + const { repo, mocks } = setup(); + + mocks.dynamodb.on(GetCommand).rejects(new Error('DynamoDB error')); + + const result = await repo.getById(randomUuid, user); + + expect(result.data).toBeUndefined(); + expect(result.error?.errorMeta.code).toBe(500); + expect(result.error?.errorMeta.description).toBe( + 'Error fetching proofing request from database.' + ); + }); + }); }); diff --git a/lambdas/backend-api/src/infra/proof-request-repository.ts b/lambdas/backend-api/src/infra/proof-request-repository.ts index 15aff63b0d..0675d93481 100644 --- a/lambdas/backend-api/src/infra/proof-request-repository.ts +++ b/lambdas/backend-api/src/infra/proof-request-repository.ts @@ -71,7 +71,7 @@ export class ProofRequestRepository { return failure(ErrorCase.NOT_FOUND, 'Proofing request not found.'); } - const proofRequestItem = response.Item as ProofRequest; + const proofRequestItem = $ProofRequest.parse(response.Item); return success(proofRequestItem); } catch (error) { diff --git a/packages/types/src/types.gen.ts b/packages/types/src/types.gen.ts index e51340c59a..7ecaf44997 100644 --- a/packages/types/src/types.gen.ts +++ b/packages/types/src/types.gen.ts @@ -1294,10 +1294,6 @@ export type PostV1TemplateByTemplateIdProofingRequestResponse = export type GetV1ProofingRequestByProofingRequestIdData = { body?: never; path: { - /** - * ID of the template the proofing request was made for - */ - templateId: string; /** * ID of the proofing request to get */ diff --git a/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts b/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts new file mode 100644 index 0000000000..40f3b0cda8 --- /dev/null +++ b/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts @@ -0,0 +1,185 @@ +import { test, expect, APIRequestContext } from '@playwright/test'; +import { type TestUser, testUsers } from '../helpers/auth/cognito-auth-helper'; +import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; +import { ContactDetailHelper } from '../helpers/db/contact-details-helper'; +import { + isoDateRegExp, + uuidRegExp, +} from 'nhs-notify-web-template-management-test-helper-utils'; +import { TemplateAPIPayloadFactory } from '../helpers/factories/template-api-payload-factory'; +import { getTestContext } from 'helpers/context/context'; +import { ProofRequest } from 'nhs-notify-web-template-management-types'; + +test.describe('GET /v1/proofing-request/:proofingRequestId', () => { + const context = getTestContext(); + const templateStorageHelper = new TemplateStorageHelper(); + const contactDetailHelper = new ContactDetailHelper(); + + let user: TestUser; + + test.beforeAll(async () => { + user = await context.auth.getTestUser( + testUsers.UserDigitalProofingEnabled.userId + ); + }); + + test.afterAll(async () => { + await templateStorageHelper.deleteAdHocTemplates(); + await templateStorageHelper.deleteSeededTemplates(); + await contactDetailHelper.cleanup(); + }); + + const createTemplate = async ( + request: APIRequestContext, + templateType: 'NHS_APP' | 'EMAIL' | 'SMS', + testUser: TestUser = user + ) => { + const createResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template`, + { + headers: { + Authorization: await testUser.getAccessToken(), + }, + data: TemplateAPIPayloadFactory.getCreateTemplatePayload({ + templateType, + }), + } + ); + + expect(createResponse.status()).toBe(201); + const created = await createResponse.json(); + + templateStorageHelper.addAdHocTemplateKey({ + templateId: created.data.id, + clientId: testUser.clientId, + }); + + return created.data; + }; + + const createProofingRequest = async ( + request: APIRequestContext, + templateId: string, + personalisation?: Record, + testUser: TestUser = user + ) => { + const proofingResponse = await request.post( + `${process.env.API_BASE_URL}/v1/template/${templateId}/proofing-request`, + { + headers: { + Authorization: await testUser.getAccessToken(), + }, + data: { + testPatientNhsNumber: '9000000009', + personalisation, + }, + } + ); + + expect(proofingResponse.status()).toBe(201); + const result = await proofingResponse.json(); + + return result.data as ProofRequest; + }; + + test.describe('happy path', () => { + test('returns statusCode 200 with proofing request', async ({ + request, + }) => { + const template = await createTemplate(request, 'NHS_APP'); + const createdProofingRequest = await createProofingRequest( + request, + template.id + ); + + const response = await request.get( + `${process.env.API_BASE_URL}/v1/proofing-request/${createdProofingRequest.id}`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + } + ); + + expect(response.status()).toBe(200); + + const result = await response.json(); + + expect(result).toEqual({ + statusCode: 200, + data: createdProofingRequest, + }); + }); + + test('returns statusCode 200 with proofing request with personalisation', async ({ + request, + }) => { + const template = await createTemplate(request, 'NHS_APP'); + + const createdProofingRequest = await createProofingRequest( + request, + template.id, + { + name: 'John', + } + ); + + const response = await request.get( + `${process.env.API_BASE_URL}/v1/proofing-request/${createdProofingRequest.id}`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + } + ); + + expect(response.status()).toBe(200); + + const result = await response.json(); + + expect(result).toEqual({ + statusCode: 200, + data: expect.objectContaining({ + ...createdProofingRequest, + id: expect.stringMatching(uuidRegExp), + createdAt: expect.stringMatching(isoDateRegExp), + }), + }); + }); + }); + + test.describe('auth', () => { + test('returns 401 if no auth token', async ({ request }) => { + const response = await request.get( + `${process.env.API_BASE_URL}/v1/proofing-request/anu-proof-id` + ); + + expect(response.status()).toBe(401); + expect(await response.json()).toEqual({ + message: 'Unauthorized', + }); + }); + }); + + test.describe('validation', () => { + test('returns 404 if proofingRequestId is invalid', async ({ request }) => { + const response = await request.get( + `${process.env.API_BASE_URL}/v1/proofing-request/invalid-id`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + } + ); + + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); + + expect(response.status(), debug).toBe(404); + expect(result).toEqual({ + statusCode: 404, + technicalMessage: 'Proofing request not found.', + }); + }); + }); +}); From 0a6597b446ee605f2372deee12d60df7f1297e8b Mon Sep 17 00:00:00 2001 From: "muhammed.salaudeen1" Date: Tue, 19 May 2026 09:36:07 +0100 Subject: [PATCH 23/28] CCM-18132: Update terraform docs --- .../terraform/components/sandbox/README.md | 19 +++++++++++++++++++ .../terraform/modules/backend-api/README.md | 1 + 2 files changed, 20 insertions(+) create mode 100644 infrastructure/terraform/components/sandbox/README.md diff --git a/infrastructure/terraform/components/sandbox/README.md b/infrastructure/terraform/components/sandbox/README.md new file mode 100644 index 0000000000..7ea5d7c399 --- /dev/null +++ b/infrastructure/terraform/components/sandbox/README.md @@ -0,0 +1,19 @@ + + + + +## Requirements + +No requirements. +## Inputs + +No inputs. +## Modules + +No modules. +## Outputs + +No outputs. + + + \ No newline at end of file diff --git a/infrastructure/terraform/modules/backend-api/README.md b/infrastructure/terraform/modules/backend-api/README.md index 84d15d2697..f65135b446 100644 --- a/infrastructure/terraform/modules/backend-api/README.md +++ b/infrastructure/terraform/modules/backend-api/README.md @@ -56,6 +56,7 @@ No requirements. | [get\_client\_lambda](#module\_get\_client\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [get\_contact\_details\_lambda](#module\_get\_contact\_details\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/4.0.0/terraform-lambda.zip | n/a | | [get\_letter\_variant\_lambda](#module\_get\_letter\_variant\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | +| [get\_proofing\_request\_lambda](#module\_get\_proofing\_request\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.6/terraform-lambda.zip | n/a | | [get\_routing\_config\_lambda](#module\_get\_routing\_config\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [get\_routing\_configs\_by\_template\_id\_lambda](#module\_get\_routing\_configs\_by\_template\_id\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [get\_template\_lambda](#module\_get\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | From 02af03dbf1cf873f0b1b754e6ccac50e8bcd0236 Mon Sep 17 00:00:00 2001 From: "muhammed.salaudeen1" Date: Tue, 19 May 2026 09:45:09 +0100 Subject: [PATCH 24/28] CCM-18132: Update terraform docs --- infrastructure/terraform/components/sandbox/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/terraform/components/sandbox/README.md b/infrastructure/terraform/components/sandbox/README.md index 7ea5d7c399..df8c1f5c06 100644 --- a/infrastructure/terraform/components/sandbox/README.md +++ b/infrastructure/terraform/components/sandbox/README.md @@ -16,4 +16,4 @@ No modules. No outputs. - \ No newline at end of file + From d66e762a103d7773b69c4ee24f6b301b322f9838 Mon Sep 17 00:00:00 2001 From: "muhammed.salaudeen1" Date: Tue, 19 May 2026 10:55:09 +0100 Subject: [PATCH 25/28] CCM-18132: Delete sandbox readme in infrastructure directory --- .../terraform/components/sandbox/README.md | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 infrastructure/terraform/components/sandbox/README.md diff --git a/infrastructure/terraform/components/sandbox/README.md b/infrastructure/terraform/components/sandbox/README.md deleted file mode 100644 index df8c1f5c06..0000000000 --- a/infrastructure/terraform/components/sandbox/README.md +++ /dev/null @@ -1,19 +0,0 @@ - - - - -## Requirements - -No requirements. -## Inputs - -No inputs. -## Modules - -No modules. -## Outputs - -No outputs. - - - From cb761747b6f741447c0219626af28251edb11a35 Mon Sep 17 00:00:00 2001 From: "muhammed.salaudeen1" Date: Tue, 19 May 2026 16:00:22 +0100 Subject: [PATCH 26/28] CCM-18132: Fix review comments --- lambdas/backend-api/README.md | 22 +++--- .../app/proofing-request-client.test.ts | 26 +------ .../src/infra/proof-request-repository.ts | 2 +- .../get-proofing-request.api.spec.ts | 70 ++++++++++++++++--- 4 files changed, 71 insertions(+), 49 deletions(-) diff --git a/lambdas/backend-api/README.md b/lambdas/backend-api/README.md index 4760714df7..8425397f0e 100644 --- a/lambdas/backend-api/README.md +++ b/lambdas/backend-api/README.md @@ -336,18 +336,6 @@ curl -X POST --location "${APIG_STAGE}/v1/template/${TEMPLATE_ID}/proofing-reque }' ``` -### GET - /v1/proofing-request/:proofingRequestId - Get a digital proofing request. by id - -Creates a digital proof request for a non-LETTER template - -**NHS_APP example:** - -```bash -curl -X GET --location "${APIG_STAGE}/v1/proofing-request/${PROOF_REQUEST_ID}" \ ---header 'Accept: application/json' \ ---header "Authorization: $SANDBOX_TOKEN" -``` - **EMAIL/SMS example:** ```bash @@ -364,4 +352,14 @@ curl -X POST --location "${APIG_STAGE}/v1/template/${TEMPLATE_ID}/proofing-reque }' ``` +### GET - /v1/proofing-request/:proofingRequestId - Get a digital proofing request by id + +Gets a digital proof request by id + +```bash +curl -X GET --location "${APIG_STAGE}/v1/proofing-request/${PROOF_REQUEST_ID}" \ +--header 'Accept: application/json' \ +--header "Authorization: $SANDBOX_TOKEN" +``` + diff --git a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts index 515e03e68e..414fca6d15 100644 --- a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts @@ -515,7 +515,7 @@ describe('ProofingRequestClient', () => { }); describe('get', () => { - it('returns error when repository lookup fails', async () => { + it('returns error when repository returns error', async () => { const { client, mocks } = setup(); const failure = { @@ -545,29 +545,5 @@ describe('ProofingRequestClient', () => { expect(result).toEqual({ data: proofRequest }); }); - - it('returns 404 when proofing request is not found', async () => { - const { client, mocks } = setup(); - - mocks.proofRequestRepository.getById.mockResolvedValueOnce({ - error: { - errorMeta: { - code: 404, - description: 'Proofing request not found.', - }, - }, - }); - - const result = await client.get('proofing-request-12', user); - - expect(result).toEqual({ - error: { - errorMeta: { - code: 404, - description: 'Proofing request not found.', - }, - }, - }); - }); }); }); diff --git a/lambdas/backend-api/src/infra/proof-request-repository.ts b/lambdas/backend-api/src/infra/proof-request-repository.ts index 0675d93481..ff5e9a0976 100644 --- a/lambdas/backend-api/src/infra/proof-request-repository.ts +++ b/lambdas/backend-api/src/infra/proof-request-repository.ts @@ -21,7 +21,7 @@ export class ProofRequestRepository { user: User ): Promise> { const now = new Date().toISOString(); - const userKey = `INTERNAL_USER#${user.internalUserId}`; + const userKey = this.getOwnerKey(user); const record = $ProofRequest.parse({ ...params, diff --git a/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts b/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts index 40f3b0cda8..01e2f06ae2 100644 --- a/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts @@ -9,6 +9,7 @@ import { import { TemplateAPIPayloadFactory } from '../helpers/factories/template-api-payload-factory'; import { getTestContext } from 'helpers/context/context'; import { ProofRequest } from 'nhs-notify-web-template-management-types'; +import { makeVerifiedContactDetail } from 'helpers/factories/contact-details-factory'; test.describe('GET /v1/proofing-request/:proofingRequestId', () => { const context = getTestContext(); @@ -16,11 +17,16 @@ test.describe('GET /v1/proofing-request/:proofingRequestId', () => { const contactDetailHelper = new ContactDetailHelper(); let user: TestUser; + let userSharedClient: TestUser; test.beforeAll(async () => { user = await context.auth.getTestUser( testUsers.UserDigitalProofingEnabled.userId ); + + userSharedClient = await context.auth.getTestUser( + testUsers.UserDigitalProofingEnabledSharedClient.userId + ); }); test.afterAll(async () => { @@ -32,7 +38,7 @@ test.describe('GET /v1/proofing-request/:proofingRequestId', () => { const createTemplate = async ( request: APIRequestContext, templateType: 'NHS_APP' | 'EMAIL' | 'SMS', - testUser: TestUser = user + testUser: TestUser ) => { const createResponse = await request.post( `${process.env.API_BASE_URL}/v1/template`, @@ -60,8 +66,9 @@ test.describe('GET /v1/proofing-request/:proofingRequestId', () => { const createProofingRequest = async ( request: APIRequestContext, templateId: string, + testUser: TestUser, personalisation?: Record, - testUser: TestUser = user + contactDetailId?: string ) => { const proofingResponse = await request.post( `${process.env.API_BASE_URL}/v1/template/${templateId}/proofing-request`, @@ -72,11 +79,13 @@ test.describe('GET /v1/proofing-request/:proofingRequestId', () => { data: { testPatientNhsNumber: '9000000009', personalisation, + contactDetailId, }, } ); - expect(proofingResponse.status()).toBe(201); + const debug = JSON.stringify(await proofingResponse.json()); + expect(proofingResponse.status(), debug).toBe(201); const result = await proofingResponse.json(); return result.data as ProofRequest; @@ -86,10 +95,11 @@ test.describe('GET /v1/proofing-request/:proofingRequestId', () => { test('returns statusCode 200 with proofing request', async ({ request, }) => { - const template = await createTemplate(request, 'NHS_APP'); + const template = await createTemplate(request, 'NHS_APP', user); const createdProofingRequest = await createProofingRequest( request, - template.id + template.id, + user ); const response = await request.get( @@ -114,11 +124,12 @@ test.describe('GET /v1/proofing-request/:proofingRequestId', () => { test('returns statusCode 200 with proofing request with personalisation', async ({ request, }) => { - const template = await createTemplate(request, 'NHS_APP'); + const template = await createTemplate(request, 'NHS_APP', user); const createdProofingRequest = await createProofingRequest( request, template.id, + user, { name: 'John', } @@ -139,11 +150,7 @@ test.describe('GET /v1/proofing-request/:proofingRequestId', () => { expect(result).toEqual({ statusCode: 200, - data: expect.objectContaining({ - ...createdProofingRequest, - id: expect.stringMatching(uuidRegExp), - createdAt: expect.stringMatching(isoDateRegExp), - }), + data: createdProofingRequest, }); }); }); @@ -182,4 +189,45 @@ test.describe('GET /v1/proofing-request/:proofingRequestId', () => { }); }); }); + + test.describe('contact details', () => { + test('returns 404 if contact detail is owned by a different user', async ({ + request, + }) => { + const template = await createTemplate(request, 'EMAIL', userSharedClient); + + const contactDetail = makeVerifiedContactDetail({ + type: 'EMAIL', + value: 'other-user@example.com', + owner: userSharedClient.internalUserId, + }); + await contactDetailHelper.seed([contactDetail]); + + const proofingRequest = await createProofingRequest( + request, + template.id, + userSharedClient, + undefined, + contactDetail.id + ); + + const response = await request.get( + `${process.env.API_BASE_URL}/v1/proofing-request/${proofingRequest.id}`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + } + ); + + const result = await response.json(); + const debug = JSON.stringify(result, null, 2); + + expect(response.status(), debug).toBe(404); + expect(result).toEqual({ + statusCode: 404, + technicalMessage: 'Proofing request not found.', + }); + }); + }); }); From 739de30bdf141f3c7b612660cdb674b5ab193ef9 Mon Sep 17 00:00:00 2001 From: "muhammed.salaudeen1" Date: Tue, 19 May 2026 16:08:43 +0100 Subject: [PATCH 27/28] CCM-18132: Fix review comments --- .../template-mgmt-api-tests/get-proofing-request.api.spec.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts b/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts index 01e2f06ae2..67d45bca8e 100644 --- a/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts @@ -2,10 +2,6 @@ import { test, expect, APIRequestContext } from '@playwright/test'; import { type TestUser, testUsers } from '../helpers/auth/cognito-auth-helper'; import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; import { ContactDetailHelper } from '../helpers/db/contact-details-helper'; -import { - isoDateRegExp, - uuidRegExp, -} from 'nhs-notify-web-template-management-test-helper-utils'; import { TemplateAPIPayloadFactory } from '../helpers/factories/template-api-payload-factory'; import { getTestContext } from 'helpers/context/context'; import { ProofRequest } from 'nhs-notify-web-template-management-types'; From df3d3f75f5d9854fafb952c6bbb78e951b7621e3 Mon Sep 17 00:00:00 2001 From: "muhammed.salaudeen1" Date: Tue, 19 May 2026 16:38:42 +0100 Subject: [PATCH 28/28] CCM-18132: Fix review comments --- .../get-proofing-request.api.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts b/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts index 67d45bca8e..ae911c9fe7 100644 --- a/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/get-proofing-request.api.spec.ts @@ -165,7 +165,9 @@ test.describe('GET /v1/proofing-request/:proofingRequestId', () => { }); test.describe('validation', () => { - test('returns 404 if proofingRequestId is invalid', async ({ request }) => { + test('returns 404 if no proofing request exists with the given proofingRequestId', async ({ + request, + }) => { const response = await request.get( `${process.env.API_BASE_URL}/v1/proofing-request/invalid-id`, { @@ -186,7 +188,7 @@ test.describe('GET /v1/proofing-request/:proofingRequestId', () => { }); }); - test.describe('contact details', () => { + test.describe('ownership', () => { test('returns 404 if contact detail is owned by a different user', async ({ request, }) => {