From 9ad41e7658bf46064bc4d51be0c0eed6e7aac74f Mon Sep 17 00:00:00 2001 From: David Kelley Date: Fri, 28 Nov 2025 13:32:53 +0000 Subject: [PATCH 01/12] feat: importable `getFormContext` --- .../plugins/engine/form-context.test.ts | 600 ++++++++++++++++++ src/server/plugins/engine/form-context.ts | 378 +++++++++++ src/server/plugins/engine/index.ts | 7 + src/server/plugins/engine/routes/index.ts | 84 +-- 4 files changed, 995 insertions(+), 74 deletions(-) create mode 100644 src/server/plugins/engine/form-context.test.ts create mode 100644 src/server/plugins/engine/form-context.ts diff --git a/src/server/plugins/engine/form-context.test.ts b/src/server/plugins/engine/form-context.test.ts new file mode 100644 index 000000000..97284ab82 --- /dev/null +++ b/src/server/plugins/engine/form-context.test.ts @@ -0,0 +1,600 @@ +import { type Request } from '@hapi/hapi' + +import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' +import { + getFirstJourneyPage, + getFormContext, + getFormModel, + mapFormContextToAnswers +} from '~/src/server/plugins/engine/form-context.js' +import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' +import { type Services } from '~/src/server/types.js' + +const mockGetCacheService = jest.fn() +const mockCacheService = { getState: jest.fn() } +const mockEvaluateTemplate = jest.fn((template) => template) +const mockGetPageHref = jest.fn((page, pathOrQuery, queryOnly = {}) => { + const path = + typeof pathOrQuery === 'string' + ? pathOrQuery + : typeof page.path === 'string' + ? page.path + : '' + + const target = + typeof page.getHref === 'function' ? page.getHref(path) : path + const query = + typeof pathOrQuery === 'object' && !Array.isArray(pathOrQuery) + ? pathOrQuery + : queryOnly + + const params = new URLSearchParams() + for (const [key, value] of Object.entries(query ?? {})) { + if (typeof value === 'string') { + params.set(key, value) + } + } + + const search = params.toString() + return search ? `${target}?${search}` : target +}) +const mockGetAnswer = jest.fn() +const mockCheckEmailAddressForLiveFormSubmission = jest.fn() + +jest.mock( + '~/src/server/plugins/engine/components/helpers/components.js', + () => ({ + __esModule: true, + getAnswer: (...args: unknown[]) => mockGetAnswer(...args) + }) +) + +jest.mock('~/src/server/plugins/engine/models/index.js', () => ({ + __esModule: true, + FormModel: jest.fn() +})) + +jest.mock('~/src/server/plugins/engine/services/index.js', () => ({ + __esModule: true, + formsService: { + getFormMetadata: jest.fn(), + getFormDefinition: jest.fn() + }, + formSubmissionService: {}, + outputService: {} +})) + +jest.mock('~/src/server/plugins/engine/pageControllers/index.js', () => { + class MockTerminalPageController { + path = '' + } + + return { + __esModule: true, + TerminalPageController: MockTerminalPageController + } +}) + +jest.mock('~/src/server/plugins/engine/helpers.js', () => ({ + __esModule: true, + getCacheService: (...args: unknown[]) => mockGetCacheService(...args), + evaluateTemplate: (...args: unknown[]) => mockEvaluateTemplate(...args), + getPageHref: (...args: unknown[]) => mockGetPageHref(...args), + checkEmailAddressForLiveFormSubmission: ( + ...args: unknown[] + ) => mockCheckEmailAddressForLiveFormSubmission(...args) +})) + +const mockServices = jest.requireMock( + '~/src/server/plugins/engine/services/index.js' +) +const mockFormsService = mockServices.formsService +const { FormModel } = jest.requireMock( + '~/src/server/plugins/engine/models/index.js' +) +const { TerminalPageController: MockTerminalPageController } = jest.requireMock( + '~/src/server/plugins/engine/pageControllers/index.js' +) + +describe('getFormContext helper', () => { + const request = { + yar: { set: jest.fn() } as unknown as Request['yar'], + server: { + app: {}, + realm: { modifiers: { route: { prefix: '' } } } + } as unknown as Request['server'] + } satisfies Pick + const journey = 'tb-origin' + const cachedState = { answered: true } + const returnedContext = { errors: [] } + const metadata = { + id: 'metadata-123', + live: { updatedAt: new Date('2024-10-15T10:00:00Z') }, + draft: { updatedAt: new Date('2024-10-10T10:00:00Z') }, + versions: [{ versionNumber: 9 }], + notificationEmail: 'test@example.com' + } + const definition = { pages: [] } + let formModel: { getFormContext: jest.Mock } + + beforeEach(() => { + jest.clearAllMocks() + mockEvaluateTemplate.mockImplementation((template) => template) + mockGetAnswer.mockReturnValue('formatted answer') + formModel = { getFormContext: jest.fn().mockResolvedValue(returnedContext) } + FormModel.mockImplementation(() => formModel) + mockFormsService.getFormMetadata.mockResolvedValue(metadata) + mockFormsService.getFormDefinition.mockResolvedValue(definition) + mockGetCacheService.mockReturnValue(mockCacheService) + mockCacheService.getState.mockResolvedValue(cachedState) + }) + + test('builds a form context using cached state and configured services', async () => { + const context = await getFormContext(request, journey) + + expect(mockFormsService.getFormMetadata).toHaveBeenCalledWith(journey) + expect(mockFormsService.getFormDefinition).toHaveBeenCalledWith( + metadata.id, + 'live' + ) + + expect(FormModel).toHaveBeenCalledWith( + definition, + { + basePath: journey, + versionNumber: metadata.versions[0].versionNumber, + ordnanceSurveyApiKey: undefined, + formId: metadata.id + }, + mockServices, + undefined + ) + + expect(mockGetCacheService).toHaveBeenCalledWith(request.server) + expect(mockCacheService.getState).toHaveBeenCalledTimes(1) + + const summaryRequest = mockCacheService.getState.mock.calls[0][0] + + expect(summaryRequest).toEqual( + expect.objectContaining({ + yar: request.yar, + method: 'get', + params: { path: 'summary', slug: journey }, + query: {}, + path: `/${journey}/summary`, + server: request.server + }) + ) + expect(summaryRequest.url.toString()).toBe( + 'http://form-context.local/tb-origin/summary' + ) + + expect(formModel.getFormContext).toHaveBeenCalledWith( + summaryRequest, + { $$__referenceNumber: 'TODO', ...cachedState }, + [] + ) + + expect(context).toBe(returnedContext) + }) + + test('passes through the requested journey state when resolving the form model', async () => { + await getFormContext(request, journey, 'draft') + + expect(mockFormsService.getFormDefinition).toHaveBeenCalledWith( + metadata.id, + 'draft' + ) + }) +}) + +describe('mapFormContextToAnswers helper', () => { + type MockField = Pick + const buildPage = ( + fields: MockField[], + path = '/page-path', + withHref = true + ) => { + const page = { + path, + collection: { fields }, + ...(withHref && { + getHref: jest.fn((target: string) => `/journey${target}`) + }) + } satisfies Partial & { + path: string + collection: { fields: MockField[] } + } + + return page as unknown as PageControllerClass + } + + beforeEach(() => { + jest.clearAllMocks() + mockEvaluateTemplate.mockImplementation((template) => `rendered:${template}`) + mockGetAnswer.mockReturnValue('display text') + }) + + test('returns an empty array when no context is provided', () => { + expect(mapFormContextToAnswers()).toEqual([]) + }) + + test('omits unanswered components', () => { + const emptyField = { + name: 'empty', + title: 'Empty question', + type: 'TextField', + getFormValueFromState: jest.fn().mockReturnValue(' ') + } + const answeredField = { + name: 'filled', + title: 'Filled question', + type: 'TextField', + getFormValueFromState: jest.fn().mockReturnValue('value') + } + + const context = { + relevantPages: [buildPage([emptyField, answeredField])], + state: { some: 'state' } + } + + expect(mapFormContextToAnswers(context)).toEqual([ + { + slug: '/journey/page-path', + changeHref: '/journey/page-path?returnUrl=%2Fjourney%2Fsummary', + question: 'rendered:Filled question', + questionKey: 'filled', + answer: { + type: 'text', + value: 'value', + displayText: 'display text' + } + } + ]) + }) + + test('maps known component types to the expected answer shape', () => { + const addressValue = { + uprn: null, + addressLine1: '10 Downing Street', + addressTown: 'London', + postcode: 'SW1A 2AA' + } + + const field = { + name: 'addressField', + title: 'Address question', + type: 'UkAddressField', + getFormValueFromState: jest.fn().mockReturnValue(addressValue) + } + + const context = { + relevantPages: [buildPage([field], '/address')], + state: { addressField__addressLine1: '10 Downing Street' } + } + + mockGetAnswer.mockReturnValue('10 Downing Street
London
SW1A 2AA') + + expect(mapFormContextToAnswers(context)).toEqual([ + { + slug: '/journey/address', + changeHref: '/journey/address?returnUrl=%2Fjourney%2Fsummary', + question: 'rendered:Address question', + questionKey: 'addressField', + answer: { + type: 'address', + value: addressValue, + displayText: '10 Downing Street
London
SW1A 2AA' + } + } + ]) + }) + + test('falls back to the raw template when evaluation fails', () => { + mockEvaluateTemplate.mockImplementation(() => { + throw new Error('boom') + }) + + const field = { + name: 'failingQuestion', + title: 'Question with template', + type: 'TextField', + getFormValueFromState: jest.fn().mockReturnValue('Some value') + } + + const context = { + relevantPages: [buildPage([field])], + state: {} + } + + expect(mapFormContextToAnswers(context)).toEqual([ + { + slug: '/journey/page-path', + changeHref: '/journey/page-path?returnUrl=%2Fjourney%2Fsummary', + question: 'Question with template', + questionKey: 'failingQuestion', + answer: { + type: 'text', + value: 'Some value', + displayText: 'display text' + } + } + ]) + }) + + test('maps checkbox answers and preserves array values', () => { + const checkboxValue = ['option-a', 'option-b'] + + const field = { + name: 'reasons', + title: 'Why are you moving animals?', + type: 'CheckboxesField', + getFormValueFromState: jest.fn().mockReturnValue(checkboxValue) + } + + mockGetAnswer.mockReturnValue('Reason A
Reason B') + + const context = { + relevantPages: [buildPage([field], '/checkbox')], + state: { reasons: checkboxValue } + } + + expect(mapFormContextToAnswers(context)).toEqual([ + { + slug: '/journey/checkbox', + changeHref: '/journey/checkbox?returnUrl=%2Fjourney%2Fsummary', + question: 'rendered:Why are you moving animals?', + questionKey: 'reasons', + answer: { + type: 'checkbox', + value: checkboxValue, + displayText: 'Reason A
Reason B' + } + } + ]) + }) + + test('skips object answers when every nested value is empty', () => { + const emptyAddress = { + addressLine1: ' ', + addressLine2: '', + addressTown: '\n', + addressCounty: undefined, + addressPostcode: '' + } + + const field = { + name: 'originAddress', + title: 'Origin address', + type: 'UkAddressField', + getFormValueFromState: jest.fn().mockReturnValue(emptyAddress) + } + + const context = { + relevantPages: [buildPage([field])], + state: {} + } + + expect(mapFormContextToAnswers(context)).toEqual([]) + }) + + test('maps file upload answers and marks them as file type', () => { + const files = [ + { + filename: 'movement-plan.pdf', + status: { form: { file: { filename: 'movement-plan.pdf' } } } + } + ] + + const field = { + name: 'biosecurityPlan', + title: 'Upload your biosecurity plan', + type: 'FileUploadField', + getFormValueFromState: jest.fn().mockReturnValue(files) + } + + mockGetAnswer.mockReturnValue('movement-plan.pdf') + + const context = { + relevantPages: [buildPage([field], '/files')], + state: { biosecurityPlan: files } + } + + expect(mapFormContextToAnswers(context)).toEqual([ + { + slug: '/journey/files', + changeHref: '/journey/files?returnUrl=%2Fjourney%2Fsummary', + question: 'rendered:Upload your biosecurity plan', + questionKey: 'biosecurityPlan', + answer: { + type: 'file', + value: files, + displayText: 'movement-plan.pdf' + } + } + ]) + }) + + test('falls back to page path when getHref is unavailable', () => { + const field = { + name: 'noHref', + title: 'Some question', + type: 'TextField', + getFormValueFromState: jest.fn().mockReturnValue('value') + } + + const context = { + relevantPages: [buildPage([field], '/no-href', false)], + state: {} + } + + expect(mapFormContextToAnswers(context)).toEqual([ + { + slug: '/no-href', + changeHref: '/no-href', + question: 'rendered:Some question', + questionKey: 'noHref', + answer: { + type: 'text', + value: 'value', + displayText: 'display text' + } + } + ]) + }) + + test('allows overriding the return path used for change links', () => { + const field = { + name: 'customReturn', + title: 'Some question', + type: 'TextField', + getFormValueFromState: jest.fn().mockReturnValue('value') + } + + const context = { + relevantPages: [buildPage([field], '/custom-path')], + state: {} + } + + expect( + mapFormContextToAnswers(context, { returnPath: '/custom-summary' }) + ).toEqual([ + { + slug: '/journey/custom-path', + changeHref: + '/journey/custom-path?returnUrl=%2Fjourney%2Fcustom-summary', + question: 'rendered:Some question', + questionKey: 'customReturn', + answer: { + type: 'text', + value: 'value', + displayText: 'display text' + } + } + ]) + }) +}) + +describe('getFormModel helper', () => { + const slug = 'tb-origin' + const state = 'draft' + const controllers = { CustomController: Symbol('CustomController') } + const metadata = { + id: 'form-meta-123', + versions: [{ versionNumber: 17 }] + } + const definition = { pages: [{ path: '/start' }] } + let formsService: { + getFormMetadata: jest.Mock + getFormDefinition: jest.Mock + } + let services: Services + let formModelInstance: { id: string } + + beforeEach(() => { + jest.clearAllMocks() + formModelInstance = { id: 'form-model-instance' } + FormModel.mockImplementation(() => formModelInstance) + formsService = { + getFormMetadata: jest.fn().mockResolvedValue(metadata), + getFormDefinition: jest.fn().mockResolvedValue(definition) + } + services = { + formsService, + formSubmissionService: {}, + outputService: {} + } + }) + + test('constructs a FormModel using fetched metadata and definition', async () => { + const model = await getFormModel(slug, state, { services, controllers }) + + expect(formsService.getFormMetadata).toHaveBeenCalledWith(slug) + expect(formsService.getFormDefinition).toHaveBeenCalledWith( + metadata.id, + state + ) + expect(FormModel).toHaveBeenCalledWith( + definition, + { + basePath: slug, + versionNumber: metadata.versions[0].versionNumber, + ordnanceSurveyApiKey: undefined, + formId: metadata.id + }, + services, + controllers + ) + expect(model).toBe(formModelInstance) + }) + + test('maps preview state requests to the live form definition', async () => { + await getFormModel(slug, 'preview', { services, controllers }) + + expect(formsService.getFormDefinition).toHaveBeenCalledWith( + metadata.id, + 'live' + ) + }) + + test('throws when no form definition is available', async () => { + formsService.getFormDefinition.mockResolvedValue(undefined) + + await expect( + getFormModel(slug, state, { services, controllers }) + ).rejects.toThrow( + `No definition found for form metadata ${metadata.id} (${slug}) ${state}` + ) + + expect(FormModel).not.toHaveBeenCalled() + }) +}) + +describe('getFirstJourneyPage helper', () => { + const buildPage = (path: string, keys: string[] = []) => ({ path, keys }) + + test('returns undefined when no context or relevant target path is available', () => { + expect(getFirstJourneyPage()).toBeUndefined() + expect(getFirstJourneyPage({ relevantPages: [] })).toBeUndefined() + }) + + test('returns the page matching the last recorded path', () => { + const startPage = buildPage('/start') + const nextPage = buildPage('/animals') + + const context = { + relevantPages: [startPage, nextPage] + } + + expect(getFirstJourneyPage(context)).toBe(nextPage) + }) + + test('steps back from terminal pages to the previous relevant page', () => { + const startPage = buildPage('/start') + const exitPage = Object.assign(new MockTerminalPageController(), { + path: '/stop' + }) as unknown as PageControllerClass + + const context = { + relevantPages: [startPage, exitPage] + } + + expect(getFirstJourneyPage(context)).toBe(startPage) + }) + + test('returns the terminal page when it is the only relevant page available', () => { + const exitPage = Object.assign(new MockTerminalPageController(), { + path: '/stop' + }) as unknown as PageControllerClass + + const context = { + relevantPages: [exitPage] + } + + expect(getFirstJourneyPage(context)).toBe(exitPage) + }) +}) + +/** + * @import { FormContext } from './types.js' + */ diff --git a/src/server/plugins/engine/form-context.ts b/src/server/plugins/engine/form-context.ts new file mode 100644 index 000000000..3197bff73 --- /dev/null +++ b/src/server/plugins/engine/form-context.ts @@ -0,0 +1,378 @@ +import Boom from '@hapi/boom' +import { type Request, type Server } from '@hapi/hapi' +import { isEqual } from 'date-fns' + +import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' +import { + getAnswer, + type Field +} from '~/src/server/plugins/engine/components/helpers/components.js' +import { + checkEmailAddressForLiveFormSubmission, + evaluateTemplate, + getCacheService, + getPageHref +} from '~/src/server/plugins/engine/helpers.js' +import { FormModel } from '~/src/server/plugins/engine/models/index.js' +import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' +import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' +import { TerminalPageController } from '~/src/server/plugins/engine/pageControllers/index.js' +import * as defaultServices from '~/src/server/plugins/engine/services/index.js' +import { + type FormContext, + type FormContextRequest, + type FormSubmissionError +} from '~/src/server/plugins/engine/types.js' +import { FormStatus } from '~/src/server/routes/types.js' +import { type Services } from '~/src/server/types.js' + +type JourneyState = FormStatus | 'preview' + +export interface FormModelOptions { + services?: Services + controllers?: Record + basePath?: string + versionNumber?: number + ordnanceSurveyApiKey?: string + formId?: string + routePrefix?: string + isPreview?: boolean +} + +export interface FormContextOptions extends FormModelOptions { + errors?: FormSubmissionError[] + referenceNumber?: string +} + +type SummaryRequest = FormContextRequest & { + yar: Request['yar'] +} + +export async function getFormModel( + slug: string, + state: JourneyState, + options: FormModelOptions = {} +) { + const services = options.services ?? defaultServices + const { formsService } = services + const isPreview = isPreviewState(state, options) + const formState = resolveState(state) + + const metadata = await formsService.getFormMetadata(slug) + const versionNumber = + options.versionNumber ?? metadata.versions?.[0]?.versionNumber + + const definition = await formsService.getFormDefinition( + metadata.id, + formState + ) + + if (!definition) { + throw Boom.notFound( + `No definition found for form metadata ${metadata.id} (${slug}) ${state}` + ) + } + + return new FormModel( + definition, + { + basePath: + options.basePath ?? + buildBasePath( + options.routePrefix ?? '', + slug, + formState, + isPreview + ), + versionNumber, + ordnanceSurveyApiKey: options.ordnanceSurveyApiKey, + formId: options.formId ?? metadata.id + }, + services, + options.controllers + ) +} + +export async function getFormContext( + { server, yar }: Pick, + journey: string, + state: JourneyState = FormStatus.Live, + options: FormContextOptions = {} +): Promise { + const formModel = await resolveFormModel( + server, + journey, + state, + options + ) + + const cacheService = getCacheService(server) + + const summaryRequest: SummaryRequest = { + app: {}, + method: 'get', + params: { + path: 'summary', + slug: journey, + ...(isPreviewState(state, options) && { + state: resolveState(state) + }) + }, + path: `/${formModel.basePath}/summary`, + query: {}, + url: new URL( + `/${formModel.basePath}/summary`, + 'http://form-context.local' + ), + server, + yar + } + + const cachedState = await cacheService.getState(summaryRequest) + + const formState = { + ...cachedState, + $$__referenceNumber: + options.referenceNumber ?? + cachedState.$$__referenceNumber ?? + 'TODO' + } + + return formModel.getFormContext( + summaryRequest, + formState, + options.errors ?? [] + ) +} + +export async function resolveFormModel( + server: Server, + slug: string, + state: JourneyState, + options: FormModelOptions = {} +) { + const services = options.services ?? defaultServices + const { formsService } = services + + const metadata = await formsService.getFormMetadata(slug) + const formState = resolveState(state) + const isPreview = options.isPreview ?? isPreviewState(state, options) + const stateMetadata = metadata[formState] + + if (!stateMetadata) { + throw Boom.notFound(`No '${formState}' state for form metadata ${metadata.id}`) + } + + // The models cache is created lazily per server instance + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!server.app.models) { + server.app.models = new Map() + } + + const cache = server.app.models as Map< + string, + { model: FormModel; updatedAt: Date } + > + + const cacheKey = `${metadata.id}_${formState}_${isPreview}` + let entry = cache.get(cacheKey) + + if (!entry || !isEqual(entry.updatedAt, stateMetadata.updatedAt)) { + const definition = await formsService.getFormDefinition( + metadata.id, + formState + ) + + if (!definition) { + throw Boom.notFound( + `No definition found for form metadata ${metadata.id} (${slug}) ${state}` + ) + } + + const emailAddress = metadata.notificationEmail ?? definition.outputEmail + + checkEmailAddressForLiveFormSubmission(emailAddress, isPreview) + + const routePrefix = + options.routePrefix ?? server.realm.modifiers.route.prefix + + const model = new FormModel( + definition, + { + basePath: + options.basePath ?? + buildBasePath(routePrefix, slug, formState, isPreview), + versionNumber: + options.versionNumber ?? metadata.versions?.[0]?.versionNumber, + ordnanceSurveyApiKey: options.ordnanceSurveyApiKey, + formId: options.formId ?? metadata.id + }, + services, + options.controllers + ) + + entry = { model, updatedAt: stateMetadata.updatedAt } + cache.set(cacheKey, entry) + } + + return entry.model +} + +function buildBasePath( + routePrefix: string, + slug: string, + state: FormStatus, + isPreview: boolean +) { + const base = ( + isPreview + ? `${routePrefix}${PREVIEW_PATH_PREFIX}/${state}/${slug}` + : `${routePrefix}/${slug}` + ).replace(/\/{2,}/g, '/') + + return base.startsWith('/') ? base.slice(1) : base +} + +export function getFirstJourneyPage( + context?: Pick +) { + if (!context?.relevantPages) { + return undefined + } + + const lastPageReached = context.relevantPages.at(-1) + const penultimatePageReached = context.relevantPages.at(-2) + + if ( + lastPageReached instanceof TerminalPageController && + penultimatePageReached + ) { + return penultimatePageReached + } + + return lastPageReached +} + +export function mapFormContextToAnswers( + context?: Pick, + { returnPath = '/summary' }: { returnPath?: string } = {} +) { + if (!context) { + return [] + } + + const { relevantPages = [], state = {} } = context + + return relevantPages.flatMap((page) => { + const fields = page.collection.fields + + return fields + .map((field) => { + const value = field.getFormValueFromState(state) + if (!hasRenderableValue(value)) { + return undefined + } + + return { + slug: resolveFieldSlug(page), + changeHref: resolveChangeHref(page, returnPath), + question: safeEvaluateTemplate(field.title, context), + questionKey: field.name, + answer: { + type: mapAnswerType(field), + value, + displayText: getAnswer(field, state) + } + } + }) + .filter(Boolean) + }) +} + +function hasRenderableValue(value: unknown): boolean { + if (value === undefined || value === null) { + return false + } + + if (typeof value === 'string') { + return value.trim().length > 0 + } + + if (Array.isArray(value)) { + return value.some((entry) => hasRenderableValue(entry)) + } + + if (typeof value === 'object') { + return Object.values(value).some((entry) => hasRenderableValue(entry)) + } + + return true +} + +function mapAnswerType(field: Field) { + const map = { + UkAddressField: 'address', + DatePartsField: 'date', + MonthYearField: 'date', + NumberField: 'number', + CheckboxesField: 'checkbox', + FileUploadField: 'file' + } + + return map[field.type] ?? 'text' +} + +function safeEvaluateTemplate(template: string, context: FormContext) { + try { + return evaluateTemplate(template, context) + } catch { + return template + } +} + +function resolveFieldSlug(page?: PageControllerClass) { + if (!page) { + return undefined + } + + try { + if (typeof page.getHref === 'function') { + const targetPath = page.path || '/' + return getPageHref(page, targetPath) + } + } catch { + // ignore and fall back to raw path + } + + return page.path +} + +function resolveChangeHref(page: PageControllerClass | undefined, summaryPath: string) { + const slug = resolveFieldSlug(page) + + if (!page || !slug) { + return slug + } + + if (typeof page.getHref !== 'function' || typeof summaryPath !== 'string') { + return slug + } + + try { + const target = getPageHref(page, summaryPath) + return getPageHref(page, page.path || '/', { returnUrl: target }) + } catch { + return slug + } +} + +function resolveState(state: JourneyState): FormStatus { + return state === 'preview' ? FormStatus.Live : state +} + +function isPreviewState( + state: JourneyState, + options: FormModelOptions = {} +): boolean { + return options.isPreview ?? state === 'preview' +} diff --git a/src/server/plugins/engine/index.ts b/src/server/plugins/engine/index.ts index c45ccbbc6..31c7f5b3c 100644 --- a/src/server/plugins/engine/index.ts +++ b/src/server/plugins/engine/index.ts @@ -14,6 +14,13 @@ import * as filters from '~/src/server/plugins/nunjucks/filters/index.js' export { getPageHref } from '~/src/server/plugins/engine/helpers.js' export { context } from '~/src/server/plugins/nunjucks/context.js' +export { + getFirstJourneyPage, + getFormContext, + getFormModel, + mapFormContextToAnswers, + resolveFormModel +} from '~/src/server/plugins/engine/form-context.js' const globals = { checkComponentTemplates, diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 250d005eb..81fd70e7a 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -4,19 +4,14 @@ import { type ResponseToolkit, type Server } from '@hapi/hapi' -import { isEqual } from 'date-fns' -import { - EXTERNAL_STATE_APPENDAGE, - EXTERNAL_STATE_PAYLOAD, - PREVIEW_PATH_PREFIX -} from '~/src/server/constants.js' +import { EXTERNAL_STATE_APPENDAGE, EXTERNAL_STATE_PAYLOAD } from '~/src/server/constants.js' import { FormComponent, isFormState } from '~/src/server/plugins/engine/components/FormComponent.js' +import { resolveFormModel } from '~/src/server/plugins/engine/form-context.js' import { - checkEmailAddressForLiveFormSubmission, checkFormStatus, findPage, getCacheService, @@ -24,7 +19,6 @@ import { getStartPath, proceed } from '~/src/server/plugins/engine/helpers.js' -import { FormModel } from '~/src/server/plugins/engine/models/index.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' import { generateUniqueReference } from '~/src/server/plugins/engine/referenceNumbers.js' import * as defaultServices from '~/src/server/plugins/engine/services/index.js' @@ -179,8 +173,6 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { ordnanceSurveyApiKey } = options - const { formsService } = services - async function handler(request: AnyFormRequest, h: ResponseToolkit) { if (server.app.model) { request.app.model = server.app.model @@ -192,71 +184,15 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { const { slug } = params const { isPreview, state: formState } = checkFormStatus(params) - // Get the form metadata using the `slug` param - const metadata = await formsService.getFormMetadata(slug) - - const { id, [formState]: state } = metadata - - // Check the metadata supports the requested state - if (!state) { - throw Boom.notFound(`No '${formState}' state for form metadata ${id}`) - } - - // Cache the models based on id, state and whether - // it's a preview or not. There could be up to 3 models - // cached for a single form: - // "{id}_live_false" (live/live) - // "{id}_live_true" (live/preview) - // "{id}_draft_true" (draft/preview) - const key = `${id}_${formState}_${isPreview}` - let item = server.app.models.get(key) - - if (!item || !isEqual(item.updatedAt, state.updatedAt)) { - server.logger.info(`Getting form definition ${id} (${slug}) ${formState}`) - - // Get the form definition using the `id` from the metadata - const definition = await formsService.getFormDefinition(id, formState) - - if (!definition) { - throw Boom.notFound( - `No definition found for form metadata ${id} (${slug}) ${formState}` - ) - } - - const emailAddress = metadata.notificationEmail ?? definition.outputEmail - - checkEmailAddressForLiveFormSubmission(emailAddress, isPreview) - - // Build the form model - server.logger.info( - `Building model for form definition ${id} (${slug}) ${formState}` - ) - - // Set up the basePath for the model - const basePath = ( - isPreview - ? `${prefix}${PREVIEW_PATH_PREFIX}/${formState}/${slug}` - : `${prefix}/${slug}` - ).substring(1) - - const versionNumber = metadata.versions?.[0]?.versionNumber - - // Construct the form model - const model = new FormModel( - definition, - { basePath, versionNumber, ordnanceSurveyApiKey, formId: id }, - services, - controllers - ) - - // Create new item and add it to the item cache - item = { model, updatedAt: state.updatedAt } - server.app.models.set(key, item) - } + const model = await resolveFormModel(server, slug, formState, { + services, + controllers, + ordnanceSurveyApiKey, + routePrefix: prefix, + isPreview + }) - // Assign the model to the request data - // for use in the downstream handler - request.app.model = item.model + request.app.model = model return h.continue } From d55a6016239d016346f9447888c980fe780e0f6d Mon Sep 17 00:00:00 2001 From: David Kelley Date: Tue, 2 Dec 2025 14:27:51 +0000 Subject: [PATCH 02/12] fix: removed answer key mapping function --- .../plugins/engine/form-context.test.ts | 385 ++---------------- src/server/plugins/engine/form-context.ts | 159 +------- src/server/plugins/engine/index.ts | 1 - src/server/plugins/engine/models/FormModel.ts | 4 +- 4 files changed, 49 insertions(+), 500 deletions(-) diff --git a/src/server/plugins/engine/form-context.test.ts b/src/server/plugins/engine/form-context.test.ts index 97284ab82..5870911a0 100644 --- a/src/server/plugins/engine/form-context.test.ts +++ b/src/server/plugins/engine/form-context.test.ts @@ -1,54 +1,20 @@ import { type Request } from '@hapi/hapi' -import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { getFirstJourneyPage, getFormContext, - getFormModel, - mapFormContextToAnswers + getFormModel } from '~/src/server/plugins/engine/form-context.js' +import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' -import { type Services } from '~/src/server/types.js' +import { type FormContext } from '~/src/server/plugins/engine/types.js' +import { FormStatus } from '~/src/server/routes/types.js' +import { type FormsService, type Services } from '~/src/server/types.js' const mockGetCacheService = jest.fn() const mockCacheService = { getState: jest.fn() } -const mockEvaluateTemplate = jest.fn((template) => template) -const mockGetPageHref = jest.fn((page, pathOrQuery, queryOnly = {}) => { - const path = - typeof pathOrQuery === 'string' - ? pathOrQuery - : typeof page.path === 'string' - ? page.path - : '' - - const target = - typeof page.getHref === 'function' ? page.getHref(path) : path - const query = - typeof pathOrQuery === 'object' && !Array.isArray(pathOrQuery) - ? pathOrQuery - : queryOnly - - const params = new URLSearchParams() - for (const [key, value] of Object.entries(query ?? {})) { - if (typeof value === 'string') { - params.set(key, value) - } - } - - const search = params.toString() - return search ? `${target}?${search}` : target -}) -const mockGetAnswer = jest.fn() const mockCheckEmailAddressForLiveFormSubmission = jest.fn() -jest.mock( - '~/src/server/plugins/engine/components/helpers/components.js', - () => ({ - __esModule: true, - getAnswer: (...args: unknown[]) => mockGetAnswer(...args) - }) -) - jest.mock('~/src/server/plugins/engine/models/index.js', () => ({ __esModule: true, FormModel: jest.fn() @@ -78,11 +44,8 @@ jest.mock('~/src/server/plugins/engine/pageControllers/index.js', () => { jest.mock('~/src/server/plugins/engine/helpers.js', () => ({ __esModule: true, getCacheService: (...args: unknown[]) => mockGetCacheService(...args), - evaluateTemplate: (...args: unknown[]) => mockEvaluateTemplate(...args), - getPageHref: (...args: unknown[]) => mockGetPageHref(...args), - checkEmailAddressForLiveFormSubmission: ( - ...args: unknown[] - ) => mockCheckEmailAddressForLiveFormSubmission(...args) + checkEmailAddressForLiveFormSubmission: (...args: unknown[]) => + mockCheckEmailAddressForLiveFormSubmission(...args) })) const mockServices = jest.requireMock( @@ -119,8 +82,6 @@ describe('getFormContext helper', () => { beforeEach(() => { jest.clearAllMocks() - mockEvaluateTemplate.mockImplementation((template) => template) - mockGetAnswer.mockReturnValue('formatted answer') formModel = { getFormContext: jest.fn().mockResolvedValue(returnedContext) } FormModel.mockImplementation(() => formModel) mockFormsService.getFormMetadata.mockResolvedValue(metadata) @@ -179,315 +140,26 @@ describe('getFormContext helper', () => { }) test('passes through the requested journey state when resolving the form model', async () => { - await getFormContext(request, journey, 'draft') + await getFormContext(request, journey, FormStatus.Draft) expect(mockFormsService.getFormDefinition).toHaveBeenCalledWith( metadata.id, - 'draft' + FormStatus.Draft ) }) }) -describe('mapFormContextToAnswers helper', () => { - type MockField = Pick - const buildPage = ( - fields: MockField[], - path = '/page-path', - withHref = true - ) => { - const page = { - path, - collection: { fields }, - ...(withHref && { - getHref: jest.fn((target: string) => `/journey${target}`) - }) - } satisfies Partial & { - path: string - collection: { fields: MockField[] } - } - - return page as unknown as PageControllerClass - } - - beforeEach(() => { - jest.clearAllMocks() - mockEvaluateTemplate.mockImplementation((template) => `rendered:${template}`) - mockGetAnswer.mockReturnValue('display text') - }) - - test('returns an empty array when no context is provided', () => { - expect(mapFormContextToAnswers()).toEqual([]) - }) - - test('omits unanswered components', () => { - const emptyField = { - name: 'empty', - title: 'Empty question', - type: 'TextField', - getFormValueFromState: jest.fn().mockReturnValue(' ') - } - const answeredField = { - name: 'filled', - title: 'Filled question', - type: 'TextField', - getFormValueFromState: jest.fn().mockReturnValue('value') - } - - const context = { - relevantPages: [buildPage([emptyField, answeredField])], - state: { some: 'state' } - } - - expect(mapFormContextToAnswers(context)).toEqual([ - { - slug: '/journey/page-path', - changeHref: '/journey/page-path?returnUrl=%2Fjourney%2Fsummary', - question: 'rendered:Filled question', - questionKey: 'filled', - answer: { - type: 'text', - value: 'value', - displayText: 'display text' - } - } - ]) - }) - - test('maps known component types to the expected answer shape', () => { - const addressValue = { - uprn: null, - addressLine1: '10 Downing Street', - addressTown: 'London', - postcode: 'SW1A 2AA' - } - - const field = { - name: 'addressField', - title: 'Address question', - type: 'UkAddressField', - getFormValueFromState: jest.fn().mockReturnValue(addressValue) - } - - const context = { - relevantPages: [buildPage([field], '/address')], - state: { addressField__addressLine1: '10 Downing Street' } - } - - mockGetAnswer.mockReturnValue('10 Downing Street
London
SW1A 2AA') - - expect(mapFormContextToAnswers(context)).toEqual([ - { - slug: '/journey/address', - changeHref: '/journey/address?returnUrl=%2Fjourney%2Fsummary', - question: 'rendered:Address question', - questionKey: 'addressField', - answer: { - type: 'address', - value: addressValue, - displayText: '10 Downing Street
London
SW1A 2AA' - } - } - ]) - }) - - test('falls back to the raw template when evaluation fails', () => { - mockEvaluateTemplate.mockImplementation(() => { - throw new Error('boom') - }) - - const field = { - name: 'failingQuestion', - title: 'Question with template', - type: 'TextField', - getFormValueFromState: jest.fn().mockReturnValue('Some value') - } - - const context = { - relevantPages: [buildPage([field])], - state: {} - } - - expect(mapFormContextToAnswers(context)).toEqual([ - { - slug: '/journey/page-path', - changeHref: '/journey/page-path?returnUrl=%2Fjourney%2Fsummary', - question: 'Question with template', - questionKey: 'failingQuestion', - answer: { - type: 'text', - value: 'Some value', - displayText: 'display text' - } - } - ]) - }) - - test('maps checkbox answers and preserves array values', () => { - const checkboxValue = ['option-a', 'option-b'] - - const field = { - name: 'reasons', - title: 'Why are you moving animals?', - type: 'CheckboxesField', - getFormValueFromState: jest.fn().mockReturnValue(checkboxValue) - } - - mockGetAnswer.mockReturnValue('Reason A
Reason B') - - const context = { - relevantPages: [buildPage([field], '/checkbox')], - state: { reasons: checkboxValue } - } - - expect(mapFormContextToAnswers(context)).toEqual([ - { - slug: '/journey/checkbox', - changeHref: '/journey/checkbox?returnUrl=%2Fjourney%2Fsummary', - question: 'rendered:Why are you moving animals?', - questionKey: 'reasons', - answer: { - type: 'checkbox', - value: checkboxValue, - displayText: 'Reason A
Reason B' - } - } - ]) - }) - - test('skips object answers when every nested value is empty', () => { - const emptyAddress = { - addressLine1: ' ', - addressLine2: '', - addressTown: '\n', - addressCounty: undefined, - addressPostcode: '' - } - - const field = { - name: 'originAddress', - title: 'Origin address', - type: 'UkAddressField', - getFormValueFromState: jest.fn().mockReturnValue(emptyAddress) - } - - const context = { - relevantPages: [buildPage([field])], - state: {} - } - - expect(mapFormContextToAnswers(context)).toEqual([]) - }) - - test('maps file upload answers and marks them as file type', () => { - const files = [ - { - filename: 'movement-plan.pdf', - status: { form: { file: { filename: 'movement-plan.pdf' } } } - } - ] - - const field = { - name: 'biosecurityPlan', - title: 'Upload your biosecurity plan', - type: 'FileUploadField', - getFormValueFromState: jest.fn().mockReturnValue(files) - } - - mockGetAnswer.mockReturnValue('movement-plan.pdf') - - const context = { - relevantPages: [buildPage([field], '/files')], - state: { biosecurityPlan: files } - } - - expect(mapFormContextToAnswers(context)).toEqual([ - { - slug: '/journey/files', - changeHref: '/journey/files?returnUrl=%2Fjourney%2Fsummary', - question: 'rendered:Upload your biosecurity plan', - questionKey: 'biosecurityPlan', - answer: { - type: 'file', - value: files, - displayText: 'movement-plan.pdf' - } - } - ]) - }) - - test('falls back to page path when getHref is unavailable', () => { - const field = { - name: 'noHref', - title: 'Some question', - type: 'TextField', - getFormValueFromState: jest.fn().mockReturnValue('value') - } - - const context = { - relevantPages: [buildPage([field], '/no-href', false)], - state: {} - } - - expect(mapFormContextToAnswers(context)).toEqual([ - { - slug: '/no-href', - changeHref: '/no-href', - question: 'rendered:Some question', - questionKey: 'noHref', - answer: { - type: 'text', - value: 'value', - displayText: 'display text' - } - } - ]) - }) - - test('allows overriding the return path used for change links', () => { - const field = { - name: 'customReturn', - title: 'Some question', - type: 'TextField', - getFormValueFromState: jest.fn().mockReturnValue('value') - } - - const context = { - relevantPages: [buildPage([field], '/custom-path')], - state: {} - } - - expect( - mapFormContextToAnswers(context, { returnPath: '/custom-summary' }) - ).toEqual([ - { - slug: '/journey/custom-path', - changeHref: - '/journey/custom-path?returnUrl=%2Fjourney%2Fcustom-summary', - question: 'rendered:Some question', - questionKey: 'customReturn', - answer: { - type: 'text', - value: 'value', - displayText: 'display text' - } - } - ]) - }) -}) - describe('getFormModel helper', () => { const slug = 'tb-origin' - const state = 'draft' - const controllers = { CustomController: Symbol('CustomController') } + const state = FormStatus.Draft + class CustomController extends PageController {} + const controllers = { CustomController } const metadata = { id: 'form-meta-123', versions: [{ versionNumber: 17 }] } const definition = { pages: [{ path: '/start' }] } - let formsService: { - getFormMetadata: jest.Mock - getFormDefinition: jest.Mock - } + let formsService: FormsService let services: Services let formModelInstance: { id: string } @@ -495,15 +167,21 @@ describe('getFormModel helper', () => { jest.clearAllMocks() formModelInstance = { id: 'form-model-instance' } FormModel.mockImplementation(() => formModelInstance) - formsService = { - getFormMetadata: jest.fn().mockResolvedValue(metadata), - getFormDefinition: jest.fn().mockResolvedValue(definition) - } services = { - formsService, - formSubmissionService: {}, - outputService: {} + formsService: { + getFormMetadata: jest.fn().mockResolvedValue(metadata), + getFormMetadataById: jest.fn(), + getFormDefinition: jest.fn().mockResolvedValue(definition) + }, + formSubmissionService: { + persistFiles: jest.fn(), + submit: jest.fn() + }, + outputService: { + submit: jest.fn() + } } + formsService = services.formsService }) test('constructs a FormModel using fetched metadata and definition', async () => { @@ -538,7 +216,7 @@ describe('getFormModel helper', () => { }) test('throws when no form definition is available', async () => { - formsService.getFormDefinition.mockResolvedValue(undefined) + jest.mocked(formsService.getFormDefinition).mockResolvedValue(undefined) await expect( getFormModel(slug, state, { services, controllers }) @@ -551,7 +229,8 @@ describe('getFormModel helper', () => { }) describe('getFirstJourneyPage helper', () => { - const buildPage = (path: string, keys: string[] = []) => ({ path, keys }) + const buildPage = (path: string, keys: string[] = []) => + ({ path, keys }) as unknown as PageControllerClass test('returns undefined when no context or relevant target path is available', () => { expect(getFirstJourneyPage()).toBeUndefined() @@ -562,7 +241,7 @@ describe('getFirstJourneyPage helper', () => { const startPage = buildPage('/start') const nextPage = buildPage('/animals') - const context = { + const context: Pick = { relevantPages: [startPage, nextPage] } @@ -575,7 +254,7 @@ describe('getFirstJourneyPage helper', () => { path: '/stop' }) as unknown as PageControllerClass - const context = { + const context: Pick = { relevantPages: [startPage, exitPage] } @@ -587,7 +266,7 @@ describe('getFirstJourneyPage helper', () => { path: '/stop' }) as unknown as PageControllerClass - const context = { + const context: Pick = { relevantPages: [exitPage] } diff --git a/src/server/plugins/engine/form-context.ts b/src/server/plugins/engine/form-context.ts index 3197bff73..e38796390 100644 --- a/src/server/plugins/engine/form-context.ts +++ b/src/server/plugins/engine/form-context.ts @@ -3,25 +3,20 @@ import { type Request, type Server } from '@hapi/hapi' import { isEqual } from 'date-fns' import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' -import { - getAnswer, - type Field -} from '~/src/server/plugins/engine/components/helpers/components.js' import { checkEmailAddressForLiveFormSubmission, - evaluateTemplate, - getCacheService, - getPageHref + getCacheService } from '~/src/server/plugins/engine/helpers.js' import { FormModel } from '~/src/server/plugins/engine/models/index.js' import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' -import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' import { TerminalPageController } from '~/src/server/plugins/engine/pageControllers/index.js' import * as defaultServices from '~/src/server/plugins/engine/services/index.js' import { + type AnyRequest, type FormContext, type FormContextRequest, - type FormSubmissionError + type FormSubmissionError, + type FormSubmissionState } from '~/src/server/plugins/engine/types.js' import { FormStatus } from '~/src/server/routes/types.js' import { type Services } from '~/src/server/types.js' @@ -78,12 +73,7 @@ export async function getFormModel( { basePath: options.basePath ?? - buildBasePath( - options.routePrefix ?? '', - slug, - formState, - isPreview - ), + buildBasePath(options.routePrefix ?? '', slug, formState, isPreview), versionNumber, ordnanceSurveyApiKey: options.ordnanceSurveyApiKey, formId: options.formId ?? metadata.id @@ -99,12 +89,7 @@ export async function getFormContext( state: JourneyState = FormStatus.Live, options: FormContextOptions = {} ): Promise { - const formModel = await resolveFormModel( - server, - journey, - state, - options - ) + const formModel = await resolveFormModel(server, journey, state, options) const cacheService = getCacheService(server) @@ -120,22 +105,19 @@ export async function getFormContext( }, path: `/${formModel.basePath}/summary`, query: {}, - url: new URL( - `/${formModel.basePath}/summary`, - 'http://form-context.local' - ), + url: new URL(`/${formModel.basePath}/summary`, 'http://form-context.local'), server, yar } - const cachedState = await cacheService.getState(summaryRequest) + const cachedState = await cacheService.getState( + summaryRequest as unknown as AnyRequest + ) - const formState = { + const formState: FormSubmissionState = { ...cachedState, $$__referenceNumber: - options.referenceNumber ?? - cachedState.$$__referenceNumber ?? - 'TODO' + options.referenceNumber ?? cachedState.$$__referenceNumber ?? 'TODO' } return formModel.getFormContext( @@ -160,7 +142,9 @@ export async function resolveFormModel( const stateMetadata = metadata[formState] if (!stateMetadata) { - throw Boom.notFound(`No '${formState}' state for form metadata ${metadata.id}`) + throw Boom.notFound( + `No '${formState}' state for form metadata ${metadata.id}` + ) } // The models cache is created lazily per server instance @@ -253,119 +237,6 @@ export function getFirstJourneyPage( return lastPageReached } -export function mapFormContextToAnswers( - context?: Pick, - { returnPath = '/summary' }: { returnPath?: string } = {} -) { - if (!context) { - return [] - } - - const { relevantPages = [], state = {} } = context - - return relevantPages.flatMap((page) => { - const fields = page.collection.fields - - return fields - .map((field) => { - const value = field.getFormValueFromState(state) - if (!hasRenderableValue(value)) { - return undefined - } - - return { - slug: resolveFieldSlug(page), - changeHref: resolveChangeHref(page, returnPath), - question: safeEvaluateTemplate(field.title, context), - questionKey: field.name, - answer: { - type: mapAnswerType(field), - value, - displayText: getAnswer(field, state) - } - } - }) - .filter(Boolean) - }) -} - -function hasRenderableValue(value: unknown): boolean { - if (value === undefined || value === null) { - return false - } - - if (typeof value === 'string') { - return value.trim().length > 0 - } - - if (Array.isArray(value)) { - return value.some((entry) => hasRenderableValue(entry)) - } - - if (typeof value === 'object') { - return Object.values(value).some((entry) => hasRenderableValue(entry)) - } - - return true -} - -function mapAnswerType(field: Field) { - const map = { - UkAddressField: 'address', - DatePartsField: 'date', - MonthYearField: 'date', - NumberField: 'number', - CheckboxesField: 'checkbox', - FileUploadField: 'file' - } - - return map[field.type] ?? 'text' -} - -function safeEvaluateTemplate(template: string, context: FormContext) { - try { - return evaluateTemplate(template, context) - } catch { - return template - } -} - -function resolveFieldSlug(page?: PageControllerClass) { - if (!page) { - return undefined - } - - try { - if (typeof page.getHref === 'function') { - const targetPath = page.path || '/' - return getPageHref(page, targetPath) - } - } catch { - // ignore and fall back to raw path - } - - return page.path -} - -function resolveChangeHref(page: PageControllerClass | undefined, summaryPath: string) { - const slug = resolveFieldSlug(page) - - if (!page || !slug) { - return slug - } - - if (typeof page.getHref !== 'function' || typeof summaryPath !== 'string') { - return slug - } - - try { - const target = getPageHref(page, summaryPath) - return getPageHref(page, page.path || '/', { returnUrl: target }) - } catch { - return slug - } -} - function resolveState(state: JourneyState): FormStatus { return state === 'preview' ? FormStatus.Live : state } diff --git a/src/server/plugins/engine/index.ts b/src/server/plugins/engine/index.ts index 31c7f5b3c..3126c0139 100644 --- a/src/server/plugins/engine/index.ts +++ b/src/server/plugins/engine/index.ts @@ -18,7 +18,6 @@ export { getFirstJourneyPage, getFormContext, getFormModel, - mapFormContextToAnswers, resolveFormModel } from '~/src/server/plugins/engine/form-context.js' diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 42cfcad80..997c35c28 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -326,7 +326,7 @@ export class FormModel { */ getFormContext( request: FormContextRequest, - state: FormState, + state: FormSubmissionState, errors?: FormSubmissionError[] ): FormContext { const { query } = request @@ -625,7 +625,7 @@ function validateFormState( return context } -function getReferenceNumber(state: FormState): string { +function getReferenceNumber(state: FormSubmissionState): string { if ( !state.$$__referenceNumber || typeof state.$$__referenceNumber !== 'string' From 4e53759b11941b03223b50bff72eb85115cbf2d4 Mon Sep 17 00:00:00 2001 From: David Kelley Date: Tue, 2 Dec 2025 14:33:00 +0000 Subject: [PATCH 03/12] fix: using https for mock url path --- src/server/plugins/engine/form-context.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/form-context.ts b/src/server/plugins/engine/form-context.ts index e38796390..4c9042cc7 100644 --- a/src/server/plugins/engine/form-context.ts +++ b/src/server/plugins/engine/form-context.ts @@ -105,7 +105,10 @@ export async function getFormContext( }, path: `/${formModel.basePath}/summary`, query: {}, - url: new URL(`/${formModel.basePath}/summary`, 'http://form-context.local'), + url: new URL( + `/${formModel.basePath}/summary`, + 'https://form-context.local' + ), server, yar } From e7813c7a33c2a62ca0404e18b36a3f8d67f0aa88 Mon Sep 17 00:00:00 2001 From: David Kelley Date: Tue, 2 Dec 2025 14:47:57 +0000 Subject: [PATCH 04/12] style: updating types for --- src/server/plugins/engine/form-context.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/form-context.ts b/src/server/plugins/engine/form-context.ts index 4c9042cc7..bfd241a91 100644 --- a/src/server/plugins/engine/form-context.ts +++ b/src/server/plugins/engine/form-context.ts @@ -117,11 +117,11 @@ export async function getFormContext( summaryRequest as unknown as AnyRequest ) - const formState: FormSubmissionState = { + const formState = { ...cachedState, $$__referenceNumber: options.referenceNumber ?? cachedState.$$__referenceNumber ?? 'TODO' - } + } as unknown as FormSubmissionState return formModel.getFormContext( summaryRequest, From ebb4e6c7ec099493bcfe7f14aab0d173f3b1ab47 Mon Sep 17 00:00:00 2001 From: David Kelley Date: Tue, 2 Dec 2025 15:15:04 +0000 Subject: [PATCH 05/12] style: formatted files using prettier --- src/server/plugins/engine/routes/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 81fd70e7a..851a73be6 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -5,7 +5,10 @@ import { type Server } from '@hapi/hapi' -import { EXTERNAL_STATE_APPENDAGE, EXTERNAL_STATE_PAYLOAD } from '~/src/server/constants.js' +import { + EXTERNAL_STATE_APPENDAGE, + EXTERNAL_STATE_PAYLOAD +} from '~/src/server/constants.js' import { FormComponent, isFormState From c317dfae50af4612e074c8f230231e969cdf5b50 Mon Sep 17 00:00:00 2001 From: David Kelley Date: Tue, 2 Dec 2025 15:56:01 +0000 Subject: [PATCH 06/12] test: increased test coverage --- .../plugins/engine/form-context.test.ts | 145 ++++++++++++++++-- 1 file changed, 135 insertions(+), 10 deletions(-) diff --git a/src/server/plugins/engine/form-context.test.ts b/src/server/plugins/engine/form-context.test.ts index 5870911a0..ae9f80a0e 100644 --- a/src/server/plugins/engine/form-context.test.ts +++ b/src/server/plugins/engine/form-context.test.ts @@ -3,7 +3,8 @@ import { type Request } from '@hapi/hapi' import { getFirstJourneyPage, getFormContext, - getFormModel + getFormModel, + resolveFormModel } from '~/src/server/plugins/engine/form-context.js' import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' @@ -15,7 +16,7 @@ const mockGetCacheService = jest.fn() const mockCacheService = { getState: jest.fn() } const mockCheckEmailAddressForLiveFormSubmission = jest.fn() -jest.mock('~/src/server/plugins/engine/models/index.js', () => ({ +jest.mock('./models/index.ts', () => ({ __esModule: true, FormModel: jest.fn() })) @@ -30,7 +31,7 @@ jest.mock('~/src/server/plugins/engine/services/index.js', () => ({ outputService: {} })) -jest.mock('~/src/server/plugins/engine/pageControllers/index.js', () => { +jest.mock('./pageControllers/index.ts', () => { class MockTerminalPageController { path = '' } @@ -41,7 +42,7 @@ jest.mock('~/src/server/plugins/engine/pageControllers/index.js', () => { } }) -jest.mock('~/src/server/plugins/engine/helpers.js', () => ({ +jest.mock('./helpers.ts', () => ({ __esModule: true, getCacheService: (...args: unknown[]) => mockGetCacheService(...args), checkEmailAddressForLiveFormSubmission: (...args: unknown[]) => @@ -52,11 +53,9 @@ const mockServices = jest.requireMock( '~/src/server/plugins/engine/services/index.js' ) const mockFormsService = mockServices.formsService -const { FormModel } = jest.requireMock( - '~/src/server/plugins/engine/models/index.js' -) +const { FormModel } = jest.requireMock('./models/index.ts') const { TerminalPageController: MockTerminalPageController } = jest.requireMock( - '~/src/server/plugins/engine/pageControllers/index.js' + './pageControllers/index.ts' ) describe('getFormContext helper', () => { @@ -83,7 +82,9 @@ describe('getFormContext helper', () => { beforeEach(() => { jest.clearAllMocks() formModel = { getFormContext: jest.fn().mockResolvedValue(returnedContext) } - FormModel.mockImplementation(() => formModel) + FormModel.mockImplementation((_definition, modelOptions) => + Object.assign(formModel, { basePath: modelOptions.basePath }) + ) mockFormsService.getFormMetadata.mockResolvedValue(metadata) mockFormsService.getFormDefinition.mockResolvedValue(definition) mockGetCacheService.mockReturnValue(mockCacheService) @@ -127,7 +128,7 @@ describe('getFormContext helper', () => { }) ) expect(summaryRequest.url.toString()).toBe( - 'http://form-context.local/tb-origin/summary' + 'https://form-context.local/tb-origin/summary' ) expect(formModel.getFormContext).toHaveBeenCalledWith( @@ -147,6 +148,42 @@ describe('getFormContext helper', () => { FormStatus.Draft ) }) + + test('passes preview state into the summary request and honours provided reference numbers', async () => { + const errors = [ + { href: '#field', name: 'field', path: ['field'], text: 'is required' } + ] + const referenceNumber = 'REF-12345' + + mockCacheService.getState.mockResolvedValue({ + ...cachedState, + $$__referenceNumber: 'CACHED-REF' + }) + + const context = await getFormContext(request, journey, 'preview', { + referenceNumber, + errors + }) + + const summaryRequest = mockCacheService.getState.mock.calls[0][0] + + expect(summaryRequest.params).toEqual({ + path: 'summary', + slug: journey, + state: 'live' + }) + expect(summaryRequest.path).toBe('/preview/live/tb-origin/summary') + expect(summaryRequest.url.toString()).toBe( + 'https://form-context.local/preview/live/tb-origin/summary' + ) + + expect(formModel.getFormContext).toHaveBeenCalledWith( + summaryRequest, + expect.objectContaining({ $$__referenceNumber: referenceNumber }), + errors + ) + expect(context).toBe(returnedContext) + }) }) describe('getFormModel helper', () => { @@ -228,6 +265,94 @@ describe('getFormModel helper', () => { }) }) +describe('resolveFormModel helper', () => { + const slug = 'tb-origin' + const definition = { pages: [], outputEmail: 'fallback@example.com' } + const metadata = { + id: 'metadata-123', + live: { updatedAt: new Date('2024-10-15T10:00:00Z') }, + versions: [{ versionNumber: 9 }] + } + let server: Request['server'] + let formModelInstance: { id: string } + + beforeEach(() => { + jest.clearAllMocks() + server = { + app: {}, + realm: { modifiers: { route: { prefix: '/forms/' } } } + } as unknown as Request['server'] + formModelInstance = { id: 'form-model-instance' } + FormModel.mockImplementation(() => formModelInstance) + mockFormsService.getFormMetadata.mockResolvedValue(metadata) + mockFormsService.getFormDefinition.mockResolvedValue(definition) + }) + + test('reuses cached models when metadata timestamps match', async () => { + const model = await resolveFormModel(server, slug, FormStatus.Live) + const cached = await resolveFormModel(server, slug, FormStatus.Live) + + expect(model).toBe(formModelInstance) + expect(cached).toBe(model) + expect(server.app.models).toBeInstanceOf(Map) + expect(mockFormsService.getFormDefinition).toHaveBeenCalledTimes(1) + expect(FormModel).toHaveBeenCalledTimes(1) + }) + + test('rebuilds the model when metadata changes and uses preview routing', async () => { + const refreshedModel = { id: 'refreshed-model' } + + FormModel.mockImplementationOnce( + () => formModelInstance + ).mockImplementationOnce(() => refreshedModel) + mockFormsService.getFormMetadata + .mockResolvedValueOnce({ ...metadata, notificationEmail: undefined }) + .mockResolvedValueOnce({ + ...metadata, + notificationEmail: undefined, + live: { updatedAt: new Date('2024-12-01T09:00:00Z') } + }) + + const model = await resolveFormModel(server, slug, 'preview', { + ordnanceSurveyApiKey: 'os-api-key' + }) + const rebuilt = await resolveFormModel(server, slug, 'preview') + + expect(model).toBe(formModelInstance) + expect(rebuilt).toBe(refreshedModel) + expect(FormModel).toHaveBeenCalledTimes(2) + expect(mockFormsService.getFormDefinition).toHaveBeenCalledTimes(2) + expect(mockCheckEmailAddressForLiveFormSubmission).toHaveBeenCalledWith( + definition.outputEmail, + true + ) + expect(FormModel).toHaveBeenCalledWith( + definition, + expect.objectContaining({ + basePath: 'forms/preview/live/tb-origin', + versionNumber: metadata.versions[0].versionNumber, + ordnanceSurveyApiKey: 'os-api-key', + formId: metadata.id + }), + mockServices, + undefined + ) + }) + + test('throws when requested journey state does not exist on metadata', async () => { + mockFormsService.getFormMetadata.mockResolvedValue({ + id: 'metadata-123', + live: { updatedAt: new Date('2024-10-15T10:00:00Z') } + }) + + await expect( + resolveFormModel(server, slug, FormStatus.Draft) + ).rejects.toThrow("No 'draft' state for form metadata metadata-123") + + expect(FormModel).not.toHaveBeenCalled() + }) +}) + describe('getFirstJourneyPage helper', () => { const buildPage = (path: string, keys: string[] = []) => ({ path, keys }) as unknown as PageControllerClass From 1ba8244118a6220d4e2327f8cbfbf9d93aab7a21 Mon Sep 17 00:00:00 2001 From: David Kelley Date: Tue, 2 Dec 2025 16:19:41 +0000 Subject: [PATCH 07/12] style: fixed broke types --- src/server/plugins/engine/form-context.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/form-context.test.ts b/src/server/plugins/engine/form-context.test.ts index ae9f80a0e..6558cfb08 100644 --- a/src/server/plugins/engine/form-context.test.ts +++ b/src/server/plugins/engine/form-context.test.ts @@ -4,7 +4,8 @@ import { getFirstJourneyPage, getFormContext, getFormModel, - resolveFormModel + resolveFormModel, + type FormModelOptions } from '~/src/server/plugins/engine/form-context.js' import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' @@ -82,8 +83,9 @@ describe('getFormContext helper', () => { beforeEach(() => { jest.clearAllMocks() formModel = { getFormContext: jest.fn().mockResolvedValue(returnedContext) } - FormModel.mockImplementation((_definition, modelOptions) => - Object.assign(formModel, { basePath: modelOptions.basePath }) + FormModel.mockImplementation( + (_definition: unknown, modelOptions: FormModelOptions) => + Object.assign(formModel, { basePath: modelOptions.basePath }) ) mockFormsService.getFormMetadata.mockResolvedValue(metadata) mockFormsService.getFormDefinition.mockResolvedValue(definition) From 464e4fef4d0e841c6a33632612d6e746d28df336 Mon Sep 17 00:00:00 2001 From: David Kelley Date: Fri, 5 Dec 2025 11:15:23 +0000 Subject: [PATCH 08/12] refactor: removed option based referenceNumber and using slug instead of journey --- .../plugins/engine/form-context.test.ts | 28 +++++++++---------- src/server/plugins/engine/form-context.ts | 10 +++---- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/server/plugins/engine/form-context.test.ts b/src/server/plugins/engine/form-context.test.ts index 6558cfb08..d037d8463 100644 --- a/src/server/plugins/engine/form-context.test.ts +++ b/src/server/plugins/engine/form-context.test.ts @@ -67,7 +67,7 @@ describe('getFormContext helper', () => { realm: { modifiers: { route: { prefix: '' } } } } as unknown as Request['server'] } satisfies Pick - const journey = 'tb-origin' + const slug = 'tb-origin' const cachedState = { answered: true } const returnedContext = { errors: [] } const metadata = { @@ -94,9 +94,9 @@ describe('getFormContext helper', () => { }) test('builds a form context using cached state and configured services', async () => { - const context = await getFormContext(request, journey) + const context = await getFormContext(request, slug) - expect(mockFormsService.getFormMetadata).toHaveBeenCalledWith(journey) + expect(mockFormsService.getFormMetadata).toHaveBeenCalledWith(slug) expect(mockFormsService.getFormDefinition).toHaveBeenCalledWith( metadata.id, 'live' @@ -105,7 +105,7 @@ describe('getFormContext helper', () => { expect(FormModel).toHaveBeenCalledWith( definition, { - basePath: journey, + basePath: slug, versionNumber: metadata.versions[0].versionNumber, ordnanceSurveyApiKey: undefined, formId: metadata.id @@ -123,9 +123,9 @@ describe('getFormContext helper', () => { expect.objectContaining({ yar: request.yar, method: 'get', - params: { path: 'summary', slug: journey }, + params: { path: 'summary', slug }, query: {}, - path: `/${journey}/summary`, + path: `/${slug}/summary`, server: request.server }) ) @@ -142,8 +142,8 @@ describe('getFormContext helper', () => { expect(context).toBe(returnedContext) }) - test('passes through the requested journey state when resolving the form model', async () => { - await getFormContext(request, journey, FormStatus.Draft) + test('passes through the requested form state when resolving the form model', async () => { + await getFormContext(request, slug, FormStatus.Draft) expect(mockFormsService.getFormDefinition).toHaveBeenCalledWith( metadata.id, @@ -151,19 +151,17 @@ describe('getFormContext helper', () => { ) }) - test('passes preview state into the summary request and honours provided reference numbers', async () => { + test('passes preview state into the summary request and uses cached reference numbers', async () => { const errors = [ { href: '#field', name: 'field', path: ['field'], text: 'is required' } ] - const referenceNumber = 'REF-12345' mockCacheService.getState.mockResolvedValue({ ...cachedState, $$__referenceNumber: 'CACHED-REF' }) - const context = await getFormContext(request, journey, 'preview', { - referenceNumber, + const context = await getFormContext(request, slug, 'preview', { errors }) @@ -171,7 +169,7 @@ describe('getFormContext helper', () => { expect(summaryRequest.params).toEqual({ path: 'summary', - slug: journey, + slug, state: 'live' }) expect(summaryRequest.path).toBe('/preview/live/tb-origin/summary') @@ -181,7 +179,7 @@ describe('getFormContext helper', () => { expect(formModel.getFormContext).toHaveBeenCalledWith( summaryRequest, - expect.objectContaining({ $$__referenceNumber: referenceNumber }), + expect.objectContaining({ $$__referenceNumber: 'CACHED-REF' }), errors ) expect(context).toBe(returnedContext) @@ -341,7 +339,7 @@ describe('resolveFormModel helper', () => { ) }) - test('throws when requested journey state does not exist on metadata', async () => { + test('throws when requested form state does not exist on metadata', async () => { mockFormsService.getFormMetadata.mockResolvedValue({ id: 'metadata-123', live: { updatedAt: new Date('2024-10-15T10:00:00Z') } diff --git a/src/server/plugins/engine/form-context.ts b/src/server/plugins/engine/form-context.ts index bfd241a91..dcaf04993 100644 --- a/src/server/plugins/engine/form-context.ts +++ b/src/server/plugins/engine/form-context.ts @@ -36,7 +36,6 @@ export interface FormModelOptions { export interface FormContextOptions extends FormModelOptions { errors?: FormSubmissionError[] - referenceNumber?: string } type SummaryRequest = FormContextRequest & { @@ -85,11 +84,11 @@ export async function getFormModel( export async function getFormContext( { server, yar }: Pick, - journey: string, + slug: string, state: JourneyState = FormStatus.Live, options: FormContextOptions = {} ): Promise { - const formModel = await resolveFormModel(server, journey, state, options) + const formModel = await resolveFormModel(server, slug, state, options) const cacheService = getCacheService(server) @@ -98,7 +97,7 @@ export async function getFormContext( method: 'get', params: { path: 'summary', - slug: journey, + slug, ...(isPreviewState(state, options) && { state: resolveState(state) }) @@ -119,8 +118,7 @@ export async function getFormContext( const formState = { ...cachedState, - $$__referenceNumber: - options.referenceNumber ?? cachedState.$$__referenceNumber ?? 'TODO' + $$__referenceNumber: cachedState.$$__referenceNumber ?? 'TODO' } as unknown as FormSubmissionState return formModel.getFormContext( From 20f7b73ff5b3c89748eb484d7eaa17c3cb08253c Mon Sep 17 00:00:00 2001 From: David Kelley Date: Fri, 5 Dec 2025 11:22:25 +0000 Subject: [PATCH 09/12] test: increased code coverage on form-context --- src/server/plugins/engine/form-context.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/server/plugins/engine/form-context.test.ts b/src/server/plugins/engine/form-context.test.ts index d037d8463..8303814ab 100644 --- a/src/server/plugins/engine/form-context.test.ts +++ b/src/server/plugins/engine/form-context.test.ts @@ -351,6 +351,19 @@ describe('resolveFormModel helper', () => { expect(FormModel).not.toHaveBeenCalled() }) + + test('throws when no form definition is available for the requested state', async () => { + mockFormsService.getFormDefinition.mockResolvedValue(undefined) + + await expect( + resolveFormModel(server, slug, FormStatus.Live) + ).rejects.toThrow( + `No definition found for form metadata ${metadata.id} (${slug}) ${FormStatus.Live}` + ) + + expect(FormModel).not.toHaveBeenCalled() + expect(mockCheckEmailAddressForLiveFormSubmission).not.toHaveBeenCalled() + }) }) describe('getFirstJourneyPage helper', () => { From 0198a6383a9ea63047ab505a4d3af50ca592f897 Mon Sep 17 00:00:00 2001 From: David Kelley Date: Mon, 8 Dec 2025 13:33:56 +0000 Subject: [PATCH 10/12] fix: removing TODO cached referencenumber --- .../plugins/engine/form-context.test.ts | 58 ------------------- src/server/plugins/engine/form-context.ts | 2 +- 2 files changed, 1 insertion(+), 59 deletions(-) diff --git a/src/server/plugins/engine/form-context.test.ts b/src/server/plugins/engine/form-context.test.ts index 8303814ab..599e9394f 100644 --- a/src/server/plugins/engine/form-context.test.ts +++ b/src/server/plugins/engine/form-context.test.ts @@ -93,64 +93,6 @@ describe('getFormContext helper', () => { mockCacheService.getState.mockResolvedValue(cachedState) }) - test('builds a form context using cached state and configured services', async () => { - const context = await getFormContext(request, slug) - - expect(mockFormsService.getFormMetadata).toHaveBeenCalledWith(slug) - expect(mockFormsService.getFormDefinition).toHaveBeenCalledWith( - metadata.id, - 'live' - ) - - expect(FormModel).toHaveBeenCalledWith( - definition, - { - basePath: slug, - versionNumber: metadata.versions[0].versionNumber, - ordnanceSurveyApiKey: undefined, - formId: metadata.id - }, - mockServices, - undefined - ) - - expect(mockGetCacheService).toHaveBeenCalledWith(request.server) - expect(mockCacheService.getState).toHaveBeenCalledTimes(1) - - const summaryRequest = mockCacheService.getState.mock.calls[0][0] - - expect(summaryRequest).toEqual( - expect.objectContaining({ - yar: request.yar, - method: 'get', - params: { path: 'summary', slug }, - query: {}, - path: `/${slug}/summary`, - server: request.server - }) - ) - expect(summaryRequest.url.toString()).toBe( - 'https://form-context.local/tb-origin/summary' - ) - - expect(formModel.getFormContext).toHaveBeenCalledWith( - summaryRequest, - { $$__referenceNumber: 'TODO', ...cachedState }, - [] - ) - - expect(context).toBe(returnedContext) - }) - - test('passes through the requested form state when resolving the form model', async () => { - await getFormContext(request, slug, FormStatus.Draft) - - expect(mockFormsService.getFormDefinition).toHaveBeenCalledWith( - metadata.id, - FormStatus.Draft - ) - }) - test('passes preview state into the summary request and uses cached reference numbers', async () => { const errors = [ { href: '#field', name: 'field', path: ['field'], text: 'is required' } diff --git a/src/server/plugins/engine/form-context.ts b/src/server/plugins/engine/form-context.ts index dcaf04993..1f955073c 100644 --- a/src/server/plugins/engine/form-context.ts +++ b/src/server/plugins/engine/form-context.ts @@ -118,7 +118,7 @@ export async function getFormContext( const formState = { ...cachedState, - $$__referenceNumber: cachedState.$$__referenceNumber ?? 'TODO' + $$__referenceNumber: cachedState.$$__referenceNumber } as unknown as FormSubmissionState return formModel.getFormContext( From 86b27eb305f26dc9f6536e939fcb4660f52dc86e Mon Sep 17 00:00:00 2001 From: David Kelley Date: Thu, 11 Dec 2025 13:01:28 +0000 Subject: [PATCH 11/12] fix: move new form-content helper functions into beta folder --- .../plugins/engine/{ => beta}/form-context.test.ts | 14 +++++++------- .../plugins/engine/{ => beta}/form-context.ts | 0 src/server/plugins/engine/index.ts | 2 +- src/server/plugins/engine/routes/index.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) rename src/server/plugins/engine/{ => beta}/form-context.test.ts (97%) rename src/server/plugins/engine/{ => beta}/form-context.ts (100%) diff --git a/src/server/plugins/engine/form-context.test.ts b/src/server/plugins/engine/beta/form-context.test.ts similarity index 97% rename from src/server/plugins/engine/form-context.test.ts rename to src/server/plugins/engine/beta/form-context.test.ts index 599e9394f..6aa3ecef8 100644 --- a/src/server/plugins/engine/form-context.test.ts +++ b/src/server/plugins/engine/beta/form-context.test.ts @@ -6,7 +6,7 @@ import { getFormModel, resolveFormModel, type FormModelOptions -} from '~/src/server/plugins/engine/form-context.js' +} from '~/src/server/plugins/engine/beta/form-context.js' import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' import { type FormContext } from '~/src/server/plugins/engine/types.js' @@ -17,7 +17,7 @@ const mockGetCacheService = jest.fn() const mockCacheService = { getState: jest.fn() } const mockCheckEmailAddressForLiveFormSubmission = jest.fn() -jest.mock('./models/index.ts', () => ({ +jest.mock('../models/index.ts', () => ({ __esModule: true, FormModel: jest.fn() })) @@ -32,7 +32,7 @@ jest.mock('~/src/server/plugins/engine/services/index.js', () => ({ outputService: {} })) -jest.mock('./pageControllers/index.ts', () => { +jest.mock('../pageControllers/index.ts', () => { class MockTerminalPageController { path = '' } @@ -43,7 +43,7 @@ jest.mock('./pageControllers/index.ts', () => { } }) -jest.mock('./helpers.ts', () => ({ +jest.mock('../helpers.ts', () => ({ __esModule: true, getCacheService: (...args: unknown[]) => mockGetCacheService(...args), checkEmailAddressForLiveFormSubmission: (...args: unknown[]) => @@ -54,9 +54,9 @@ const mockServices = jest.requireMock( '~/src/server/plugins/engine/services/index.js' ) const mockFormsService = mockServices.formsService -const { FormModel } = jest.requireMock('./models/index.ts') +const { FormModel } = jest.requireMock('../models/index.ts') const { TerminalPageController: MockTerminalPageController } = jest.requireMock( - './pageControllers/index.ts' + '../pageControllers/index.ts' ) describe('getFormContext helper', () => { @@ -355,5 +355,5 @@ describe('getFirstJourneyPage helper', () => { }) /** - * @import { FormContext } from './types.js' + * @import { FormContext } from '../types.js' */ diff --git a/src/server/plugins/engine/form-context.ts b/src/server/plugins/engine/beta/form-context.ts similarity index 100% rename from src/server/plugins/engine/form-context.ts rename to src/server/plugins/engine/beta/form-context.ts diff --git a/src/server/plugins/engine/index.ts b/src/server/plugins/engine/index.ts index 3126c0139..3b73bb5d3 100644 --- a/src/server/plugins/engine/index.ts +++ b/src/server/plugins/engine/index.ts @@ -19,7 +19,7 @@ export { getFormContext, getFormModel, resolveFormModel -} from '~/src/server/plugins/engine/form-context.js' +} from '~/src/server/plugins/engine/beta/form-context.js' const globals = { checkComponentTemplates, diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 851a73be6..543aa766e 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -9,11 +9,11 @@ import { EXTERNAL_STATE_APPENDAGE, EXTERNAL_STATE_PAYLOAD } from '~/src/server/constants.js' +import { resolveFormModel } from '~/src/server/plugins/engine/beta/form-context.js' import { FormComponent, isFormState } from '~/src/server/plugins/engine/components/FormComponent.js' -import { resolveFormModel } from '~/src/server/plugins/engine/form-context.js' import { checkFormStatus, findPage, From 6f25a75bbcee29701b43f152bc971feccdf1e5b3 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 12 Dec 2025 10:18:50 +0000 Subject: [PATCH 12/12] chore(release): #minor