diff --git a/src/server/routes/copy-form.js b/src/server/routes/copy-form.js index c8bb7df4a..64fd4127a 100644 --- a/src/server/routes/copy-form.js +++ b/src/server/routes/copy-form.js @@ -117,14 +117,8 @@ export default [ handler: async (request, h) => { const { auth, params } = request const { accessToken } = auth.credentials - const { form, referenceNumber, submissionToCopy } = - await getFormAndSubmission(params, accessToken) - - const { translator } = await getTranslator( - request, - form, - submissionToCopy - ) + const { form, referenceNumber, submissionToCopy, translator } = + await getCopyFormContext(request, params, accessToken) const previewStatus = getPreviewStatus(submissionToCopy) const backLinkHref = constructHomepageLink(form.slug, previewStatus) @@ -153,18 +147,10 @@ export default [ const { accessToken } = auth.credentials const { form, referenceNumber, submissionToCopy } = await getFormAndSubmission(params, accessToken) + const { formDeclarations, formVersionHasChanged } = + await getFormComparisonState(submissionToCopy, form, referenceNumber) - const def = await getFormDefinitionForSubmission( - submissionToCopy, - form, - referenceNumber - ) - const { formHasDeclaration, formVersionHasChanged } = getFormDetails( - def, - submissionToCopy - ) - - if (formHasDeclaration || formVersionHasChanged) { + if (formDeclarations.length > 0 || formVersionHasChanged) { return h .redirect( `/confirm-copy-form/${form.id}/${referenceNumber}/form-changed` @@ -201,14 +187,8 @@ export default [ handler: async (request, h) => { const { auth, params } = request const { accessToken } = auth.credentials - const { form, referenceNumber, submissionToCopy } = - await getFormAndSubmission(params, accessToken) - - const { translator } = await getTranslator( - request, - form, - submissionToCopy - ) + const { form, referenceNumber, submissionToCopy, translator } = + await getCopyFormContext(request, params, accessToken) const model = copyFormViewModel( 'confirmCopyFormChanged.title', @@ -233,14 +213,8 @@ export default [ handler: async (request, h) => { const { auth, params } = request const { accessToken } = auth.credentials - const { form, referenceNumber, submissionToCopy } = - await getFormAndSubmission(params, accessToken) - - const { translator } = await getTranslator( - request, - form, - submissionToCopy - ) + const { form, referenceNumber, submissionToCopy, translator } = + await getCopyFormContext(request, params, accessToken) const model = copyFormViewModel( 'confirmCopyCheckAnswers.title', @@ -267,16 +241,12 @@ export default [ const { accessToken } = auth.credentials const { form, referenceNumber, submissionToCopy } = await getFormAndSubmission(params, accessToken) - - const def = await getFormDefinitionForSubmission( - submissionToCopy, - form, - referenceNumber - ) - const { formVersionHasChanged } = getFormDetails(def, submissionToCopy) + const { formDeclarations, formVersionHasChanged } = + await getFormComparisonState(submissionToCopy, form, referenceNumber) + const ignoreKeys = formDeclarations.map((declaration) => declaration.name) // Set state and dispatch user into the form - await setCacheState(request, submissionToCopy, form) + await setCacheState(request, submissionToCopy, form, ignoreKeys) // If the form version has changed, redirect to the form start page and walkthrough, // otherwise redirect to the summary page and allow the engine to handle the walkthrough @@ -285,9 +255,10 @@ export default [ submissionToCopy.meta.isPreview, submissionToCopy.meta.status ) - const nextHref = formVersionHasChanged - ? formHref - : `${formHref}${ControllerPath.Summary}` + const nextHref = + formVersionHasChanged || formDeclarations.length > 0 + ? formHref + : `${formHref}${ControllerPath.Summary}` return h.redirect(nextHref) }, @@ -301,6 +272,36 @@ export default [ }) ] +/** + * @param {Request<{ Params: CopyFormParams }>} request + * @param {CopyFormParams} params + * @param {string} accessToken + * @returns {Promise<{ form: FormMetadata, referenceNumber: string, submissionToCopy: FormSubmissionDocument, translator: Awaited>['translator'] }>} + */ +async function getCopyFormContext(request, params, accessToken) { + const { form, referenceNumber, submissionToCopy } = + await getFormAndSubmission(params, accessToken) + const { translator } = await getTranslator(request, form, submissionToCopy) + + return { form, referenceNumber, submissionToCopy, translator } +} + +/** + * @param {FormSubmissionDocument} submissionToCopy + * @param {FormMetadata} form + * @param {string} referenceNumber + * @returns {Promise<{ formDeclarations: DeclarationFieldComponent[], formVersionHasChanged: boolean }>} + */ +async function getFormComparisonState(submissionToCopy, form, referenceNumber) { + const def = await getFormDefinitionForSubmission( + submissionToCopy, + form, + referenceNumber + ) + + return getFormDetails(def, submissionToCopy) +} + /** * @param {string} pageTitleKey - the page title key for translation * @param {string} backLinkHref - the back link href @@ -372,35 +373,25 @@ async function getFormDefinitionForSubmission( * Get diff details about the form definition and submission * @param {FormDefinition} def * @param {FormSubmissionDocument} submission - * @returns {{ formHasDeclaration: boolean, formVersionHasChanged: boolean }} + * @returns {{ formDeclarations: DeclarationFieldComponent[], formVersionHasChanged: boolean }} */ function getFormDetails(def, submission) { - const formHasDeclaration = def.pages + const formDeclarations = def.pages .flatMap((p) => (hasFormComponents(p) ? p.components : [])) - .some((c) => c.type === ComponentType.DeclarationField) + .filter((c) => c.type === ComponentType.DeclarationField) const latestVersionMetadata = /** @type {FormVersionMetadata | undefined} */ ( def.metadata?.$$__formVersion ) - const { referenceNumber, status, versionMetadata } = submission.meta - - if (!latestVersionMetadata) { - throw Boom.badData( - `Cannot copy form '${referenceNumber}' because the latest ${status} version of the form definition doesn't contain versionMetadata` - ) - } - - if (!versionMetadata) { - throw Boom.badData( - `Cannot copy form '${referenceNumber}' because the submission doesn't contain versionMetadata` - ) - } + const { versionMetadata } = submission.meta const formVersionHasChanged = - latestVersionMetadata.versionNumber > versionMetadata.versionNumber + latestVersionMetadata && versionMetadata + ? latestVersionMetadata.versionNumber > versionMetadata.versionNumber + : true // If either version metadata is missing, assume the form has changed - return { formHasDeclaration, formVersionHasChanged } + return { formDeclarations, formVersionHasChanged } } /** @@ -408,10 +399,11 @@ function getFormDetails(def, submission) { * @param {Request<{ Params: CopyFormParams }>} request * @param {FormSubmissionDocument} submissionToCopy * @param {FormMetadata} form + * @param {string[]} [ignoreKeys] - keys to ignore when copying state (e.g. declaration fields) */ -async function setCacheState(request, submissionToCopy, form) { +async function setCacheState(request, submissionToCopy, form, ignoreKeys = []) { const cacheService = getCacheService(request.server) - const copiedState = mapValueToState(submissionToCopy) + const copiedState = mapValueToState(submissionToCopy, ignoreKeys) const state = getPreviewStatus(submissionToCopy) const req = { yar: request.yar, params: { slug: form.slug, state } } @@ -456,7 +448,7 @@ async function setCacheState(request, submissionToCopy, form) { /** * @import { ServerRoute, Request, RequestQuery } from '@hapi/hapi' * @import { Yar } from '@hapi/yar' - * @import { FormVersionMetadata, FormMetadata, FormDefinition, FormStatus } from '@defra/forms-model' + * @import { FormVersionMetadata, FormMetadata, FormDefinition, FormStatus, DeclarationFieldComponent } from '@defra/forms-model' * @import { Translator } from '@defra/forms-engine-plugin/engine/i18n/types.js' * @import { FormSubmissionDocument } from '~/src/server/services/submissionService.js' */ diff --git a/src/server/routes/copy-form.test.js b/src/server/routes/copy-form.test.js index ae7484838..03456cc9c 100644 --- a/src/server/routes/copy-form.test.js +++ b/src/server/routes/copy-form.test.js @@ -273,7 +273,7 @@ describe('Copy form routes', () => { expect(response.statusCode).toBe(StatusCodes.NOT_FOUND) }) - test('responds with unprocessable entity when the form version metadata is missing', async () => { + test('redirects to the changed form page when the form version metadata is missing', async () => { jest .mocked(getFormMetadataById) // @ts-expect-error - allow partial objects for tests @@ -294,10 +294,13 @@ describe('Copy form routes', () => { const { response } = await renderResponse(server, options) - expect(response.statusCode).toBe(StatusCodes.UNPROCESSABLE_ENTITY) + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe( + `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}/form-changed` + ) }) - test('responds with unprocessable entity when the submission version metadata is missing', async () => { + test('redirects to the changed form page when the submission version metadata is missing', async () => { jest .mocked(getFormMetadataById) // @ts-expect-error - allow partial objects for tests @@ -320,7 +323,10 @@ describe('Copy form routes', () => { const { response } = await renderResponse(server, options) - expect(response.statusCode).toBe(StatusCodes.UNPROCESSABLE_ENTITY) + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe( + `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}/form-changed` + ) }) }) @@ -395,6 +401,34 @@ describe('Copy form routes', () => { expect(response.headers.location).toBe('/form/my-form') }) + test('redirects to the form when the form contains declaration fields', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ id: FORM_ID, slug: 'my-form' }) + jest.mocked(getSubmissionRecord).mockResolvedValueOnce( + // @ts-expect-error - allow partial objects for tests + buildSubmission({ versionNumber: 1 }) + ) + + jest.mocked(getFormDefinition).mockResolvedValueOnce( + // @ts-expect-error - allow partial objects for tests + buildFormDefinition({ versionNumber: 1, hasDeclaration: true }) + ) + + const options = { + method: 'POST', + url: `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}/check-answers`, + auth: { strategy: 'citizen-session', credentials }, + payload: { confirmCopy: true } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe('/form/my-form') + }) + test('redirects to the summary when the form version has not changed', async () => { jest .mocked(getFormMetadataById) diff --git a/src/server/utils/map-submission-to-state.js b/src/server/utils/map-submission-to-state.js index bcedc1b17..b52e37887 100644 --- a/src/server/utils/map-submission-to-state.js +++ b/src/server/utils/map-submission-to-state.js @@ -3,13 +3,14 @@ import { randomUUID } from 'node:crypto' /** * Handles sub fields like address.addressLine1 and date.day * @param {Record} subfieldObject - * @param {[string, RichFormValue|null]} entry + * @param {[string, RichFormValue | null]} entry + * @param {string[]} ignoreKeys - keys to ignore when copying state (e.g. declaration fields) * @returns {Record} */ -function handleSubfields(subfieldObject, [key, value]) { +function handleSubfields(subfieldObject, [key, value], ignoreKeys = []) { if (typeof value === 'object' && value !== null && !Array.isArray(value)) { const subValues = Object.entries(value).reduce((acc2, [key2, value2]) => { - if (value2 === undefined) { + if (value2 === undefined || ignoreKeys.includes(key2)) { return acc2 } return { @@ -24,7 +25,7 @@ function handleSubfields(subfieldObject, [key, value]) { } } - if (value === undefined) { + if (value === undefined || ignoreKeys.includes(key)) { return subfieldObject } @@ -44,11 +45,12 @@ export const isString = (value) => typeof value === 'string' /** * Turns a form submission document into data suitable for session state * @param {FormSubmissionDocument} formSubmissionMessage + * @param {string[]} [ignoreKeys] - keys to ignore when copying state (e.g. declaration fields) */ -export function mapValueToState(formSubmissionMessage) { +export function mapValueToState(formSubmissionMessage, ignoreKeys = []) { const mainEntries = Object.entries(formSubmissionMessage.data.main) const main = mainEntries.reduce( - (acc, entry) => handleSubfields(acc, entry), + (acc, entry) => handleSubfields(acc, entry, ignoreKeys), /** @type {Record} */ ({}) ) @@ -56,7 +58,7 @@ export function mapValueToState(formSubmissionMessage) { const repeaters = repeaterEntries.reduce((repeaterObject, [key, value]) => { const values = value.map((repeater) => { const reduced = Object.entries(repeater).reduce( - (acc, entry) => handleSubfields(acc, entry), + (acc, entry) => handleSubfields(acc, entry, ignoreKeys), /** @type {Record} */ ({}) ) return { diff --git a/src/server/utils/map-submission-to-state.test.js b/src/server/utils/map-submission-to-state.test.js index f4f0f6ebe..b4b2ae214 100644 --- a/src/server/utils/map-submission-to-state.test.js +++ b/src/server/utils/map-submission-to-state.test.js @@ -6,6 +6,59 @@ import { } from '~/src/server/utils/map-submission-to-state.js' describe('mapValueToState', () => { + it('should ignore nested keys when passed to the mapper', () => { + const message = { + data: { + main: { + declaration: 'I agree', + firstName: 'Alice', + address: { + line1: '1 High Street', + postcode: 'SW1A 1AA' + }, + dateOfBirth: { + day: 1, + month: 2, + year: 2000 + } + }, + repeaters: { + people: [ + { + name: 'Bob', + declaration: 'I agree', + dateOfBirth: { + day: 3, + month: 4, + year: 2001 + } + } + ] + } + } + } + + expect( + // @ts-expect-error - test data is not a valid FormSubmissionDocument + mapValueToState(message, [ + 'declaration', + 'line1', + 'postcode', + 'day', + 'month', + 'year' + ]) + ).toEqual({ + firstName: 'Alice', + people: [ + { + name: 'Bob', + itemId: expect.any(String) + } + ] + }) + }) + it('should map adaptor v2 message to state2', () => { /** * @type {import('~/src/server/services/submissionService.js').FormSubmissionDocument}