diff --git a/src/server/plugins/engine/beta/form-context.test.ts b/src/server/plugins/engine/beta/form-context.test.ts new file mode 100644 index 000000000..6aa3ecef8 --- /dev/null +++ b/src/server/plugins/engine/beta/form-context.test.ts @@ -0,0 +1,359 @@ +import { type Request } from '@hapi/hapi' + +import { + getFirstJourneyPage, + getFormContext, + getFormModel, + resolveFormModel, + type FormModelOptions +} 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' +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 mockCheckEmailAddressForLiveFormSubmission = jest.fn() + +jest.mock('../models/index.ts', () => ({ + __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('../pageControllers/index.ts', () => { + class MockTerminalPageController { + path = '' + } + + return { + __esModule: true, + TerminalPageController: MockTerminalPageController + } +}) + +jest.mock('../helpers.ts', () => ({ + __esModule: true, + getCacheService: (...args: unknown[]) => mockGetCacheService(...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('../models/index.ts') +const { TerminalPageController: MockTerminalPageController } = jest.requireMock( + '../pageControllers/index.ts' +) + +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 slug = '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() + formModel = { getFormContext: jest.fn().mockResolvedValue(returnedContext) } + FormModel.mockImplementation( + (_definition: unknown, modelOptions: FormModelOptions) => + Object.assign(formModel, { basePath: modelOptions.basePath }) + ) + mockFormsService.getFormMetadata.mockResolvedValue(metadata) + mockFormsService.getFormDefinition.mockResolvedValue(definition) + mockGetCacheService.mockReturnValue(mockCacheService) + mockCacheService.getState.mockResolvedValue(cachedState) + }) + + 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' } + ] + + mockCacheService.getState.mockResolvedValue({ + ...cachedState, + $$__referenceNumber: 'CACHED-REF' + }) + + const context = await getFormContext(request, slug, 'preview', { + errors + }) + + const summaryRequest = mockCacheService.getState.mock.calls[0][0] + + expect(summaryRequest.params).toEqual({ + path: 'summary', + slug, + 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: 'CACHED-REF' }), + errors + ) + expect(context).toBe(returnedContext) + }) +}) + +describe('getFormModel helper', () => { + const slug = 'tb-origin' + 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: FormsService + let services: Services + let formModelInstance: { id: string } + + beforeEach(() => { + jest.clearAllMocks() + formModelInstance = { id: 'form-model-instance' } + FormModel.mockImplementation(() => formModelInstance) + services = { + 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 () => { + 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 () => { + jest.mocked(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('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 form 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() + }) + + 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', () => { + 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() + expect(getFirstJourneyPage({ relevantPages: [] })).toBeUndefined() + }) + + test('returns the page matching the last recorded path', () => { + const startPage = buildPage('/start') + const nextPage = buildPage('/animals') + + const context: Pick = { + 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: Pick = { + 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: Pick = { + relevantPages: [exitPage] + } + + expect(getFirstJourneyPage(context)).toBe(exitPage) + }) +}) + +/** + * @import { FormContext } from '../types.js' + */ diff --git a/src/server/plugins/engine/beta/form-context.ts b/src/server/plugins/engine/beta/form-context.ts new file mode 100644 index 000000000..1f955073c --- /dev/null +++ b/src/server/plugins/engine/beta/form-context.ts @@ -0,0 +1,250 @@ +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 { + checkEmailAddressForLiveFormSubmission, + 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 { 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 FormSubmissionState +} 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[] +} + +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, + slug: string, + state: JourneyState = FormStatus.Live, + options: FormContextOptions = {} +): Promise { + const formModel = await resolveFormModel(server, slug, state, options) + + const cacheService = getCacheService(server) + + const summaryRequest: SummaryRequest = { + app: {}, + method: 'get', + params: { + path: 'summary', + slug, + ...(isPreviewState(state, options) && { + state: resolveState(state) + }) + }, + path: `/${formModel.basePath}/summary`, + query: {}, + url: new URL( + `/${formModel.basePath}/summary`, + 'https://form-context.local' + ), + server, + yar + } + + const cachedState = await cacheService.getState( + summaryRequest as unknown as AnyRequest + ) + + const formState = { + ...cachedState, + $$__referenceNumber: cachedState.$$__referenceNumber + } as unknown as FormSubmissionState + + 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 +} + +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..3b73bb5d3 100644 --- a/src/server/plugins/engine/index.ts +++ b/src/server/plugins/engine/index.ts @@ -14,6 +14,12 @@ 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, + resolveFormModel +} from '~/src/server/plugins/engine/beta/form-context.js' const globals = { checkComponentTemplates, 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' diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 250d005eb..543aa766e 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -4,19 +4,17 @@ import { type ResponseToolkit, type Server } from '@hapi/hapi' -import { isEqual } from 'date-fns' import { EXTERNAL_STATE_APPENDAGE, - EXTERNAL_STATE_PAYLOAD, - PREVIEW_PATH_PREFIX + 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 { - checkEmailAddressForLiveFormSubmission, checkFormStatus, findPage, getCacheService, @@ -24,7 +22,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 +176,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 +187,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 }