From 62514c6249bc5c94705d0992dd13b5e04226780a Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 26 Aug 2026 13:31:07 +0100 Subject: [PATCH 1/6] Add generateReferenceNumber function and update types --- src/server/index.ts | 3 ++- src/server/services/formsService.js | 22 +++++++++++++++++++++- src/server/types.ts | 4 ++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index caeee20a4..cddb872a2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -191,7 +191,8 @@ export const configureEnginePlugin = async ({ }, ordnanceSurveyApiKey: config.get('ordnanceSurveyApiKey'), ordnanceSurveyApiSecret: config.get('ordnanceSurveyApiSecret'), - getLanguage: resolveLanguage + getLanguage: resolveLanguage, + generateReferenceNumber: formsService.generateReferenceNumber } } const routeOptions = { diff --git a/src/server/services/formsService.js b/src/server/services/formsService.js index 4926eb709..97fbecbbd 100644 --- a/src/server/services/formsService.js +++ b/src/server/services/formsService.js @@ -113,6 +113,26 @@ export async function validateSaveAndExitCredentials( return results } +/** + * Generates a unique reference number + * @param {string} [prefix] - the prefix + */ +export async function generateReferenceNumber(prefix) { + const postJsonByType = + /** @type {typeof postJson} */ (postJson) + + const { payload: results } = await postJsonByType( + `${submissionUrl}/submission/generate-reference-number?prefix=${prefix}`, + { payload: {} } + ) + + if (!results) { + throw new Error('Unexpected empty response in generateReferenceNumber') + } + + return results.referenceNumber +} + /** * Retrieves a form secret and decrypts the value * @param {string} formId - the id of the form @@ -130,5 +150,5 @@ export async function getFormSecret(formId, secretName) { /** * @import { FormDefinition, FormMetadata } from '@defra/forms-model' - * @import { SaveAndExitDetails, SaveAndExitResumeDetails } from '~/src/server/types.js' + * @import { GenerateReferenceNumber, SaveAndExitDetails, SaveAndExitResumeDetails } from '~/src/server/types.js' */ diff --git a/src/server/types.ts b/src/server/types.ts index 26b6c24f1..c0529eff9 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -70,3 +70,7 @@ export interface SaveAndExitDetails { export interface SaveAndExitResumeDetails extends SaveAndExitDetails { validPassword: boolean } + +export interface GenerateReferenceNumber { + referenceNumber: string +} From 3c62fe81e2da1e51ae06bfaf918916510abde945 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 26 Aug 2026 17:33:33 +0100 Subject: [PATCH 2/6] Refactor generateReferenceNumber to construct query string conditionally --- src/server/services/formsService.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server/services/formsService.js b/src/server/services/formsService.js index 97fbecbbd..4ab56c1bc 100644 --- a/src/server/services/formsService.js +++ b/src/server/services/formsService.js @@ -121,8 +121,9 @@ export async function generateReferenceNumber(prefix) { const postJsonByType = /** @type {typeof postJson} */ (postJson) + const query = prefix ? `?prefix=${prefix}` : '' const { payload: results } = await postJsonByType( - `${submissionUrl}/submission/generate-reference-number?prefix=${prefix}`, + `${submissionUrl}/submission/generate-reference-number${query}`, { payload: {} } ) From ec2d8e23c4cc589c2b81b8fda7c77c6bebabbfe7 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 3 Sep 2026 15:49:56 +0100 Subject: [PATCH 3/6] Increase timeout for generateReferenceNumber API call to 10 seconds --- src/server/services/formsService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/services/formsService.js b/src/server/services/formsService.js index 4ab56c1bc..a1aea64ef 100644 --- a/src/server/services/formsService.js +++ b/src/server/services/formsService.js @@ -124,7 +124,7 @@ export async function generateReferenceNumber(prefix) { const query = prefix ? `?prefix=${prefix}` : '' const { payload: results } = await postJsonByType( `${submissionUrl}/submission/generate-reference-number${query}`, - { payload: {} } + { payload: {}, timeout: 10 * 1000 } // 10 seconds ) if (!results) { From e848c163498bc1e929c840b160ab8bcc597235c9 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 3 Sep 2026 16:11:04 +0100 Subject: [PATCH 4/6] Update @defra/forms-engine-plugin to version 5.0.0-alpha.22 --- package-lock.json | 22 +++++++++++----------- package.json | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 80f5f2bcb..560f35ae9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.1079.0", - "@defra/forms-engine-plugin": "^5.0.0-alpha.20", + "@defra/forms-engine-plugin": "^5.0.0-alpha.22", "@defra/forms-model": "^3.0.700", "@defra/hapi-tracing": "^1.30.0", "@elastic/ecs-pino-format": "^1.5.0", @@ -2492,13 +2492,13 @@ } }, "node_modules/@defra/forms-engine-plugin": { - "version": "5.0.0-alpha.20", - "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-5.0.0-alpha.20.tgz", - "integrity": "sha512-n1x5oYVUVknFg5JRinVuRtajJ9fDMBNfEKHmuC4MTzW1yIGLDFMc7TD7/X480A3tb/GrQDu9ziCGqg5zFv6JaA==", + "version": "5.0.0-alpha.22", + "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-5.0.0-alpha.22.tgz", + "integrity": "sha512-RvBiWL66qi8ahtainewL2dTFf4yBtpsZo0QHjvAgjupOl7cSse7J8FXLY/sbl3+yx9NeFUdyUHMMK/3uDStESA==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.698", + "@defra/forms-model": "^3.0.704", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "0.0.33-alpha", "@elastic/ecs-pino-format": "^1.5.0", @@ -2626,9 +2626,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.700", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.700.tgz", - "integrity": "sha512-IQcrMR48GrEkjZKMHm0Al4KPr9QWpibzxr9UOQF0/8nQRL/5DUPvf2WKqurZ/wsD6diN44Rk8W61wVSy6O5oPA==", + "version": "3.0.705", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.705.tgz", + "integrity": "sha512-N25xjj8ibucHX2I4RvdKuBz2AtudIDJ0Gy/0eWLM8j6tAFsM9Nd8mr2LCifrMTjtQqlS+N8RduSOfuqNUKStyw==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -11733,9 +11733,9 @@ } }, "node_modules/globals": { - "version": "17.11.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", - "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index e3af9c7c1..087e492b1 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.1079.0", - "@defra/forms-engine-plugin": "^5.0.0-alpha.20", + "@defra/forms-engine-plugin": "^5.0.0-alpha.22", "@defra/forms-model": "^3.0.700", "@defra/hapi-tracing": "^1.30.0", "@elastic/ecs-pino-format": "^1.5.0", From 38a35d41e7c687410ba153c88951a85aa2b0bcc3 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 3 Sep 2026 16:40:36 +0100 Subject: [PATCH 5/6] Add generateReferenceNumber mock to tests for consistent reference generation --- src/server/index.test.ts | 2 ++ test/form/confirmation-email.test.js | 6 +++++- test/form/cookies.test.js | 6 +++++- test/form/phase-banner.test.js | 6 +++++- test/form/user-feedback.test.js | 3 +++ 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/server/index.test.ts b/src/server/index.test.ts index c16564fef..de66221ab 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -5,6 +5,7 @@ import { StatusCodes } from 'http-status-codes' import { FORM_PREFIX } from '~/src/server/constants.js' import { configureEnginePlugin, createServer } from '~/src/server/index.js' import { + generateReferenceNumber, getFormDefinition, getFormMetadata } from '~/src/server/services/formsService.js' @@ -31,6 +32,7 @@ describe('Model cache', () => { beforeEach(() => { jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) + jest.mocked(generateReferenceNumber).mockResolvedValue('XXX-XXX-XXX') server.app.models.clear() }) diff --git a/test/form/confirmation-email.test.js b/test/form/confirmation-email.test.js index 155af1a54..08348329e 100644 --- a/test/form/confirmation-email.test.js +++ b/test/form/confirmation-email.test.js @@ -7,7 +7,10 @@ import { StatusCodes } from 'http-status-codes' import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' -import { getFormMetadata } from '~/src/server/services/formsService.js' +import { + generateReferenceNumber, + getFormMetadata +} from '~/src/server/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' @@ -99,6 +102,7 @@ describe('Confirmation email', () => { // server.app.models.clear() jest.clearAllMocks() jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) + jest.mocked(generateReferenceNumber).mockResolvedValue('XXX-XXX-XXX') }) afterAll(async () => { diff --git a/test/form/cookies.test.js b/test/form/cookies.test.js index 077a1b6cb..898b635a0 100644 --- a/test/form/cookies.test.js +++ b/test/form/cookies.test.js @@ -5,7 +5,10 @@ import { StatusCodes } from 'http-status-codes' import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' -import { getFormMetadata } from '~/src/server/services/formsService.js' +import { + generateReferenceNumber, + getFormMetadata +} from '~/src/server/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookieHeader } from '~/test/utils/get-cookie.js' @@ -18,6 +21,7 @@ describe(`Cookie banner and analytics`, () => { beforeEach(() => { jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) + jest.mocked(generateReferenceNumber).mockResolvedValue('XXX-XXX-XXX') }) afterEach(async () => { diff --git a/test/form/phase-banner.test.js b/test/form/phase-banner.test.js index 31750483b..bbee78db1 100644 --- a/test/form/phase-banner.test.js +++ b/test/form/phase-banner.test.js @@ -4,7 +4,10 @@ import { within } from '@testing-library/dom' import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' -import { getFormMetadata } from '~/src/server/services/formsService.js' +import { + generateReferenceNumber, + getFormMetadata +} from '~/src/server/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' jest.mock('~/src/server/services/formsService.js') @@ -19,6 +22,7 @@ describe(`Phase banner`, () => { beforeEach(() => { jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) + jest.mocked(generateReferenceNumber).mockResolvedValue('XXX-XXX-XXX') }) test('shows the server phase tag by default', async () => { diff --git a/test/form/user-feedback.test.js b/test/form/user-feedback.test.js index f5172cc4d..f2a581908 100644 --- a/test/form/user-feedback.test.js +++ b/test/form/user-feedback.test.js @@ -8,6 +8,7 @@ import { StatusCodes } from 'http-status-codes' import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { + generateReferenceNumber, getFormMetadata, getFormMetadataById } from '~/src/server/services/formsService.js' @@ -105,6 +106,8 @@ describe('User feedback journey', () => { beforeAll(async () => { jest.mocked(getFormMetadataById).mockResolvedValue(metadata) jest.mocked(getFormMetadata).mockResolvedValue(metadata) + jest.mocked(generateReferenceNumber).mockResolvedValue('XXX-XXX-XXX') + server = await createServer({ formFileName: 'user-feedback-with-custom-controller.js', formFilePath: join(import.meta.dirname, 'definitions'), From cad3858df0a6b6ee210859003bf4f5f79c3e7ef7 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 3 Sep 2026 16:58:11 +0100 Subject: [PATCH 6/6] Add tests for generateReferenceNumber function to validate reference generation --- src/server/services/formsService.test.js | 51 ++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/server/services/formsService.test.js b/src/server/services/formsService.test.js index 5423110ec..f106b0d30 100644 --- a/src/server/services/formsService.test.js +++ b/src/server/services/formsService.test.js @@ -2,6 +2,7 @@ import { FormStatus } from '@defra/forms-engine-plugin/types' import { StatusCodes } from 'http-status-codes' import { + generateReferenceNumber, getFormDefinition, getFormMetadata, getFormMetadataById, @@ -217,6 +218,56 @@ describe('Forms service', () => { }) }) + describe('generateReferenceNumber', () => { + it('returns the generated reference number', async () => { + jest.mocked(postJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload: { referenceNumber: 'XXX-XXX-XXX' } + }) + + const referenceNumber = await generateReferenceNumber() + + expect(referenceNumber).toBe('XXX-XXX-XXX') + expect(postJson).toHaveBeenCalledWith( + `${SUBMISSION_URL}/submission/generate-reference-number`, + { payload: {}, timeout: 10 * 1000 } // 10 seconds + ) + }) + + it('returns the generated reference number with prefix', async () => { + jest.mocked(postJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload: { referenceNumber: 'XYZ-XXX-XXX' } + }) + + const referenceNumber = await generateReferenceNumber('XYZ') + + expect(referenceNumber).toBe('XYZ-XXX-XXX') + expect(postJson).toHaveBeenCalledWith( + `${SUBMISSION_URL}/submission/generate-reference-number?prefix=XYZ`, + { payload: {}, timeout: 10 * 1000 } // 10 seconds + ) + }) + + it('throws if no results', async () => { + // @ts-expect-error - partial mock of payload + jest.mocked(postJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload: undefined + }) + + await expect(() => generateReferenceNumber()).rejects.toThrow( + 'Unexpected empty response in generateReferenceNumber' + ) + }) + }) + describe('getFormSecret', () => { beforeEach(() => { // @ts-expect-error - mock fetch