From 5854a75d608c423ad87c2c7582f3cb7d2a07ed35 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 28 Apr 2026 16:54:11 +0100 Subject: [PATCH] feat(payment): persist CYA confirmation email to state for GOV.UK Pay (DF-832) DF-832 requires the email pre-populated on GOV.UK Pay to come from the CYA confirmation email field. Persist userConfirmationEmailAddress to state on CYA POST so the engine can read it at payment dispatch and outputService can read it on auto-submit. --- package-lock.json | 70 ++++------ ...ageWithConfirmationEmailController.test.ts | 120 ++++++++++++++++++ ...maryPageWithConfirmationEmailController.ts | 13 ++ src/server/services/outputService.test.js | 18 +-- src/server/services/outputService.ts | 2 +- 5 files changed, 169 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index e908d1afa..712b12298 100644 --- a/package-lock.json +++ b/package-lock.json @@ -747,14 +747,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.21.tgz", - "integrity": "sha512-qxNiHUtlrsjTeSlrPWiFkWps7uD6YB4eKzg7eLAFH8jbiHTlt0ePNlo2Xu+WlftP38JIcMaIX4jTUjOlE2ySWw==", + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.17.tgz", + "integrity": "sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg==", "license": "Apache-2.0", "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.2", + "@smithy/types": "^4.14.0", + "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" }, "engines": { @@ -4159,9 +4158,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.651", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.651.tgz", - "integrity": "sha512-fEf7cldtT+4gthN1Na2l57S6H4oL9CdOfAus3W1Q8KXP9PbU5kuc8NY8jHCsa87PlwJL86q2v93FS14pFQZ2Bg==", + "version": "3.0.649", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.649.tgz", + "integrity": "sha512-zmJlDrPeBFjNaF26Zv1Gxm79J4Kgqu90mns3N59LCNQnEYFwb0zgmSEam9xbMltHDjbAtbdm5R7/RnxMbzPZMg==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -8164,18 +8163,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@node-rs/jieba": { "version": "1.10.4", "resolved": "https://registry.npmjs.org/@node-rs/jieba/-/jieba-1.10.4.tgz", @@ -9478,9 +9465,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", - "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.0.tgz", + "integrity": "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -12928,9 +12915,9 @@ } }, "node_modules/basic-ftp": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", - "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.2.tgz", + "integrity": "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -18042,9 +18029,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", - "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", @@ -18057,9 +18044,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", - "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", + "version": "5.5.11", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.11.tgz", + "integrity": "sha512-QL0eb0YbSTVWF6tTf1+LEMSgtCEjBYPpnAjoLC8SscESlAjXEIRJ7cHtLG0pLeDFaZLa4VKZLArtA/60ZS7vyA==", "funding": [ { "type": "github", @@ -18068,9 +18055,8 @@ ], "license": "MIT", "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.5", - "path-expression-matcher": "^1.5.0", + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.4.0", "strnum": "^2.2.3" }, "bin": { @@ -22519,9 +22505,9 @@ } }, "node_modules/liquidjs": { - "version": "10.25.7", - "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.25.7.tgz", - "integrity": "sha512-rPCjJLiD4eDhQjvv964AeXFC+HbeYBbZrd7Z82Q6hqv1lX7G+5w4SJcKLn9CAAAwHI4aS3dTdo083UB79K3pDA==", + "version": "10.25.5", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.25.5.tgz", + "integrity": "sha512-GKiKeZjJDdVoQAu+S9rzkYsYnYhcep5W3WwZXgb5f+yq484P/k9JqamBbGYu+LBEixcUAXZr2jogdAIjB3ki1w==", "license": "MIT", "dependencies": { "commander": "^10.0.0" @@ -26919,9 +26905,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", diff --git a/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts b/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts index c6bc44e50..0ef5cd695 100644 --- a/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts +++ b/src/server/plugins/SummaryPageWithConfirmationEmailController.test.ts @@ -12,6 +12,7 @@ import { ControllerType, type PageSummaryWithConfirmationEmail } from '@defra/forms-model' +import { type ResponseObject } from '@hapi/hapi' import { SummaryPageWithConfirmationEmailController, @@ -129,6 +130,125 @@ describe('SummaryPageWithConfirmationEmailController', () => { }) }) + describe('persists userConfirmationEmailAddress to state', () => { + const responseStub = {} as ResponseObject + + it('should mergeState with the email when payload provides it', async () => { + const state: FormSubmissionState = { + $$__referenceNumber: 'foobar', + licenceLength: 365, + fullName: 'John Smith' + } + const mergedState: FormSubmissionState = { + $$__referenceNumber: 'foobar', + licenceLength: 365, + fullName: 'John Smith', + userConfirmationEmailAddress: 'cya@example.com' + } + const request = { + ...requestPage, + method: 'post', + payload: { + action: 'send', + userConfirmationEmailAddress: 'cya@example.com' + } + } as unknown as FormRequestPayload + + const context = model.getFormContext(request, state) + + const mergeStateSpy = jest + .spyOn(controller, 'mergeState') + .mockResolvedValue(mergedState) + jest.spyOn(controller, 'handleFormSubmit').mockResolvedValue(responseStub) + + const postHandler = controller.makePostRouteHandler() + await postHandler(request, context, h) + + expect(mergeStateSpy).toHaveBeenCalledWith(request, expect.any(Object), { + userConfirmationEmailAddress: 'cya@example.com' + }) + expect(context.state.userConfirmationEmailAddress).toBe('cya@example.com') + }) + + it('should not mergeState when payload omits the email', async () => { + const state: FormSubmissionState = { + $$__referenceNumber: 'foobar', + licenceLength: 365, + fullName: 'John Smith' + } + const request = { + ...requestPage, + method: 'post', + payload: { action: 'send' } + } as unknown as FormRequestPayload + + const context = model.getFormContext(request, state) + + const mergeStateSpy = jest + .spyOn(controller, 'mergeState') + .mockResolvedValue(state) + jest.spyOn(controller, 'handleFormSubmit').mockResolvedValue(responseStub) + + const postHandler = controller.makePostRouteHandler() + await postHandler(request, context, h) + + expect(mergeStateSpy).not.toHaveBeenCalled() + }) + + it('should not mergeState when payload provides an empty email', async () => { + const state: FormSubmissionState = { + $$__referenceNumber: 'foobar', + licenceLength: 365, + fullName: 'John Smith' + } + const request = { + ...requestPage, + method: 'post', + payload: { action: 'send', userConfirmationEmailAddress: '' } + } as unknown as FormRequestPayload + + const context = model.getFormContext(request, state) + + const mergeStateSpy = jest + .spyOn(controller, 'mergeState') + .mockResolvedValue(state) + jest.spyOn(controller, 'handleFormSubmit').mockResolvedValue(responseStub) + + const postHandler = controller.makePostRouteHandler() + await postHandler(request, context, h) + + expect(mergeStateSpy).not.toHaveBeenCalled() + }) + + it('should propagate the error when mergeState rejects', async () => { + const state: FormSubmissionState = { + $$__referenceNumber: 'foobar', + licenceLength: 365, + fullName: 'John Smith' + } + const request = { + ...requestPage, + method: 'post', + payload: { + action: 'send', + userConfirmationEmailAddress: 'cya@example.com' + } + } as unknown as FormRequestPayload + + const context = model.getFormContext(request, state) + + const cacheError = new Error('cache unavailable') + jest.spyOn(controller, 'mergeState').mockRejectedValue(cacheError) + const handleFormSubmitSpy = jest + .spyOn(controller, 'handleFormSubmit') + .mockResolvedValue(responseStub) + + const postHandler = controller.makePostRouteHandler() + await expect(postHandler(request, context, h)).rejects.toBe(cacheError) + expect(handleFormSubmitSpy).not.toHaveBeenCalled() + }) + }) + describe('getUserConfirmationEmailAddress', () => { test('should get confirmation email', () => { const field = getUserConfirmationEmailAddress() diff --git a/src/server/plugins/SummaryPageWithConfirmationEmailController.ts b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts index f80caf6fe..57963e851 100644 --- a/src/server/plugins/SummaryPageWithConfirmationEmailController.ts +++ b/src/server/plugins/SummaryPageWithConfirmationEmailController.ts @@ -102,6 +102,19 @@ export class SummaryPageWithConfirmationEmailController extends SummaryPageContr return h.view(viewName, viewModel) } + const userConfirmationEmailAddress = + request.payload[CONFIRMATION_EMAIL_FIELD_NAME] + if ( + typeof userConfirmationEmailAddress === 'string' && + userConfirmationEmailAddress + ) { + context.state = await ( + this as unknown as QuestionPageController + ).mergeState(request, context.state, { + [CONFIRMATION_EMAIL_FIELD_NAME]: userConfirmationEmailAddress + }) + } + // Should not have to coerce the type - ticket to resolve later https://eaflood.atlassian.net/browse/DF-555 return (this as unknown as SummaryPageController).handleFormSubmit( request, diff --git a/src/server/services/outputService.test.js b/src/server/services/outputService.test.js index 3099a9db3..4c946b012 100644 --- a/src/server/services/outputService.test.js +++ b/src/server/services/outputService.test.js @@ -177,20 +177,16 @@ describe('OutputService', () => { data: mockItems } - const mockRequestWithEmail = /** @type {FormRequestPayload} */ ( - /** @type {unknown} */ ({ - ...mockRequest, - payload: { - userConfirmationEmailAddress: 'my-email@test123.com' - } - }) - ) + const mockContextWithEmail = { + ...mockContext, + state: { userConfirmationEmailAddress: 'my-email@test123.com' } + } mockFormatter.mockReturnValue(JSON.stringify(mockPayload)) await outputService.submit( - mockContext, - mockRequestWithEmail, + mockContextWithEmail, + mockRequest, mockModel, 'test@example.com', mockItems, @@ -201,7 +197,7 @@ describe('OutputService', () => { expect(checkFormStatus).toHaveBeenCalledWith(mockRequest.params) expect(getFormatter).toHaveBeenCalledWith('adapter', '1') expect(mockFormatter).toHaveBeenCalledWith( - mockContext, + mockContextWithEmail, mockItems, mockModel, mockSubmitResponse, diff --git a/src/server/services/outputService.ts b/src/server/services/outputService.ts index 95984d717..c2cdce35f 100644 --- a/src/server/services/outputService.ts +++ b/src/server/services/outputService.ts @@ -100,7 +100,7 @@ export class OutputService implements IOutputService { // Add user confirmation email if supplied const userConfirmationEmailAddress = - request.payload.userConfirmationEmailAddress + context.state.userConfirmationEmailAddress if (typeof userConfirmationEmailAddress === 'string') { customMeta.userConfirmationEmail = userConfirmationEmailAddress }