@defra/forms-engine-plugin 2.0.3 → 2.1.0

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.
@@ -3,7 +3,8 @@ import { Engine as CatboxRedis } from '@hapi/catbox-redis'
3
3
  import hapi, {
4
4
  type Request,
5
5
  type ResponseToolkit,
6
- type ServerOptions
6
+ type ServerOptions,
7
+ type ServerRoute
7
8
  } from '@hapi/hapi'
8
9
  import inert from '@hapi/inert'
9
10
  import Scooter from '@hapi/scooter'
@@ -21,7 +22,7 @@ import pluginErrorPages from '~/src/server/plugins/errorPages.js'
21
22
  import { plugin as pluginViews } from '~/src/server/plugins/nunjucks/index.js'
22
23
  import pluginPulse from '~/src/server/plugins/pulse.js'
23
24
  import pluginSession from '~/src/server/plugins/session.js'
24
- import { publicRoutes } from '~/src/server/routes/index.js'
25
+ import { dummyApiRoutes, publicRoutes } from '~/src/server/routes/index.js'
25
26
  import { prepareSecureContext } from '~/src/server/secure-context.js'
26
27
  import { type RouteConfig } from '~/src/server/types.js'
27
28
 
@@ -120,6 +121,7 @@ export async function createServer(routeConfig?: RouteConfig) {
120
121
  name: 'router',
121
122
  register: (server) => {
122
123
  server.route(publicRoutes)
124
+ server.route(dummyApiRoutes as ServerRoute[])
123
125
  }
124
126
  }
125
127
  })
@@ -0,0 +1,416 @@
1
+ import Boom from '@hapi/boom'
2
+ import { type ResponseObject, type ResponseToolkit } from '@hapi/hapi'
3
+ // eslint-disable-next-line n/no-unpublished-import
4
+ import nock from 'nock'
5
+
6
+ import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
7
+ import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers.js'
8
+ import { redirectOrMakeHandler } from '~/src/server/plugins/engine/routes/index.js'
9
+ import {
10
+ makeGetHandler,
11
+ makePostHandler
12
+ } from '~/src/server/plugins/engine/routes/questions.js'
13
+ import { type FormContext } from '~/src/server/plugins/engine/types.js'
14
+ import {
15
+ type FormRequest,
16
+ type FormRequestPayload
17
+ } from '~/src/server/routes/types.js'
18
+ jest.mock('~/src/server/plugins/engine/models/SummaryViewModel', () => ({
19
+ SummaryViewModel: class {
20
+ summary = 'mocked summary'
21
+ }
22
+ }))
23
+
24
+ jest.mock(
25
+ '~/src/server/plugins/engine/pageControllers/SummaryPageController',
26
+ () => ({
27
+ getFormSubmissionData: jest.fn().mockReturnValue([])
28
+ })
29
+ )
30
+
31
+ jest.mock('~/src/server/plugins/engine/outputFormatters/machine/v1', () => ({
32
+ format: jest.fn().mockReturnValue('mocked format')
33
+ }))
34
+
35
+ jest.mock('~/src/server/plugins/engine/routes/index')
36
+
37
+ describe('makeGetHandler', () => {
38
+ const hMock: Pick<ResponseToolkit, 'redirect' | 'view'> = {
39
+ redirect: jest.fn(),
40
+ view: jest.fn()
41
+ }
42
+
43
+ beforeEach(() => {
44
+ nock('http://test').persist().post('/load').reply(200, {
45
+ wasGetCalled: true
46
+ })
47
+ })
48
+
49
+ afterEach(() => {
50
+ jest.mocked(redirectOrMakeHandler).mockRestore()
51
+ nock.cleanAll()
52
+ })
53
+
54
+ it('calls the callback when events.onLoad.type is http', async () => {
55
+ let data = {}
56
+
57
+ const modelMock = {
58
+ basePath: 'some-base-path',
59
+ def: { name: 'Hello world' }
60
+ } as FormModel
61
+
62
+ const pageMock = createMockPageController(
63
+ modelMock,
64
+ (
65
+ _request: FormRequest,
66
+ context: FormContext,
67
+ _h: Pick<ResponseToolkit, 'redirect' | 'view'>
68
+ ) => {
69
+ data = context.data
70
+ return Promise.resolve({} as unknown as ResponseObject)
71
+ }
72
+ )
73
+
74
+ const contextMock = { data: {}, model: {} } as unknown as FormContext
75
+
76
+ const requestMock = {
77
+ params: { path: 'some-path' },
78
+ app: { model: modelMock }
79
+ } as FormRequest
80
+
81
+ jest
82
+ .mocked(redirectOrMakeHandler)
83
+ .mockImplementation(
84
+ (
85
+ _req: FormRequest | FormRequestPayload,
86
+ _h: Pick<ResponseToolkit, 'redirect' | 'view'>,
87
+ fn
88
+ ) => Promise.resolve(fn(pageMock, contextMock))
89
+ )
90
+
91
+ await makeGetHandler()(requestMock, hMock)
92
+
93
+ expect(data).toMatchObject({
94
+ wasGetCalled: true
95
+ })
96
+ })
97
+
98
+ it('does not call the callback when the events.onLoad.type is not http', async () => {
99
+ let data = {}
100
+
101
+ const modelMock = {
102
+ basePath: 'some-base-path',
103
+ def: { name: 'Hello world' }
104
+ } as FormModel
105
+
106
+ const pageMock = createMockPageController(
107
+ modelMock,
108
+ (
109
+ _request: FormRequest,
110
+ context: FormContext,
111
+ _h: Pick<ResponseToolkit, 'redirect' | 'view'>
112
+ ) => {
113
+ data = context.data
114
+ return Promise.resolve({} as unknown as ResponseObject)
115
+ }
116
+ )
117
+
118
+ pageMock.events = {}
119
+
120
+ const contextMock = { data: {}, model: {} } as unknown as FormContext
121
+
122
+ const requestMock = {
123
+ params: { path: 'some-path' },
124
+ app: { model: modelMock }
125
+ } as FormRequest
126
+
127
+ jest
128
+ .mocked(redirectOrMakeHandler)
129
+ .mockImplementation(
130
+ (
131
+ _req: FormRequest | FormRequestPayload,
132
+ _h: Pick<ResponseToolkit, 'redirect' | 'view'>,
133
+ fn
134
+ ) => Promise.resolve(fn(pageMock, contextMock))
135
+ )
136
+
137
+ await makeGetHandler()(requestMock, hMock)
138
+
139
+ expect(data).toMatchObject({})
140
+ })
141
+
142
+ it('throws when model is missing', async () => {
143
+ let error
144
+
145
+ const modelMock = {
146
+ basePath: 'some-base-path',
147
+ def: { name: 'Hello world' }
148
+ } as FormModel
149
+
150
+ const pageMock = createMockPageController(
151
+ modelMock,
152
+ (
153
+ _request: FormRequest,
154
+ _context: FormContext,
155
+ _h: Pick<ResponseToolkit, 'redirect' | 'view'>
156
+ ) => {
157
+ return Promise.resolve({} as unknown as ResponseObject)
158
+ }
159
+ )
160
+
161
+ const contextMock = { data: {}, model: {} } as unknown as FormContext
162
+
163
+ const requestMock = {
164
+ params: { path: 'some-path' },
165
+ app: {}
166
+ } as FormRequest
167
+
168
+ jest
169
+ .mocked(redirectOrMakeHandler)
170
+ .mockImplementation(
171
+ async (
172
+ _req: FormRequest | FormRequestPayload,
173
+ _h: Pick<ResponseToolkit, 'redirect' | 'view'>,
174
+ fn
175
+ ) => {
176
+ try {
177
+ await fn(pageMock, contextMock)
178
+ } catch (err) {
179
+ error = err
180
+ }
181
+
182
+ return Promise.resolve({} as unknown as ResponseObject)
183
+ }
184
+ )
185
+
186
+ await makeGetHandler()(requestMock, hMock)
187
+
188
+ expect(error).toEqual(Boom.notFound('No model found for /some-path'))
189
+ })
190
+ })
191
+
192
+ describe('makePostHandler', () => {
193
+ const hMock: Pick<ResponseToolkit, 'redirect' | 'view'> = {
194
+ redirect: jest.fn(),
195
+ view: jest.fn()
196
+ }
197
+
198
+ beforeEach(() => {
199
+ nock('http://test').post('/save').reply(200, {
200
+ wasPostCalled: true
201
+ })
202
+ })
203
+
204
+ afterEach(() => {
205
+ jest.mocked(redirectOrMakeHandler).mockRestore()
206
+ nock.cleanAll()
207
+ })
208
+
209
+ it('calls the callback when events.onSave.type is http and the page controller was successful', async () => {
210
+ const mockPostResponse: ResponseObject = {
211
+ statusCode: 200
212
+ } as ResponseObject
213
+
214
+ const modelMock = {
215
+ basePath: 'some-base-path',
216
+ def: { name: 'Hello world' }
217
+ } as FormModel
218
+
219
+ const pageMock = createMockPageController(
220
+ modelMock,
221
+ (
222
+ _request: FormRequest,
223
+ _context: FormContext,
224
+ _h: Pick<ResponseToolkit, 'redirect' | 'view'>
225
+ ) => {
226
+ // do return a valid ResponseObject wrapped in Promise.resolve
227
+ return mockPostResponse
228
+ }
229
+ )
230
+
231
+ const contextMock = { data: {}, model: {} } as unknown as FormContext
232
+
233
+ const requestMock = {
234
+ params: { path: 'some-path' },
235
+ app: { model: modelMock },
236
+ payload: { some: 'payload' }
237
+ } as unknown as FormRequestPayload
238
+
239
+ jest
240
+ .mocked(redirectOrMakeHandler)
241
+ .mockImplementation(
242
+ (
243
+ _req: FormRequest | FormRequestPayload,
244
+ _h: Pick<ResponseToolkit, 'redirect' | 'view'>,
245
+ fn
246
+ ) => Promise.resolve(fn(pageMock, contextMock))
247
+ )
248
+
249
+ const response = await makePostHandler()(requestMock, hMock)
250
+
251
+ expect(nock.pendingMocks()).toBeEmpty()
252
+ expect(response).toBe(mockPostResponse)
253
+ })
254
+
255
+ it('does not call the callback when the events.onSave.type is not http', async () => {
256
+ const modelMock = {
257
+ basePath: 'some-base-path',
258
+ def: { name: 'Hello world' }
259
+ } as FormModel
260
+
261
+ const pageMock = createMockPageController(
262
+ modelMock,
263
+ (
264
+ _request: FormRequest,
265
+ _context: FormContext,
266
+ _h: Pick<ResponseToolkit, 'redirect' | 'view'>
267
+ ) => {
268
+ return Promise.resolve({} as unknown as ResponseObject)
269
+ }
270
+ )
271
+
272
+ pageMock.events = {}
273
+
274
+ const contextMock = { data: {}, model: {} } as unknown as FormContext
275
+
276
+ const requestMock = {
277
+ params: { path: 'some-path' },
278
+ app: { model: modelMock },
279
+ payload: { some: 'payload' }
280
+ } as unknown as FormRequestPayload
281
+
282
+ jest
283
+ .mocked(redirectOrMakeHandler)
284
+ .mockImplementation(
285
+ (
286
+ _req: FormRequest | FormRequestPayload,
287
+ _h: Pick<ResponseToolkit, 'redirect' | 'view'>,
288
+ fn
289
+ ) => Promise.resolve(fn(pageMock, contextMock))
290
+ )
291
+
292
+ await makePostHandler()(requestMock, hMock)
293
+
294
+ expect(nock.pendingMocks()).not.toBeEmpty()
295
+ })
296
+
297
+ it('does not call the callback when events.onSave.type is http and the page controller was unsuccessful', async () => {
298
+ const mockPostResponse: ResponseObject = {
299
+ statusCode: 500
300
+ } as ResponseObject
301
+
302
+ const modelMock = {
303
+ basePath: 'some-base-path',
304
+ def: { name: 'Hello world' }
305
+ } as FormModel
306
+
307
+ const pageMock = createMockPageController(
308
+ modelMock,
309
+ (
310
+ _request: FormRequest,
311
+ _context: FormContext,
312
+ _h: Pick<ResponseToolkit, 'redirect' | 'view'>
313
+ ) => {
314
+ // do return a valid ResponseObject wrapped in Promise.resolve
315
+ return mockPostResponse
316
+ }
317
+ )
318
+
319
+ const contextMock = { data: {}, model: {} } as unknown as FormContext
320
+
321
+ const requestMock = {
322
+ params: { path: 'some-path' },
323
+ app: { model: modelMock },
324
+ payload: { some: 'payload' }
325
+ } as unknown as FormRequestPayload
326
+
327
+ jest
328
+ .mocked(redirectOrMakeHandler)
329
+ .mockImplementation(
330
+ (
331
+ _req: FormRequest | FormRequestPayload,
332
+ _h: Pick<ResponseToolkit, 'redirect' | 'view'>,
333
+ fn
334
+ ) => Promise.resolve(fn(pageMock, contextMock))
335
+ )
336
+
337
+ await makePostHandler()(requestMock, hMock)
338
+
339
+ expect(nock.pendingMocks()).not.toBeEmpty()
340
+ })
341
+
342
+ it('throws when model is missing', async () => {
343
+ let error
344
+
345
+ const modelMock = {
346
+ basePath: 'some-base-path',
347
+ def: { name: 'Hello world' }
348
+ } as FormModel
349
+
350
+ const pageMock = createMockPageController(
351
+ modelMock,
352
+ (
353
+ _request: FormRequest,
354
+ _context: FormContext,
355
+ _h: Pick<ResponseToolkit, 'redirect' | 'view'>
356
+ ) => {
357
+ return Promise.resolve({} as unknown as ResponseObject)
358
+ }
359
+ )
360
+
361
+ const contextMock = { data: {}, model: {} } as unknown as FormContext
362
+
363
+ const requestMock = {
364
+ params: { path: 'some-path' },
365
+ app: {},
366
+ payload: { some: 'payload' }
367
+ } as unknown as FormRequestPayload
368
+
369
+ jest
370
+ .mocked(redirectOrMakeHandler)
371
+ .mockImplementation(
372
+ async (
373
+ _req: FormRequest | FormRequestPayload,
374
+ _h: Pick<ResponseToolkit, 'redirect' | 'view'>,
375
+ fn
376
+ ) => {
377
+ try {
378
+ await fn(pageMock, contextMock)
379
+ } catch (err) {
380
+ error = err
381
+ }
382
+
383
+ return Promise.resolve({} as unknown as ResponseObject)
384
+ }
385
+ )
386
+
387
+ await makePostHandler()(requestMock, hMock)
388
+
389
+ expect(error).toEqual(Boom.notFound('No model found for /some-path'))
390
+ })
391
+ })
392
+
393
+ function createMockPageController(
394
+ model: FormModel,
395
+ routeHandler: (
396
+ request: FormRequest,
397
+ context: FormContext,
398
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
399
+ ) => ResponseObject | Promise<ResponseObject>
400
+ ): PageControllerClass {
401
+ return {
402
+ model,
403
+ events: {
404
+ onLoad: {
405
+ type: 'http',
406
+ options: { method: 'POST', url: 'http://test/load' }
407
+ },
408
+ onSave: {
409
+ type: 'http',
410
+ options: { method: 'POST', url: 'http://test/save' }
411
+ }
412
+ },
413
+ makeGetRouteHandler: () => routeHandler,
414
+ makePostRouteHandler: () => routeHandler
415
+ } as unknown as PageControllerClass
416
+ }
@@ -1,6 +1,7 @@
1
- import { hasFormComponents, slugSchema } from '@defra/forms-model'
1
+ import { hasFormComponents, slugSchema, type Event } from '@defra/forms-model'
2
2
  import Boom from '@hapi/boom'
3
3
  import {
4
+ type ResponseObject,
4
5
  type ResponseToolkit,
5
6
  type RouteOptions,
6
7
  type ServerRoute
@@ -12,14 +13,21 @@ import {
12
13
  proceed,
13
14
  redirectPath
14
15
  } from '~/src/server/plugins/engine/helpers.js'
15
- import { SummaryViewModel } from '~/src/server/plugins/engine/models/index.js'
16
+ import {
17
+ SummaryViewModel,
18
+ type FormModel
19
+ } from '~/src/server/plugins/engine/models/index.js'
16
20
  import { format } from '~/src/server/plugins/engine/outputFormatters/machine/v1.js'
17
21
  import { getFormSubmissionData } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js'
22
+ import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers.js'
18
23
  import {
19
24
  dispatchHandler,
20
25
  redirectOrMakeHandler
21
26
  } from '~/src/server/plugins/engine/routes/index.js'
22
- import { type PreparePageEventRequestOptions } from '~/src/server/plugins/engine/types.js'
27
+ import {
28
+ type FormContext,
29
+ type PreparePageEventRequestOptions
30
+ } from '~/src/server/plugins/engine/types.js'
23
31
  import {
24
32
  type FormRequest,
25
33
  type FormRequestPayload,
@@ -35,7 +43,36 @@ import {
35
43
  } from '~/src/server/schemas/index.js'
36
44
  import * as httpService from '~/src/server/services/httpService.js'
37
45
 
38
- function makeGetHandler(
46
+ async function handleHttpEvent(
47
+ request: FormRequest | FormRequestPayload,
48
+ page: PageControllerClass,
49
+ context: FormContext,
50
+ event: Event,
51
+ model: FormModel,
52
+ preparePageEventRequestOptions?: PreparePageEventRequestOptions
53
+ ) {
54
+ const { options } = event
55
+ const { url } = options
56
+
57
+ // TODO: Update structured data POST payload with when helper
58
+ // is updated to removing the dependency on `SummaryViewModel` etc.
59
+ const viewModel = new SummaryViewModel(request, page, context)
60
+ const items = getFormSubmissionData(viewModel.context, viewModel.details)
61
+
62
+ // @ts-expect-error - function signature will be refactored in the next iteration of the formatter
63
+ const payload = format(context, items, model, undefined, undefined)
64
+ const opts = { payload }
65
+
66
+ if (preparePageEventRequestOptions) {
67
+ preparePageEventRequestOptions(opts, event, page, context)
68
+ }
69
+
70
+ const { payload: response } = await httpService.postJson(url, opts)
71
+
72
+ Object.assign(context.data, response)
73
+ }
74
+
75
+ export function makeGetHandler(
39
76
  preparePageEventRequestOptions?: PreparePageEventRequestOptions
40
77
  ) {
41
78
  return function getHandler(
@@ -59,28 +96,14 @@ function makeGetHandler(
59
96
  }
60
97
 
61
98
  if (events?.onLoad && events.onLoad.type === 'http') {
62
- const { options } = events.onLoad
63
- const { url } = options
64
-
65
- // TODO: Update structured data POST payload with when helper
66
- // is updated to removing the dependency on `SummaryViewModel` etc.
67
- const viewModel = new SummaryViewModel(request, page, context)
68
- const items = getFormSubmissionData(
69
- viewModel.context,
70
- viewModel.details
99
+ await handleHttpEvent(
100
+ request,
101
+ page,
102
+ context,
103
+ events.onLoad,
104
+ model,
105
+ preparePageEventRequestOptions
71
106
  )
72
-
73
- // @ts-expect-error - function signature will be refactored in the next iteration of the formatter
74
- const payload = format(context, items, model, undefined, undefined)
75
- const opts = { payload }
76
-
77
- if (preparePageEventRequestOptions) {
78
- preparePageEventRequestOptions(opts, events.onLoad, page, context)
79
- }
80
-
81
- const { payload: response } = await httpService.postJson(url, opts)
82
-
83
- Object.assign(context.data, response)
84
107
  }
85
108
 
86
109
  return page.makeGetRouteHandler()(request, context, h)
@@ -88,23 +111,56 @@ function makeGetHandler(
88
111
  }
89
112
  }
90
113
 
91
- function postHandler(
92
- request: FormRequestPayload,
93
- h: Pick<ResponseToolkit, 'redirect' | 'view'>
114
+ export function makePostHandler(
115
+ preparePageEventRequestOptions?: PreparePageEventRequestOptions
94
116
  ) {
95
- const { query } = request
117
+ return function postHandler(
118
+ request: FormRequestPayload,
119
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
120
+ ) {
121
+ const { query } = request
96
122
 
97
- return redirectOrMakeHandler(request, h, (page, context) => {
98
- const { pageDef } = page
99
- const { isForceAccess } = context
123
+ return redirectOrMakeHandler(request, h, async (page, context) => {
124
+ const { pageDef } = page
125
+ const { isForceAccess } = context
126
+ const { model } = request.app
127
+ const { events } = page
100
128
 
101
- // Redirect to GET for preview URL direct access
102
- if (isForceAccess && !hasFormComponents(pageDef)) {
103
- return proceed(request, h, redirectPath(page.href, query))
104
- }
129
+ // Redirect to GET for preview URL direct access
130
+ if (isForceAccess && !hasFormComponents(pageDef)) {
131
+ return proceed(request, h, redirectPath(page.href, query))
132
+ }
133
+
134
+ if (!model) {
135
+ throw Boom.notFound(`No model found for /${request.params.path}`)
136
+ }
137
+
138
+ const response = await page.makePostRouteHandler()(request, context, h)
139
+
140
+ if (
141
+ events?.onSave &&
142
+ events.onSave.type === 'http' &&
143
+ isSuccessful(response)
144
+ ) {
145
+ await handleHttpEvent(
146
+ request,
147
+ page,
148
+ context,
149
+ events.onSave,
150
+ model,
151
+ preparePageEventRequestOptions
152
+ )
153
+ }
154
+
155
+ return response
156
+ })
157
+ }
158
+ }
159
+
160
+ function isSuccessful(response: ResponseObject): boolean {
161
+ const { statusCode } = response
105
162
 
106
- return page.makePostRouteHandler()(request, context, h)
107
- })
163
+ return !Boom.isBoom(response) && statusCode >= 200 && statusCode < 400
108
164
  }
109
165
 
110
166
  export function getRoutes(
@@ -174,7 +230,7 @@ export function getRoutes(
174
230
  {
175
231
  method: 'post',
176
232
  path: '/{slug}/{path}/{itemId?}',
177
- handler: postHandler,
233
+ handler: makePostHandler(preparePageEventRequestOptions),
178
234
  options: {
179
235
  ...postRouteOptions,
180
236
  validate: {
@@ -196,7 +252,7 @@ export function getRoutes(
196
252
  {
197
253
  method: 'post',
198
254
  path: '/preview/{state}/{slug}/{path}/{itemId?}',
199
- handler: postHandler,
255
+ handler: makePostHandler(preparePageEventRequestOptions),
200
256
  options: {
201
257
  ...postRouteOptions,
202
258
  validate: {
@@ -29,20 +29,19 @@ export const formsService = async () => {
29
29
  // Instantiate the file loader form service
30
30
  const loader = new FileFormService()
31
31
 
32
- // Add a Json form
33
- await loader.addForm('src/server/forms/register-as-a-unicorn-breeder.json', {
32
+ // Add a Yaml form
33
+ await loader.addForm('src/server/forms/register-as-a-unicorn-breeder.yaml', {
34
34
  ...metadata,
35
- id: '95e92559-968d-44ae-8666-2b1ad3dffd31',
35
+ id: '641aeafd-13dd-40fa-9186-001703800efb',
36
36
  title: 'Register as a unicorn breeder',
37
37
  slug: 'register-as-a-unicorn-breeder'
38
38
  })
39
39
 
40
- // Add a Yaml form
41
- await loader.addForm('src/server/forms/register-as-a-unicorn-breeder.yaml', {
40
+ await loader.addForm('src/server/forms/page-events.yaml', {
42
41
  ...metadata,
43
- id: '641aeafd-13dd-40fa-9186-001703800efb',
44
- title: 'Register as a unicorn breeder (yaml)',
45
- slug: 'register-as-a-unicorn-breeder-yaml' // if we needed to validate any JSON logic, make it available for convenience
42
+ id: '511db05e-ebbd-42e8-8270-5fe93f5c9762',
43
+ title: 'Page events demo',
44
+ slug: 'page-events-demo'
46
45
  })
47
46
 
48
47
  await loader.addForm('src/server/forms/components.json', {