Skip to content

Commit 3174b0a

Browse files
authored
Merge pull request #269 from DEFRA/feat/importable-form-context
feat: importable `getFormContext`
2 parents d901a34 + 6f25a75 commit 3174b0a

File tree

5 files changed

+627
-73
lines changed

5 files changed

+627
-73
lines changed
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
*/

0 commit comments

Comments
 (0)