@defra/forms-engine-plugin 4.0.28 → 4.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["Boom","isEqual","EXTERNAL_STATE_APPENDAGE","EXTERNAL_STATE_PAYLOAD","PREVIEW_PATH_PREFIX","FormComponent","isFormState","checkEmailAddressForLiveFormSubmission","checkFormStatus","findPage","getCacheService","getPage","getStartPath","proceed","FormModel","generateUniqueReference","defaultServices","redirectOrMakeHandler","request","h","onRequest","makeHandler","app","params","model","notFound","path","cacheService","server","page","state","getState","$$__referenceNumber","prefix","def","metadata","referenceNumberPrefix","badImplementation","referenceNumber","mergeState","importExternalComponentState","flash","getFlash","context","getFormContext","errors","relevantPath","getRelevantPath","summaryPath","getSummaryPath","result","continue","startsWith","isForceAccess","redirectTo","next","length","query","returnUrl","getHref","externalComponentData","yar","Array","isArray","typedStateAppendage","componentName","component","stateAppendage","data","componentMap","get","Error","TypeError","isStateValid","isState","componentState","Object","fromEntries","entries","map","key","value","pageState","getStateFromValidForm","savedState","payload","stashedPayload","localState","makeLoadFormPreHandler","options","realm","modifiers","route","services","controllers","ordnanceSurveyApiKey","formsService","handler","slug","isPreview","formState","getFormMetadata","id","item","models","updatedAt","logger","info","definition","getFormDefinition","emailAddress","notificationEmail","outputEmail","basePath","substring","versionNumber","versions","formId","set","dispatchHandler","servicePath"],"sources":["../../../../../src/server/plugins/engine/routes/index.ts"],"sourcesContent":["import Boom from '@hapi/boom'\nimport {\n type ResponseObject,\n type ResponseToolkit,\n type Server\n} from '@hapi/hapi'\nimport { isEqual } from 'date-fns'\n\nimport {\n EXTERNAL_STATE_APPENDAGE,\n EXTERNAL_STATE_PAYLOAD,\n PREVIEW_PATH_PREFIX\n} from '~/src/server/constants.js'\nimport {\n FormComponent,\n isFormState\n} from '~/src/server/plugins/engine/components/FormComponent.js'\nimport {\n checkEmailAddressForLiveFormSubmission,\n checkFormStatus,\n findPage,\n getCacheService,\n getPage,\n getStartPath,\n proceed\n} from '~/src/server/plugins/engine/helpers.js'\nimport { FormModel } from '~/src/server/plugins/engine/models/index.js'\nimport { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'\nimport { generateUniqueReference } from '~/src/server/plugins/engine/referenceNumbers.js'\nimport * as defaultServices from '~/src/server/plugins/engine/services/index.js'\nimport {\n type AnyFormRequest,\n type ExternalStateAppendage,\n type FormContext,\n type FormPayload,\n type FormSubmissionState,\n type OnRequestCallback,\n type PluginOptions\n} from '~/src/server/plugins/engine/types.js'\nimport {\n type FormRequest,\n type FormResponseToolkit\n} from '~/src/server/routes/types.js'\n\nexport async function redirectOrMakeHandler(\n request: AnyFormRequest,\n h: FormResponseToolkit,\n onRequest: OnRequestCallback | undefined,\n makeHandler: (\n page: PageControllerClass,\n context: FormContext\n ) => ResponseObject | Promise<ResponseObject>\n) {\n const { app, params } = request\n const { model } = app\n\n if (!model) {\n throw Boom.notFound(`No model found for /${params.path}`)\n }\n\n const cacheService = getCacheService(request.server)\n const page = getPage(model, request)\n let state = await page.getState(request)\n\n if (!state.$$__referenceNumber) {\n const prefix = model.def.metadata?.referenceNumberPrefix ?? ''\n\n if (typeof prefix !== 'string') {\n throw Boom.badImplementation(\n 'Reference number prefix must be a string or undefined'\n )\n }\n\n const referenceNumber = generateUniqueReference(prefix)\n state = await page.mergeState(request, state, {\n $$__referenceNumber: referenceNumber\n })\n }\n\n state = await importExternalComponentState(request, page, state)\n\n const flash = cacheService.getFlash(request)\n const context = model.getFormContext(request, state, flash?.errors)\n const relevantPath = page.getRelevantPath(request, context)\n const summaryPath = page.getSummaryPath()\n\n // Call the onRequest callback if it has been supplied\n if (onRequest) {\n const result = await onRequest(request, h, context)\n if (result !== h.continue) {\n return result\n }\n }\n\n // Return handler for relevant pages or preview URL direct access\n if (relevantPath.startsWith(page.path) || context.isForceAccess) {\n return makeHandler(page, context)\n }\n\n // Redirect back to last relevant page\n const redirectTo = findPage(model, relevantPath)\n\n // Set the return URL unless an exit page\n if (redirectTo?.next.length) {\n request.query.returnUrl = page.getHref(summaryPath)\n }\n\n return proceed(request, h, page.getHref(relevantPath))\n}\n\nasync function importExternalComponentState(\n request: AnyFormRequest,\n page: PageControllerClass,\n state: FormSubmissionState\n): Promise<FormSubmissionState> {\n const externalComponentData = request.yar.flash(EXTERNAL_STATE_APPENDAGE)\n\n if (Array.isArray(externalComponentData)) {\n return state\n }\n\n const typedStateAppendage = externalComponentData as ExternalStateAppendage\n const componentName = typedStateAppendage.component\n const stateAppendage = typedStateAppendage.data\n const component = request.app.model?.componentMap.get(componentName)\n\n if (!component) {\n throw new Error(`Component ${componentName} not found in form`)\n }\n\n if (!(component instanceof FormComponent)) {\n throw new TypeError(\n `Component ${componentName} is not a FormComponent and does not support isState`\n )\n }\n\n const isStateValid = component.isState(stateAppendage)\n\n if (!isStateValid) {\n throw new Error(`State for component ${componentName} is invalid`)\n }\n\n const componentState = isFormState(stateAppendage)\n ? Object.fromEntries(\n Object.entries(stateAppendage).map(([key, value]) => [\n `${componentName}__${key}`,\n value\n ])\n )\n : { [componentName]: stateAppendage }\n\n // Save the external component state immediately\n const pageState = page.getStateFromValidForm(\n request,\n state,\n componentState as FormPayload\n )\n const savedState = await page.mergeState(request, state, pageState)\n\n // Merge any stashed payload into the local state\n const payload = request.yar.flash(EXTERNAL_STATE_PAYLOAD)\n const stashedPayload = Array.isArray(payload) ? {} : (payload as FormPayload)\n\n const localState = page.getStateFromValidForm(request, savedState, {\n ...stashedPayload,\n ...componentState\n } as FormPayload)\n\n return { ...savedState, ...localState }\n}\n\nexport function makeLoadFormPreHandler(server: Server, options: PluginOptions) {\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- hapi types are wrong\n const prefix = server.realm.modifiers.route.prefix ?? ''\n\n const {\n services = defaultServices,\n controllers,\n ordnanceSurveyApiKey\n } = options\n\n const { formsService } = services\n\n async function handler(request: AnyFormRequest, h: ResponseToolkit) {\n if (server.app.model) {\n request.app.model = server.app.model\n\n return h.continue\n }\n\n const { params } = request\n const { slug } = params\n const { isPreview, state: formState } = checkFormStatus(params)\n\n // Get the form metadata using the `slug` param\n const metadata = await formsService.getFormMetadata(slug)\n\n const { id, [formState]: state } = metadata\n\n // Check the metadata supports the requested state\n if (!state) {\n throw Boom.notFound(`No '${formState}' state for form metadata ${id}`)\n }\n\n // Cache the models based on id, state and whether\n // it's a preview or not. There could be up to 3 models\n // cached for a single form:\n // \"{id}_live_false\" (live/live)\n // \"{id}_live_true\" (live/preview)\n // \"{id}_draft_true\" (draft/preview)\n const key = `${id}_${formState}_${isPreview}`\n let item = server.app.models.get(key)\n\n if (!item || !isEqual(item.updatedAt, state.updatedAt)) {\n server.logger.info(`Getting form definition ${id} (${slug}) ${formState}`)\n\n // Get the form definition using the `id` from the metadata\n const definition = await formsService.getFormDefinition(id, formState)\n\n if (!definition) {\n throw Boom.notFound(\n `No definition found for form metadata ${id} (${slug}) ${formState}`\n )\n }\n\n const emailAddress = metadata.notificationEmail ?? definition.outputEmail\n\n checkEmailAddressForLiveFormSubmission(emailAddress, isPreview)\n\n // Build the form model\n server.logger.info(\n `Building model for form definition ${id} (${slug}) ${formState}`\n )\n\n // Set up the basePath for the model\n const basePath = (\n isPreview\n ? `${prefix}${PREVIEW_PATH_PREFIX}/${formState}/${slug}`\n : `${prefix}/${slug}`\n ).substring(1)\n\n const versionNumber = metadata.versions?.[0]?.versionNumber\n\n // Construct the form model\n const model = new FormModel(\n definition,\n { basePath, versionNumber, ordnanceSurveyApiKey, formId: id },\n services,\n controllers\n )\n\n // Create new item and add it to the item cache\n item = { model, updatedAt: state.updatedAt }\n server.app.models.set(key, item)\n }\n\n // Assign the model to the request data\n // for use in the downstream handler\n request.app.model = item.model\n\n return h.continue\n }\n\n return handler\n}\n\nexport function dispatchHandler(request: FormRequest, h: FormResponseToolkit) {\n const { model } = request.app\n\n const servicePath = model ? `/${model.basePath}` : ''\n return proceed(request, h, `${servicePath}${getStartPath(model)}`)\n}\n"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"file":"index.js","names":["Boom","EXTERNAL_STATE_APPENDAGE","EXTERNAL_STATE_PAYLOAD","resolveFormModel","FormComponent","isFormState","checkFormStatus","findPage","getCacheService","getPage","getStartPath","proceed","generateUniqueReference","defaultServices","redirectOrMakeHandler","request","h","onRequest","makeHandler","app","params","model","notFound","path","cacheService","server","page","state","getState","$$__referenceNumber","prefix","def","metadata","referenceNumberPrefix","badImplementation","referenceNumber","mergeState","importExternalComponentState","flash","getFlash","context","getFormContext","errors","relevantPath","getRelevantPath","summaryPath","getSummaryPath","result","continue","startsWith","isForceAccess","redirectTo","next","length","query","returnUrl","getHref","externalComponentData","yar","Array","isArray","typedStateAppendage","componentName","component","stateAppendage","data","componentMap","get","Error","TypeError","isStateValid","isState","componentState","Object","fromEntries","entries","map","key","value","pageState","getStateFromValidForm","savedState","payload","stashedPayload","localState","makeLoadFormPreHandler","options","realm","modifiers","route","services","controllers","ordnanceSurveyApiKey","handler","slug","isPreview","formState","routePrefix","dispatchHandler","servicePath","basePath"],"sources":["../../../../../src/server/plugins/engine/routes/index.ts"],"sourcesContent":["import Boom from '@hapi/boom'\nimport {\n type ResponseObject,\n type ResponseToolkit,\n type Server\n} from '@hapi/hapi'\n\nimport {\n EXTERNAL_STATE_APPENDAGE,\n EXTERNAL_STATE_PAYLOAD\n} from '~/src/server/constants.js'\nimport { resolveFormModel } from '~/src/server/plugins/engine/beta/form-context.js'\nimport {\n FormComponent,\n isFormState\n} from '~/src/server/plugins/engine/components/FormComponent.js'\nimport {\n checkFormStatus,\n findPage,\n getCacheService,\n getPage,\n getStartPath,\n proceed\n} from '~/src/server/plugins/engine/helpers.js'\nimport { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'\nimport { generateUniqueReference } from '~/src/server/plugins/engine/referenceNumbers.js'\nimport * as defaultServices from '~/src/server/plugins/engine/services/index.js'\nimport {\n type AnyFormRequest,\n type ExternalStateAppendage,\n type FormContext,\n type FormPayload,\n type FormSubmissionState,\n type OnRequestCallback,\n type PluginOptions\n} from '~/src/server/plugins/engine/types.js'\nimport {\n type FormRequest,\n type FormResponseToolkit\n} from '~/src/server/routes/types.js'\n\nexport async function redirectOrMakeHandler(\n request: AnyFormRequest,\n h: FormResponseToolkit,\n onRequest: OnRequestCallback | undefined,\n makeHandler: (\n page: PageControllerClass,\n context: FormContext\n ) => ResponseObject | Promise<ResponseObject>\n) {\n const { app, params } = request\n const { model } = app\n\n if (!model) {\n throw Boom.notFound(`No model found for /${params.path}`)\n }\n\n const cacheService = getCacheService(request.server)\n const page = getPage(model, request)\n let state = await page.getState(request)\n\n if (!state.$$__referenceNumber) {\n const prefix = model.def.metadata?.referenceNumberPrefix ?? ''\n\n if (typeof prefix !== 'string') {\n throw Boom.badImplementation(\n 'Reference number prefix must be a string or undefined'\n )\n }\n\n const referenceNumber = generateUniqueReference(prefix)\n state = await page.mergeState(request, state, {\n $$__referenceNumber: referenceNumber\n })\n }\n\n state = await importExternalComponentState(request, page, state)\n\n const flash = cacheService.getFlash(request)\n const context = model.getFormContext(request, state, flash?.errors)\n const relevantPath = page.getRelevantPath(request, context)\n const summaryPath = page.getSummaryPath()\n\n // Call the onRequest callback if it has been supplied\n if (onRequest) {\n const result = await onRequest(request, h, context)\n if (result !== h.continue) {\n return result\n }\n }\n\n // Return handler for relevant pages or preview URL direct access\n if (relevantPath.startsWith(page.path) || context.isForceAccess) {\n return makeHandler(page, context)\n }\n\n // Redirect back to last relevant page\n const redirectTo = findPage(model, relevantPath)\n\n // Set the return URL unless an exit page\n if (redirectTo?.next.length) {\n request.query.returnUrl = page.getHref(summaryPath)\n }\n\n return proceed(request, h, page.getHref(relevantPath))\n}\n\nasync function importExternalComponentState(\n request: AnyFormRequest,\n page: PageControllerClass,\n state: FormSubmissionState\n): Promise<FormSubmissionState> {\n const externalComponentData = request.yar.flash(EXTERNAL_STATE_APPENDAGE)\n\n if (Array.isArray(externalComponentData)) {\n return state\n }\n\n const typedStateAppendage = externalComponentData as ExternalStateAppendage\n const componentName = typedStateAppendage.component\n const stateAppendage = typedStateAppendage.data\n const component = request.app.model?.componentMap.get(componentName)\n\n if (!component) {\n throw new Error(`Component ${componentName} not found in form`)\n }\n\n if (!(component instanceof FormComponent)) {\n throw new TypeError(\n `Component ${componentName} is not a FormComponent and does not support isState`\n )\n }\n\n const isStateValid = component.isState(stateAppendage)\n\n if (!isStateValid) {\n throw new Error(`State for component ${componentName} is invalid`)\n }\n\n const componentState = isFormState(stateAppendage)\n ? Object.fromEntries(\n Object.entries(stateAppendage).map(([key, value]) => [\n `${componentName}__${key}`,\n value\n ])\n )\n : { [componentName]: stateAppendage }\n\n // Save the external component state immediately\n const pageState = page.getStateFromValidForm(\n request,\n state,\n componentState as FormPayload\n )\n const savedState = await page.mergeState(request, state, pageState)\n\n // Merge any stashed payload into the local state\n const payload = request.yar.flash(EXTERNAL_STATE_PAYLOAD)\n const stashedPayload = Array.isArray(payload) ? {} : (payload as FormPayload)\n\n const localState = page.getStateFromValidForm(request, savedState, {\n ...stashedPayload,\n ...componentState\n } as FormPayload)\n\n return { ...savedState, ...localState }\n}\n\nexport function makeLoadFormPreHandler(server: Server, options: PluginOptions) {\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- hapi types are wrong\n const prefix = server.realm.modifiers.route.prefix ?? ''\n\n const {\n services = defaultServices,\n controllers,\n ordnanceSurveyApiKey\n } = options\n\n async function handler(request: AnyFormRequest, h: ResponseToolkit) {\n if (server.app.model) {\n request.app.model = server.app.model\n\n return h.continue\n }\n\n const { params } = request\n const { slug } = params\n const { isPreview, state: formState } = checkFormStatus(params)\n\n const model = await resolveFormModel(server, slug, formState, {\n services,\n controllers,\n ordnanceSurveyApiKey,\n routePrefix: prefix,\n isPreview\n })\n\n request.app.model = model\n\n return h.continue\n }\n\n return handler\n}\n\nexport function dispatchHandler(request: FormRequest, h: FormResponseToolkit) {\n const { model } = request.app\n\n const servicePath = model ? `/${model.basePath}` : ''\n return proceed(request, h, `${servicePath}${getStartPath(model)}`)\n}\n"],"mappings":"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","ignoreList":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/forms-engine-plugin",
3
- "version": "4.0.28",
3
+ "version": "4.0.30",
4
4
  "description": "Defra forms engine",
5
5
  "type": "module",
6
6
  "files": [
@@ -70,7 +70,7 @@
70
70
  },
71
71
  "license": "SEE LICENSE IN LICENSE",
72
72
  "dependencies": {
73
- "@defra/forms-model": "^3.0.585",
73
+ "@defra/forms-model": "^3.0.597",
74
74
  "@defra/hapi-tracing": "^1.29.0",
75
75
  "@elastic/ecs-pino-format": "^1.5.0",
76
76
  "@hapi/boom": "^10.0.1",
@@ -0,0 +1,359 @@
1
+ import { type Request } from '@hapi/hapi'
2
+
3
+ import {
4
+ getFirstJourneyPage,
5
+ getFormContext,
6
+ getFormModel,
7
+ resolveFormModel,
8
+ type FormModelOptions
9
+ } from '~/src/server/plugins/engine/beta/form-context.js'
10
+ import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'
11
+ import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
12
+ import { type FormContext } from '~/src/server/plugins/engine/types.js'
13
+ import { FormStatus } from '~/src/server/routes/types.js'
14
+ import { type FormsService, type Services } from '~/src/server/types.js'
15
+
16
+ const mockGetCacheService = jest.fn()
17
+ const mockCacheService = { getState: jest.fn() }
18
+ const mockCheckEmailAddressForLiveFormSubmission = jest.fn()
19
+
20
+ jest.mock('../models/index.ts', () => ({
21
+ __esModule: true,
22
+ FormModel: jest.fn()
23
+ }))
24
+
25
+ jest.mock('~/src/server/plugins/engine/services/index.js', () => ({
26
+ __esModule: true,
27
+ formsService: {
28
+ getFormMetadata: jest.fn(),
29
+ getFormDefinition: jest.fn()
30
+ },
31
+ formSubmissionService: {},
32
+ outputService: {}
33
+ }))
34
+
35
+ jest.mock('../pageControllers/index.ts', () => {
36
+ class MockTerminalPageController {
37
+ path = ''
38
+ }
39
+
40
+ return {
41
+ __esModule: true,
42
+ TerminalPageController: MockTerminalPageController
43
+ }
44
+ })
45
+
46
+ jest.mock('../helpers.ts', () => ({
47
+ __esModule: true,
48
+ getCacheService: (...args: unknown[]) => mockGetCacheService(...args),
49
+ checkEmailAddressForLiveFormSubmission: (...args: unknown[]) =>
50
+ mockCheckEmailAddressForLiveFormSubmission(...args)
51
+ }))
52
+
53
+ const mockServices = jest.requireMock(
54
+ '~/src/server/plugins/engine/services/index.js'
55
+ )
56
+ const mockFormsService = mockServices.formsService
57
+ const { FormModel } = jest.requireMock('../models/index.ts')
58
+ const { TerminalPageController: MockTerminalPageController } = jest.requireMock(
59
+ '../pageControllers/index.ts'
60
+ )
61
+
62
+ describe('getFormContext helper', () => {
63
+ const request = {
64
+ yar: { set: jest.fn() } as unknown as Request['yar'],
65
+ server: {
66
+ app: {},
67
+ realm: { modifiers: { route: { prefix: '' } } }
68
+ } as unknown as Request['server']
69
+ } satisfies Pick<Request, 'yar' | 'server'>
70
+ const slug = 'tb-origin'
71
+ const cachedState = { answered: true }
72
+ const returnedContext = { errors: [] }
73
+ const metadata = {
74
+ id: 'metadata-123',
75
+ live: { updatedAt: new Date('2024-10-15T10:00:00Z') },
76
+ draft: { updatedAt: new Date('2024-10-10T10:00:00Z') },
77
+ versions: [{ versionNumber: 9 }],
78
+ notificationEmail: 'test@example.com'
79
+ }
80
+ const definition = { pages: [] }
81
+ let formModel: { getFormContext: jest.Mock }
82
+
83
+ beforeEach(() => {
84
+ jest.clearAllMocks()
85
+ formModel = { getFormContext: jest.fn().mockResolvedValue(returnedContext) }
86
+ FormModel.mockImplementation(
87
+ (_definition: unknown, modelOptions: FormModelOptions) =>
88
+ Object.assign(formModel, { basePath: modelOptions.basePath })
89
+ )
90
+ mockFormsService.getFormMetadata.mockResolvedValue(metadata)
91
+ mockFormsService.getFormDefinition.mockResolvedValue(definition)
92
+ mockGetCacheService.mockReturnValue(mockCacheService)
93
+ mockCacheService.getState.mockResolvedValue(cachedState)
94
+ })
95
+
96
+ test('passes preview state into the summary request and uses cached reference numbers', async () => {
97
+ const errors = [
98
+ { href: '#field', name: 'field', path: ['field'], text: 'is required' }
99
+ ]
100
+
101
+ mockCacheService.getState.mockResolvedValue({
102
+ ...cachedState,
103
+ $$__referenceNumber: 'CACHED-REF'
104
+ })
105
+
106
+ const context = await getFormContext(request, slug, 'preview', {
107
+ errors
108
+ })
109
+
110
+ const summaryRequest = mockCacheService.getState.mock.calls[0][0]
111
+
112
+ expect(summaryRequest.params).toEqual({
113
+ path: 'summary',
114
+ slug,
115
+ state: 'live'
116
+ })
117
+ expect(summaryRequest.path).toBe('/preview/live/tb-origin/summary')
118
+ expect(summaryRequest.url.toString()).toBe(
119
+ 'https://form-context.local/preview/live/tb-origin/summary'
120
+ )
121
+
122
+ expect(formModel.getFormContext).toHaveBeenCalledWith(
123
+ summaryRequest,
124
+ expect.objectContaining({ $$__referenceNumber: 'CACHED-REF' }),
125
+ errors
126
+ )
127
+ expect(context).toBe(returnedContext)
128
+ })
129
+ })
130
+
131
+ describe('getFormModel helper', () => {
132
+ const slug = 'tb-origin'
133
+ const state = FormStatus.Draft
134
+ class CustomController extends PageController {}
135
+ const controllers = { CustomController }
136
+ const metadata = {
137
+ id: 'form-meta-123',
138
+ versions: [{ versionNumber: 17 }]
139
+ }
140
+ const definition = { pages: [{ path: '/start' }] }
141
+ let formsService: FormsService
142
+ let services: Services
143
+ let formModelInstance: { id: string }
144
+
145
+ beforeEach(() => {
146
+ jest.clearAllMocks()
147
+ formModelInstance = { id: 'form-model-instance' }
148
+ FormModel.mockImplementation(() => formModelInstance)
149
+ services = {
150
+ formsService: {
151
+ getFormMetadata: jest.fn().mockResolvedValue(metadata),
152
+ getFormMetadataById: jest.fn(),
153
+ getFormDefinition: jest.fn().mockResolvedValue(definition)
154
+ },
155
+ formSubmissionService: {
156
+ persistFiles: jest.fn(),
157
+ submit: jest.fn()
158
+ },
159
+ outputService: {
160
+ submit: jest.fn()
161
+ }
162
+ }
163
+ formsService = services.formsService
164
+ })
165
+
166
+ test('constructs a FormModel using fetched metadata and definition', async () => {
167
+ const model = await getFormModel(slug, state, { services, controllers })
168
+
169
+ expect(formsService.getFormMetadata).toHaveBeenCalledWith(slug)
170
+ expect(formsService.getFormDefinition).toHaveBeenCalledWith(
171
+ metadata.id,
172
+ state
173
+ )
174
+ expect(FormModel).toHaveBeenCalledWith(
175
+ definition,
176
+ {
177
+ basePath: slug,
178
+ versionNumber: metadata.versions[0].versionNumber,
179
+ ordnanceSurveyApiKey: undefined,
180
+ formId: metadata.id
181
+ },
182
+ services,
183
+ controllers
184
+ )
185
+ expect(model).toBe(formModelInstance)
186
+ })
187
+
188
+ test('maps preview state requests to the live form definition', async () => {
189
+ await getFormModel(slug, 'preview', { services, controllers })
190
+
191
+ expect(formsService.getFormDefinition).toHaveBeenCalledWith(
192
+ metadata.id,
193
+ 'live'
194
+ )
195
+ })
196
+
197
+ test('throws when no form definition is available', async () => {
198
+ jest.mocked(formsService.getFormDefinition).mockResolvedValue(undefined)
199
+
200
+ await expect(
201
+ getFormModel(slug, state, { services, controllers })
202
+ ).rejects.toThrow(
203
+ `No definition found for form metadata ${metadata.id} (${slug}) ${state}`
204
+ )
205
+
206
+ expect(FormModel).not.toHaveBeenCalled()
207
+ })
208
+ })
209
+
210
+ describe('resolveFormModel helper', () => {
211
+ const slug = 'tb-origin'
212
+ const definition = { pages: [], outputEmail: 'fallback@example.com' }
213
+ const metadata = {
214
+ id: 'metadata-123',
215
+ live: { updatedAt: new Date('2024-10-15T10:00:00Z') },
216
+ versions: [{ versionNumber: 9 }]
217
+ }
218
+ let server: Request['server']
219
+ let formModelInstance: { id: string }
220
+
221
+ beforeEach(() => {
222
+ jest.clearAllMocks()
223
+ server = {
224
+ app: {},
225
+ realm: { modifiers: { route: { prefix: '/forms/' } } }
226
+ } as unknown as Request['server']
227
+ formModelInstance = { id: 'form-model-instance' }
228
+ FormModel.mockImplementation(() => formModelInstance)
229
+ mockFormsService.getFormMetadata.mockResolvedValue(metadata)
230
+ mockFormsService.getFormDefinition.mockResolvedValue(definition)
231
+ })
232
+
233
+ test('reuses cached models when metadata timestamps match', async () => {
234
+ const model = await resolveFormModel(server, slug, FormStatus.Live)
235
+ const cached = await resolveFormModel(server, slug, FormStatus.Live)
236
+
237
+ expect(model).toBe(formModelInstance)
238
+ expect(cached).toBe(model)
239
+ expect(server.app.models).toBeInstanceOf(Map)
240
+ expect(mockFormsService.getFormDefinition).toHaveBeenCalledTimes(1)
241
+ expect(FormModel).toHaveBeenCalledTimes(1)
242
+ })
243
+
244
+ test('rebuilds the model when metadata changes and uses preview routing', async () => {
245
+ const refreshedModel = { id: 'refreshed-model' }
246
+
247
+ FormModel.mockImplementationOnce(
248
+ () => formModelInstance
249
+ ).mockImplementationOnce(() => refreshedModel)
250
+ mockFormsService.getFormMetadata
251
+ .mockResolvedValueOnce({ ...metadata, notificationEmail: undefined })
252
+ .mockResolvedValueOnce({
253
+ ...metadata,
254
+ notificationEmail: undefined,
255
+ live: { updatedAt: new Date('2024-12-01T09:00:00Z') }
256
+ })
257
+
258
+ const model = await resolveFormModel(server, slug, 'preview', {
259
+ ordnanceSurveyApiKey: 'os-api-key'
260
+ })
261
+ const rebuilt = await resolveFormModel(server, slug, 'preview')
262
+
263
+ expect(model).toBe(formModelInstance)
264
+ expect(rebuilt).toBe(refreshedModel)
265
+ expect(FormModel).toHaveBeenCalledTimes(2)
266
+ expect(mockFormsService.getFormDefinition).toHaveBeenCalledTimes(2)
267
+ expect(mockCheckEmailAddressForLiveFormSubmission).toHaveBeenCalledWith(
268
+ definition.outputEmail,
269
+ true
270
+ )
271
+ expect(FormModel).toHaveBeenCalledWith(
272
+ definition,
273
+ expect.objectContaining({
274
+ basePath: 'forms/preview/live/tb-origin',
275
+ versionNumber: metadata.versions[0].versionNumber,
276
+ ordnanceSurveyApiKey: 'os-api-key',
277
+ formId: metadata.id
278
+ }),
279
+ mockServices,
280
+ undefined
281
+ )
282
+ })
283
+
284
+ test('throws when requested form state does not exist on metadata', async () => {
285
+ mockFormsService.getFormMetadata.mockResolvedValue({
286
+ id: 'metadata-123',
287
+ live: { updatedAt: new Date('2024-10-15T10:00:00Z') }
288
+ })
289
+
290
+ await expect(
291
+ resolveFormModel(server, slug, FormStatus.Draft)
292
+ ).rejects.toThrow("No 'draft' state for form metadata metadata-123")
293
+
294
+ expect(FormModel).not.toHaveBeenCalled()
295
+ })
296
+
297
+ test('throws when no form definition is available for the requested state', async () => {
298
+ mockFormsService.getFormDefinition.mockResolvedValue(undefined)
299
+
300
+ await expect(
301
+ resolveFormModel(server, slug, FormStatus.Live)
302
+ ).rejects.toThrow(
303
+ `No definition found for form metadata ${metadata.id} (${slug}) ${FormStatus.Live}`
304
+ )
305
+
306
+ expect(FormModel).not.toHaveBeenCalled()
307
+ expect(mockCheckEmailAddressForLiveFormSubmission).not.toHaveBeenCalled()
308
+ })
309
+ })
310
+
311
+ describe('getFirstJourneyPage helper', () => {
312
+ const buildPage = (path: string, keys: string[] = []) =>
313
+ ({ path, keys }) as unknown as PageControllerClass
314
+
315
+ test('returns undefined when no context or relevant target path is available', () => {
316
+ expect(getFirstJourneyPage()).toBeUndefined()
317
+ expect(getFirstJourneyPage({ relevantPages: [] })).toBeUndefined()
318
+ })
319
+
320
+ test('returns the page matching the last recorded path', () => {
321
+ const startPage = buildPage('/start')
322
+ const nextPage = buildPage('/animals')
323
+
324
+ const context: Pick<FormContext, 'relevantPages'> = {
325
+ relevantPages: [startPage, nextPage]
326
+ }
327
+
328
+ expect(getFirstJourneyPage(context)).toBe(nextPage)
329
+ })
330
+
331
+ test('steps back from terminal pages to the previous relevant page', () => {
332
+ const startPage = buildPage('/start')
333
+ const exitPage = Object.assign(new MockTerminalPageController(), {
334
+ path: '/stop'
335
+ }) as unknown as PageControllerClass
336
+
337
+ const context: Pick<FormContext, 'relevantPages'> = {
338
+ relevantPages: [startPage, exitPage]
339
+ }
340
+
341
+ expect(getFirstJourneyPage(context)).toBe(startPage)
342
+ })
343
+
344
+ test('returns the terminal page when it is the only relevant page available', () => {
345
+ const exitPage = Object.assign(new MockTerminalPageController(), {
346
+ path: '/stop'
347
+ }) as unknown as PageControllerClass
348
+
349
+ const context: Pick<FormContext, 'relevantPages'> = {
350
+ relevantPages: [exitPage]
351
+ }
352
+
353
+ expect(getFirstJourneyPage(context)).toBe(exitPage)
354
+ })
355
+ })
356
+
357
+ /**
358
+ * @import { FormContext } from '../types.js'
359
+ */