@defra/forms-engine-plugin 4.0.27 → 4.0.29

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","StatusCodes","Joi","EXTERNAL_STATE_APPENDAGE","JOURNEY_BASE_URL","detailsPayloadSchema","detailsViewModel","manualPayloadSchema","manualViewModel","selectPayloadSchema","selectViewModel","stepSchema","steps","service","viewName","getSessionState","request","state","yar","get","internal","flashComponentState","componentName","address","addressState","addressLine1","addressLine2","town","county","postcode","uprn","undefined","appendage","component","data","flash","dispatch","h","initial","details","postcodeQuery","buildingNameQuery","set","query","step","redirect","code","SEE_OTHER","getRoutes","options","getRoute","postRoute","method","path","handler","session","model","manual","view","validate","object","keys","string","allow","optional","payload","detailsPostHandler","select","selectPostHandler","manualPostHandler","badRequest","unknown","ordnanceSurveyApiKey","apiKey","value","error","addresses","searchByUPRN","property","at","sourceUrl","abortEarly"],"sources":["../../../../../src/server/plugins/postcode-lookup/routes/index.js"],"sourcesContent":["import Boom from '@hapi/boom'\nimport { StatusCodes } from 'http-status-codes'\nimport Joi from 'joi'\n\nimport { EXTERNAL_STATE_APPENDAGE } from '~/src/server/constants.js'\nimport {\n JOURNEY_BASE_URL,\n detailsPayloadSchema,\n detailsViewModel,\n manualPayloadSchema,\n manualViewModel,\n selectPayloadSchema,\n selectViewModel,\n stepSchema,\n steps\n} from '~/src/server/plugins/postcode-lookup/models/index.js'\nimport * as service from '~/src/server/plugins/postcode-lookup/service.js'\n\nconst viewName = 'postcode-lookup-details'\n\n/**\n * Get the session state associated with this journey\n * @param {PostcodeLookupRequest} request\n */\nfunction getSessionState(request) {\n /**\n * @type {PostcodeLookupSessionData | null | undefined}\n */\n const state = request.yar.get(JOURNEY_BASE_URL)\n\n if (!state) {\n throw Boom.internal(`No postcode lookup data found for ${JOURNEY_BASE_URL}`)\n }\n\n return state\n}\n\n/**\n * Flash form component state\n * @param {PostcodeLookupRequest} request - the request\n * @param {string} componentName - the component name\n * @param {Address | PostcodeLookupManualPayload} address - the address from ordnance survey or manually entered\n */\nfunction flashComponentState(request, componentName, address) {\n const addressState = {\n addressLine1: address.addressLine1,\n addressLine2: address.addressLine2,\n town: address.town,\n county: address.county,\n postcode: address.postcode,\n uprn: 'uprn' in address && address.uprn ? address.uprn : undefined\n }\n\n /**\n * @type {ExternalStateAppendage}\n */\n const appendage = {\n component: componentName,\n data: addressState\n }\n\n request.yar.flash(EXTERNAL_STATE_APPENDAGE, appendage, true)\n}\n\n/**\n * Initialises and dispatches the request to the postcode lookup journey\n * @param {FormRequestPayload} request - the source page\n * @param {FormResponseToolkit} h - the source page\n * @param {PostcodeLookupDispatchData} initial - the source data\n */\nexport function dispatch(request, h, initial) {\n /**\n * @type {PostcodeLookupSessionData}\n */\n const data = {\n initial,\n details: { postcodeQuery: '', buildingNameQuery: '' }\n }\n\n request.yar.set(JOURNEY_BASE_URL, data)\n\n const query = initial.step ? `?step=${initial.step}` : ''\n\n return h.redirect(`${JOURNEY_BASE_URL}${query}`).code(StatusCodes.SEE_OTHER)\n}\n\n/**\n * Gets the postcode lookup routes\n * @param {PostcodeLookupConfiguration} options - ordnance survey api key\n */\nexport function getRoutes(options) {\n return [getRoute(), postRoute(options)]\n}\n\n/**\n * @returns {ServerRoute<PostcodeLookupGetRequestRefs>}\n */\nfunction getRoute() {\n return {\n method: 'GET',\n path: JOURNEY_BASE_URL,\n handler(request, h) {\n const { query } = request\n const { step } = query\n const session = getSessionState(request)\n\n const model =\n step === steps.manual\n ? manualViewModel(session)\n : detailsViewModel(session)\n\n return h.view(viewName, model)\n },\n options: {\n validate: {\n query: Joi.object()\n .keys({\n step: Joi.string().allow(steps.details, steps.manual).optional()\n })\n .optional()\n }\n }\n }\n}\n\n/**\n * @param {PostcodeLookupConfiguration} options\n * @returns {ServerRoute<PostcodeLookupPostRequestRefs>}\n */\nfunction postRoute(options) {\n return {\n method: 'POST',\n path: JOURNEY_BASE_URL,\n async handler(request, h) {\n const { payload } = request\n const { step } = payload\n\n switch (step) {\n case steps.details: {\n return detailsPostHandler(request, h, options)\n }\n case steps.select: {\n return selectPostHandler(request, h, options)\n }\n case steps.manual: {\n return manualPostHandler(request, h)\n }\n default:\n throw Boom.badRequest(`Invalid step ${step}`)\n }\n },\n options: {\n validate: {\n payload: Joi.object()\n .keys({\n step: stepSchema\n })\n .unknown(true)\n }\n }\n }\n}\n\n/**\n * Post handler for the details step\n * @param {PostcodeLookupPostRequest} request\n * @param {ResponseToolkit<PostcodeLookupPostRequestRefs>} h\n * @param {PostcodeLookupConfiguration} options\n */\nasync function detailsPostHandler(request, h, options) {\n const { payload } = request\n const session = getSessionState(request)\n const { ordnanceSurveyApiKey: apiKey } = options\n const { value: details, error } = detailsPayloadSchema.validate(payload)\n\n let model\n\n if (error) {\n model = detailsViewModel(session, details, error)\n\n return h.view(viewName, model)\n }\n\n const { postcodeQuery, buildingNameQuery } = details\n session.details = { postcodeQuery, buildingNameQuery }\n\n // Store the updated session\n request.yar.set(JOURNEY_BASE_URL, session)\n\n model = await selectViewModel({ session, apiKey })\n\n return h.view(viewName, model)\n}\n\n/**\n * Post handler for the select step\n * @param {PostcodeLookupPostRequest} request\n * @param {ResponseToolkit<PostcodeLookupPostRequestRefs>} h\n * @param {PostcodeLookupConfiguration} options\n */\nasync function selectPostHandler(request, h, options) {\n const { payload } = request\n const session = getSessionState(request)\n const { ordnanceSurveyApiKey: apiKey } = options\n const { value: select, error } = selectPayloadSchema.validate(payload)\n\n if (error) {\n const model = await selectViewModel({ session, apiKey }, select, error)\n\n return h.view(viewName, model)\n }\n\n const addresses = await service.searchByUPRN(select.uprn, apiKey)\n const property = addresses.at(0)\n\n if (!property) {\n throw Boom.internal(`UPRN ${property} not found`)\n }\n\n const { componentName, sourceUrl } = session.initial\n flashComponentState(request, componentName, property)\n\n // Redirect back to the source form page\n return h.redirect(sourceUrl).code(StatusCodes.SEE_OTHER)\n}\n\n/**\n * Post handler for the manual step\n * @param {PostcodeLookupPostRequest} request\n * @param {ResponseToolkit<PostcodeLookupPostRequestRefs>} h\n */\nfunction manualPostHandler(request, h) {\n const { payload } = request\n const session = getSessionState(request)\n\n const { value: manual, error } = manualPayloadSchema.validate(payload, {\n abortEarly: false\n })\n\n if (error) {\n const model = manualViewModel(session, manual, error)\n\n return h.view(viewName, model)\n }\n\n const { componentName, sourceUrl } = session.initial\n flashComponentState(request, componentName, manual)\n\n // Redirect back to the source form page\n return h.redirect(sourceUrl).code(StatusCodes.SEE_OTHER)\n}\n\n/**\n * @import { ResponseToolkit, ServerRoute } from '@hapi/hapi'\n * @import { PostcodeLookupManualPayload, Address, PostcodeLookupGetRequestRefs, PostcodeLookupPostRequestRefs, PostcodeLookupRequest, PostcodeLookupPostRequest, PostcodeLookupConfiguration, PostcodeLookupDispatchData, PostcodeLookupSessionData } from '~/src/server/plugins/postcode-lookup/types.js'\n * @import { FormRequestPayload, FormResponseToolkit } from '~/src/server/routes/types.js'\n * @import { ExternalStateAppendage } from '~/src/server/plugins/engine/types.js'\n */\n"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"file":"index.js","names":["Boom","StatusCodes","Joi","EXTERNAL_STATE_APPENDAGE","JOURNEY_BASE_URL","detailsPayloadSchema","detailsViewModel","manualPayloadSchema","manualViewModel","selectPayloadSchema","selectViewModel","stepSchema","steps","service","viewName","getSessionState","request","state","yar","get","badRequest","flashComponentState","componentName","address","addressState","addressLine1","addressLine2","town","county","postcode","uprn","undefined","appendage","component","data","flash","dispatch","h","initial","details","postcodeQuery","buildingNameQuery","set","query","step","redirect","code","SEE_OTHER","getRoutes","options","getRoute","postRoute","method","path","handler","session","model","manual","view","validate","object","keys","string","allow","optional","payload","detailsPostHandler","select","selectPostHandler","manualPostHandler","unknown","ordnanceSurveyApiKey","apiKey","value","error","addresses","searchByUPRN","property","at","internal","sourceUrl","abortEarly"],"sources":["../../../../../src/server/plugins/postcode-lookup/routes/index.js"],"sourcesContent":["import Boom from '@hapi/boom'\nimport { StatusCodes } from 'http-status-codes'\nimport Joi from 'joi'\n\nimport { EXTERNAL_STATE_APPENDAGE } from '~/src/server/constants.js'\nimport {\n JOURNEY_BASE_URL,\n detailsPayloadSchema,\n detailsViewModel,\n manualPayloadSchema,\n manualViewModel,\n selectPayloadSchema,\n selectViewModel,\n stepSchema,\n steps\n} from '~/src/server/plugins/postcode-lookup/models/index.js'\nimport * as service from '~/src/server/plugins/postcode-lookup/service.js'\n\nconst viewName = 'postcode-lookup-details'\n\n/**\n * Get the session state associated with this journey\n * @param {PostcodeLookupRequest} request\n */\nfunction getSessionState(request) {\n /**\n * @type {PostcodeLookupSessionData | null | undefined}\n */\n const state = request.yar.get(JOURNEY_BASE_URL)\n\n if (!state) {\n throw Boom.badRequest(\n `No postcode lookup data found for ${JOURNEY_BASE_URL}`\n )\n }\n\n return state\n}\n\n/**\n * Flash form component state\n * @param {PostcodeLookupRequest} request - the request\n * @param {string} componentName - the component name\n * @param {Address | PostcodeLookupManualPayload} address - the address from ordnance survey or manually entered\n */\nfunction flashComponentState(request, componentName, address) {\n const addressState = {\n addressLine1: address.addressLine1,\n addressLine2: address.addressLine2,\n town: address.town,\n county: address.county,\n postcode: address.postcode,\n uprn: 'uprn' in address && address.uprn ? address.uprn : undefined\n }\n\n /**\n * @type {ExternalStateAppendage}\n */\n const appendage = {\n component: componentName,\n data: addressState\n }\n\n request.yar.flash(EXTERNAL_STATE_APPENDAGE, appendage, true)\n}\n\n/**\n * Initialises and dispatches the request to the postcode lookup journey\n * @param {FormRequestPayload} request - the source page\n * @param {FormResponseToolkit} h - the source page\n * @param {PostcodeLookupDispatchData} initial - the source data\n */\nexport function dispatch(request, h, initial) {\n /**\n * @type {PostcodeLookupSessionData}\n */\n const data = {\n initial,\n details: { postcodeQuery: '', buildingNameQuery: '' }\n }\n\n request.yar.set(JOURNEY_BASE_URL, data)\n\n const query = initial.step ? `?step=${initial.step}` : ''\n\n return h.redirect(`${JOURNEY_BASE_URL}${query}`).code(StatusCodes.SEE_OTHER)\n}\n\n/**\n * Gets the postcode lookup routes\n * @param {PostcodeLookupConfiguration} options - ordnance survey api key\n */\nexport function getRoutes(options) {\n return [getRoute(), postRoute(options)]\n}\n\n/**\n * @returns {ServerRoute<PostcodeLookupGetRequestRefs>}\n */\nfunction getRoute() {\n return {\n method: 'GET',\n path: JOURNEY_BASE_URL,\n handler(request, h) {\n const { query } = request\n const { step } = query\n const session = getSessionState(request)\n\n const model =\n step === steps.manual\n ? manualViewModel(session)\n : detailsViewModel(session)\n\n return h.view(viewName, model)\n },\n options: {\n validate: {\n query: Joi.object()\n .keys({\n step: Joi.string().allow(steps.details, steps.manual).optional()\n })\n .optional()\n }\n }\n }\n}\n\n/**\n * @param {PostcodeLookupConfiguration} options\n * @returns {ServerRoute<PostcodeLookupPostRequestRefs>}\n */\nfunction postRoute(options) {\n return {\n method: 'POST',\n path: JOURNEY_BASE_URL,\n async handler(request, h) {\n const { payload } = request\n const { step } = payload\n\n switch (step) {\n case steps.details: {\n return detailsPostHandler(request, h, options)\n }\n case steps.select: {\n return selectPostHandler(request, h, options)\n }\n case steps.manual: {\n return manualPostHandler(request, h)\n }\n default:\n throw Boom.badRequest(`Invalid step ${step}`)\n }\n },\n options: {\n validate: {\n payload: Joi.object()\n .keys({\n step: stepSchema\n })\n .unknown(true)\n }\n }\n }\n}\n\n/**\n * Post handler for the details step\n * @param {PostcodeLookupPostRequest} request\n * @param {ResponseToolkit<PostcodeLookupPostRequestRefs>} h\n * @param {PostcodeLookupConfiguration} options\n */\nasync function detailsPostHandler(request, h, options) {\n const { payload } = request\n const session = getSessionState(request)\n const { ordnanceSurveyApiKey: apiKey } = options\n const { value: details, error } = detailsPayloadSchema.validate(payload)\n\n let model\n\n if (error) {\n model = detailsViewModel(session, details, error)\n\n return h.view(viewName, model)\n }\n\n const { postcodeQuery, buildingNameQuery } = details\n session.details = { postcodeQuery, buildingNameQuery }\n\n // Store the updated session\n request.yar.set(JOURNEY_BASE_URL, session)\n\n model = await selectViewModel({ session, apiKey })\n\n return h.view(viewName, model)\n}\n\n/**\n * Post handler for the select step\n * @param {PostcodeLookupPostRequest} request\n * @param {ResponseToolkit<PostcodeLookupPostRequestRefs>} h\n * @param {PostcodeLookupConfiguration} options\n */\nasync function selectPostHandler(request, h, options) {\n const { payload } = request\n const session = getSessionState(request)\n const { ordnanceSurveyApiKey: apiKey } = options\n const { value: select, error } = selectPayloadSchema.validate(payload)\n\n if (error) {\n const model = await selectViewModel({ session, apiKey }, select, error)\n\n return h.view(viewName, model)\n }\n\n const addresses = await service.searchByUPRN(select.uprn, apiKey)\n const property = addresses.at(0)\n\n if (!property) {\n throw Boom.internal(`UPRN ${property} not found`)\n }\n\n const { componentName, sourceUrl } = session.initial\n flashComponentState(request, componentName, property)\n\n // Redirect back to the source form page\n return h.redirect(sourceUrl).code(StatusCodes.SEE_OTHER)\n}\n\n/**\n * Post handler for the manual step\n * @param {PostcodeLookupPostRequest} request\n * @param {ResponseToolkit<PostcodeLookupPostRequestRefs>} h\n */\nfunction manualPostHandler(request, h) {\n const { payload } = request\n const session = getSessionState(request)\n\n const { value: manual, error } = manualPayloadSchema.validate(payload, {\n abortEarly: false\n })\n\n if (error) {\n const model = manualViewModel(session, manual, error)\n\n return h.view(viewName, model)\n }\n\n const { componentName, sourceUrl } = session.initial\n flashComponentState(request, componentName, manual)\n\n // Redirect back to the source form page\n return h.redirect(sourceUrl).code(StatusCodes.SEE_OTHER)\n}\n\n/**\n * @import { ResponseToolkit, ServerRoute } from '@hapi/hapi'\n * @import { PostcodeLookupManualPayload, Address, PostcodeLookupGetRequestRefs, PostcodeLookupPostRequestRefs, PostcodeLookupRequest, PostcodeLookupPostRequest, PostcodeLookupConfiguration, PostcodeLookupDispatchData, PostcodeLookupSessionData } from '~/src/server/plugins/postcode-lookup/types.js'\n * @import { FormRequestPayload, FormResponseToolkit } from '~/src/server/routes/types.js'\n * @import { ExternalStateAppendage } from '~/src/server/plugins/engine/types.js'\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.27",
3
+ "version": "4.0.29",
4
4
  "description": "Defra forms engine",
5
5
  "type": "module",
6
6
  "files": [
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "engines": {
68
68
  "node": ">=22.11.0 <25.0.0",
69
- "npm": "^10.9.0"
69
+ "npm": ">=10.9.0 <11.6.4"
70
70
  },
71
71
  "license": "SEE LICENSE IN LICENSE",
72
72
  "dependencies": {
@@ -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
+ */