@defra/forms-engine-plugin 1.3.1 → 1.4.1

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.
Files changed (61) hide show
  1. package/.server/server/plugins/engine/index.js +1 -1
  2. package/.server/server/plugins/engine/index.js.map +1 -1
  3. package/.server/server/plugins/engine/models/FormModel.js +2 -2
  4. package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
  5. package/.server/server/plugins/engine/models/SummaryViewModel.d.ts +1 -0
  6. package/.server/server/plugins/engine/models/SummaryViewModel.js +1 -0
  7. package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
  8. package/.server/server/plugins/engine/options.js +4 -1
  9. package/.server/server/plugins/engine/options.js.map +1 -1
  10. package/.server/server/plugins/engine/options.test.js +20 -0
  11. package/.server/server/plugins/engine/options.test.js.map +1 -1
  12. package/.server/server/plugins/engine/pageControllers/QuestionPageController.d.ts +5 -0
  13. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +27 -1
  14. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  15. package/.server/server/plugins/engine/pageControllers/StartPageController.d.ts +2 -0
  16. package/.server/server/plugins/engine/pageControllers/StartPageController.js +3 -0
  17. package/.server/server/plugins/engine/pageControllers/StartPageController.js.map +1 -1
  18. package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +1 -0
  19. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +4 -0
  20. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  21. package/.server/server/plugins/engine/plugin.js +5 -2
  22. package/.server/server/plugins/engine/plugin.js.map +1 -1
  23. package/.server/server/plugins/engine/routes/exit.d.ts +46 -0
  24. package/.server/server/plugins/engine/routes/exit.js +36 -0
  25. package/.server/server/plugins/engine/routes/exit.js.map +1 -0
  26. package/.server/server/plugins/engine/types.d.ts +6 -2
  27. package/.server/server/plugins/engine/types.js.map +1 -1
  28. package/.server/server/plugins/engine/views/exit.html +31 -0
  29. package/.server/server/plugins/engine/views/partials/form.html +17 -6
  30. package/.server/server/routes/types.d.ts +2 -1
  31. package/.server/server/routes/types.js +1 -0
  32. package/.server/server/routes/types.js.map +1 -1
  33. package/.server/server/schemas/index.js +1 -1
  34. package/.server/server/schemas/index.js.map +1 -1
  35. package/.server/server/services/cacheService.d.ts +2 -0
  36. package/.server/server/services/cacheService.js +9 -5
  37. package/.server/server/services/cacheService.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/server/index.test.ts +39 -0
  40. package/src/server/plugins/engine/components/helpers.test.ts +31 -0
  41. package/src/server/plugins/engine/index.ts +1 -3
  42. package/src/server/plugins/engine/models/FormModel.test.ts +85 -11
  43. package/src/server/plugins/engine/models/FormModel.ts +5 -2
  44. package/src/server/plugins/engine/models/SummaryViewModel.test.ts +59 -0
  45. package/src/server/plugins/engine/models/SummaryViewModel.ts +1 -0
  46. package/src/server/plugins/engine/options.js +4 -1
  47. package/src/server/plugins/engine/options.test.js +20 -0
  48. package/src/server/plugins/engine/pageControllers/PageController.test.ts +25 -0
  49. package/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +178 -1
  50. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +28 -1
  51. package/src/server/plugins/engine/pageControllers/StartPageController.ts +4 -0
  52. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +5 -0
  53. package/src/server/plugins/engine/plugin.ts +5 -1
  54. package/src/server/plugins/engine/routes/exit.ts +47 -0
  55. package/src/server/plugins/engine/types.ts +10 -4
  56. package/src/server/plugins/engine/views/exit.html +31 -0
  57. package/src/server/plugins/engine/views/partials/form.html +17 -6
  58. package/src/server/routes/types.ts +2 -1
  59. package/src/server/schemas/index.ts +2 -1
  60. package/src/server/services/cacheService.test.ts +45 -0
  61. package/src/server/services/cacheService.ts +20 -9
@@ -1 +1 @@
1
- {"version":3,"file":"cacheService.js","names":["Hoek","config","partition","ADDITIONAL_IDENTIFIER","CacheService","cache","generateKey","customFetcher","logger","constructor","server","cacheName","options","keyGenerator","sessionHydrator","log","defaultKeyGenerator","bind","undefined","segment","getState","request","cached","get","Key","rehydrated","set","setState","state","key","ttl","getConfirmationState","Confirmation","value","setConfirmationState","confirmationState","clearState","yar","id","drop","getFlash","messages","flash","Array","isArray","length","at","setFlash","message","Error","params","slug","additionalIdentifier","baseKey","merge","update","mergeArrays"],"sources":["../../../src/server/services/cacheService.ts"],"sourcesContent":["import { type Request, type Server } from '@hapi/hapi'\nimport * as Hoek from '@hapi/hoek'\n\nimport { config } from '~/src/config/index.js'\nimport { type createServer } from '~/src/server/index.js'\nimport {\n type FormPayload,\n type FormState,\n type FormSubmissionError,\n type FormSubmissionState\n} from '~/src/server/plugins/engine/types.js'\nimport {\n type FormRequest,\n type FormRequestPayload\n} from '~/src/server/routes/types.js'\n\nconst partition = 'cache'\n\nexport enum ADDITIONAL_IDENTIFIER {\n Confirmation = ':confirmation'\n}\n\nexport class CacheService {\n /**\n * This service is responsible for getting, storing or deleting a user's session data in the cache. This service has been registered by {@link createServer}\n */\n cache\n generateKey?: (request: Request | FormRequest | FormRequestPayload) => string\n customFetcher?: (\n request: Request | FormRequest | FormRequestPayload\n ) => Promise<FormSubmissionState | null>\n\n logger: Server['logger']\n\n constructor({\n server,\n cacheName,\n options\n }: {\n server: Server\n cacheName?: string\n options?: {\n keyGenerator?: (\n request: Request | FormRequest | FormRequestPayload\n ) => string\n sessionHydrator?: (\n request: Request | FormRequest | FormRequestPayload\n ) => Promise<FormSubmissionState | null>\n }\n }) {\n const { keyGenerator, sessionHydrator } = options ?? {}\n if (!cacheName) {\n server.log(\n 'warn',\n 'You are using the default hapi cache. Please provide a cache name in plugin registration options.'\n )\n }\n this.generateKey = keyGenerator ?? this.defaultKeyGenerator.bind(this)\n this.customFetcher = sessionHydrator ?? undefined\n this.cache = server.cache({ cache: cacheName, segment: 'formSubmission' })\n this.logger = server.logger\n }\n\n async getState(\n request: Request | FormRequest | FormRequestPayload\n ): Promise<FormSubmissionState> {\n let cached = await this.cache.get(this.Key(request))\n\n // If nothing in Redis, attempt to rehydrate from backend DB\n if (!cached && this.customFetcher) {\n const rehydrated = await this.customFetcher(request)\n\n if (rehydrated != null) {\n await this.cache.set(\n this.Key(request),\n rehydrated,\n config.get('sessionTimeout')\n )\n cached = await this.getState(request)\n }\n }\n\n return cached ?? {}\n }\n\n async setState(\n request: FormRequest | FormRequestPayload,\n state: FormSubmissionState\n ) {\n const key = this.Key(request)\n const ttl = config.get('sessionTimeout')\n\n await this.cache.set(key, state, ttl)\n return this.getState(request)\n }\n\n async getConfirmationState(\n request: FormRequest | FormRequestPayload\n ): Promise<{ confirmed?: true }> {\n const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation)\n const value = await this.cache.get(key)\n\n return value ?? {}\n }\n\n async setConfirmationState(\n request: FormRequest | FormRequestPayload,\n confirmationState: { confirmed?: true }\n ) {\n const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation)\n const ttl = config.get('confirmationSessionTimeout')\n\n return this.cache.set(key, confirmationState, ttl)\n }\n\n async clearState(request: FormRequest | FormRequestPayload) {\n if (request.yar.id) {\n await this.cache.drop(this.Key(request))\n }\n }\n\n getFlash(\n request: FormRequest | FormRequestPayload\n ): { errors: FormSubmissionError[] } | undefined {\n const key = this.Key(request)\n const messages = request.yar.flash(key.id)\n\n if (Array.isArray(messages) && messages.length) {\n return messages.at(0) as { errors: FormSubmissionError[] }\n }\n }\n\n setFlash(\n request: FormRequest | FormRequestPayload,\n message: { errors: FormSubmissionError[] }\n ) {\n const key = this.Key(request)\n\n request.yar.flash(key.id, message)\n }\n\n private defaultKeyGenerator(\n request: Request | FormRequest | FormRequestPayload\n ): string {\n if (!request.yar.id) {\n throw new Error('No session ID found')\n }\n\n const state = request.params.state ?? ''\n const slug = request.params.slug ?? ''\n return `${request.yar.id}:${state}:${slug}:`\n }\n\n /**\n * The key used to store user session data against.\n * If there are multiple forms on the same runner instance, for example `form-a` and `form-a-feedback` this will prevent CacheService from clearing data from `form-a` if a user gave feedback before they finished `form-a`\n * @param request - hapi request object\n * @param additionalIdentifier - appended to the id\n */\n Key(\n request: Request | FormRequest | FormRequestPayload,\n additionalIdentifier?: ADDITIONAL_IDENTIFIER\n ) {\n const baseKey = this.generateKey\n ? this.generateKey(request)\n : this.defaultKeyGenerator(request)\n\n return {\n segment: partition,\n id: `${baseKey}${additionalIdentifier ?? ''}`\n }\n }\n}\n\n/**\n * State merge helper\n * 1. Merges objects (form fields)\n * 2. Overwrites arrays\n */\nexport function merge<StateType extends FormState | FormPayload>(\n state: StateType,\n update: object\n): StateType {\n return Hoek.merge(state, update, {\n mergeArrays: false\n })\n}\n"],"mappings":"AACA,OAAO,KAAKA,IAAI,MAAM,YAAY;AAElC,SAASC,MAAM;AAaf,MAAMC,SAAS,GAAG,OAAO;AAEzB,WAAYC,qBAAqB,0BAArBA,qBAAqB;EAArBA,qBAAqB;EAAA,OAArBA,qBAAqB;AAAA;AAIjC,OAAO,MAAMC,YAAY,CAAC;EACxB;AACF;AACA;EACEC,KAAK;EACLC,WAAW;EACXC,aAAa;EAIbC,MAAM;EAENC,WAAWA,CAAC;IACVC,MAAM;IACNC,SAAS;IACTC;EAYF,CAAC,EAAE;IACD,MAAM;MAAEC,YAAY;MAAEC;IAAgB,CAAC,GAAGF,OAAO,IAAI,CAAC,CAAC;IACvD,IAAI,CAACD,SAAS,EAAE;MACdD,MAAM,CAACK,GAAG,CACR,MAAM,EACN,mGACF,CAAC;IACH;IACA,IAAI,CAACT,WAAW,GAAGO,YAAY,IAAI,IAAI,CAACG,mBAAmB,CAACC,IAAI,CAAC,IAAI,CAAC;IACtE,IAAI,CAACV,aAAa,GAAGO,eAAe,IAAII,SAAS;IACjD,IAAI,CAACb,KAAK,GAAGK,MAAM,CAACL,KAAK,CAAC;MAAEA,KAAK,EAAEM,SAAS;MAAEQ,OAAO,EAAE;IAAiB,CAAC,CAAC;IAC1E,IAAI,CAACX,MAAM,GAAGE,MAAM,CAACF,MAAM;EAC7B;EAEA,MAAMY,QAAQA,CACZC,OAAmD,EACrB;IAC9B,IAAIC,MAAM,GAAG,MAAM,IAAI,CAACjB,KAAK,CAACkB,GAAG,CAAC,IAAI,CAACC,GAAG,CAACH,OAAO,CAAC,CAAC;;IAEpD;IACA,IAAI,CAACC,MAAM,IAAI,IAAI,CAACf,aAAa,EAAE;MACjC,MAAMkB,UAAU,GAAG,MAAM,IAAI,CAAClB,aAAa,CAACc,OAAO,CAAC;MAEpD,IAAII,UAAU,IAAI,IAAI,EAAE;QACtB,MAAM,IAAI,CAACpB,KAAK,CAACqB,GAAG,CAClB,IAAI,CAACF,GAAG,CAACH,OAAO,CAAC,EACjBI,UAAU,EACVxB,MAAM,CAACsB,GAAG,CAAC,gBAAgB,CAC7B,CAAC;QACDD,MAAM,GAAG,MAAM,IAAI,CAACF,QAAQ,CAACC,OAAO,CAAC;MACvC;IACF;IAEA,OAAOC,MAAM,IAAI,CAAC,CAAC;EACrB;EAEA,MAAMK,QAAQA,CACZN,OAAyC,EACzCO,KAA0B,EAC1B;IACA,MAAMC,GAAG,GAAG,IAAI,CAACL,GAAG,CAACH,OAAO,CAAC;IAC7B,MAAMS,GAAG,GAAG7B,MAAM,CAACsB,GAAG,CAAC,gBAAgB,CAAC;IAExC,MAAM,IAAI,CAAClB,KAAK,CAACqB,GAAG,CAACG,GAAG,EAAED,KAAK,EAAEE,GAAG,CAAC;IACrC,OAAO,IAAI,CAACV,QAAQ,CAACC,OAAO,CAAC;EAC/B;EAEA,MAAMU,oBAAoBA,CACxBV,OAAyC,EACV;IAC/B,MAAMQ,GAAG,GAAG,IAAI,CAACL,GAAG,CAACH,OAAO,EAAElB,qBAAqB,CAAC6B,YAAY,CAAC;IACjE,MAAMC,KAAK,GAAG,MAAM,IAAI,CAAC5B,KAAK,CAACkB,GAAG,CAACM,GAAG,CAAC;IAEvC,OAAOI,KAAK,IAAI,CAAC,CAAC;EACpB;EAEA,MAAMC,oBAAoBA,CACxBb,OAAyC,EACzCc,iBAAuC,EACvC;IACA,MAAMN,GAAG,GAAG,IAAI,CAACL,GAAG,CAACH,OAAO,EAAElB,qBAAqB,CAAC6B,YAAY,CAAC;IACjE,MAAMF,GAAG,GAAG7B,MAAM,CAACsB,GAAG,CAAC,4BAA4B,CAAC;IAEpD,OAAO,IAAI,CAAClB,KAAK,CAACqB,GAAG,CAACG,GAAG,EAAEM,iBAAiB,EAAEL,GAAG,CAAC;EACpD;EAEA,MAAMM,UAAUA,CAACf,OAAyC,EAAE;IAC1D,IAAIA,OAAO,CAACgB,GAAG,CAACC,EAAE,EAAE;MAClB,MAAM,IAAI,CAACjC,KAAK,CAACkC,IAAI,CAAC,IAAI,CAACf,GAAG,CAACH,OAAO,CAAC,CAAC;IAC1C;EACF;EAEAmB,QAAQA,CACNnB,OAAyC,EACM;IAC/C,MAAMQ,GAAG,GAAG,IAAI,CAACL,GAAG,CAACH,OAAO,CAAC;IAC7B,MAAMoB,QAAQ,GAAGpB,OAAO,CAACgB,GAAG,CAACK,KAAK,CAACb,GAAG,CAACS,EAAE,CAAC;IAE1C,IAAIK,KAAK,CAACC,OAAO,CAACH,QAAQ,CAAC,IAAIA,QAAQ,CAACI,MAAM,EAAE;MAC9C,OAAOJ,QAAQ,CAACK,EAAE,CAAC,CAAC,CAAC;IACvB;EACF;EAEAC,QAAQA,CACN1B,OAAyC,EACzC2B,OAA0C,EAC1C;IACA,MAAMnB,GAAG,GAAG,IAAI,CAACL,GAAG,CAACH,OAAO,CAAC;IAE7BA,OAAO,CAACgB,GAAG,CAACK,KAAK,CAACb,GAAG,CAACS,EAAE,EAAEU,OAAO,CAAC;EACpC;EAEQhC,mBAAmBA,CACzBK,OAAmD,EAC3C;IACR,IAAI,CAACA,OAAO,CAACgB,GAAG,CAACC,EAAE,EAAE;MACnB,MAAM,IAAIW,KAAK,CAAC,qBAAqB,CAAC;IACxC;IAEA,MAAMrB,KAAK,GAAGP,OAAO,CAAC6B,MAAM,CAACtB,KAAK,IAAI,EAAE;IACxC,MAAMuB,IAAI,GAAG9B,OAAO,CAAC6B,MAAM,CAACC,IAAI,IAAI,EAAE;IACtC,OAAO,GAAG9B,OAAO,CAACgB,GAAG,CAACC,EAAE,IAAIV,KAAK,IAAIuB,IAAI,GAAG;EAC9C;;EAEA;AACF;AACA;AACA;AACA;AACA;EACE3B,GAAGA,CACDH,OAAmD,EACnD+B,oBAA4C,EAC5C;IACA,MAAMC,OAAO,GAAG,IAAI,CAAC/C,WAAW,GAC5B,IAAI,CAACA,WAAW,CAACe,OAAO,CAAC,GACzB,IAAI,CAACL,mBAAmB,CAACK,OAAO,CAAC;IAErC,OAAO;MACLF,OAAO,EAAEjB,SAAS;MAClBoC,EAAE,EAAE,GAAGe,OAAO,GAAGD,oBAAoB,IAAI,EAAE;IAC7C,CAAC;EACH;AACF;;AAEA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASE,KAAKA,CACnB1B,KAAgB,EAChB2B,MAAc,EACH;EACX,OAAOvD,IAAI,CAACsD,KAAK,CAAC1B,KAAK,EAAE2B,MAAM,EAAE;IAC/BC,WAAW,EAAE;EACf,CAAC,CAAC;AACJ","ignoreList":[]}
1
+ {"version":3,"file":"cacheService.js","names":["Hoek","config","partition","ADDITIONAL_IDENTIFIER","CacheService","cache","generateKey","customFetcher","customPersister","logger","constructor","server","cacheName","options","keyGenerator","sessionHydrator","sessionPersister","log","defaultKeyGenerator","bind","undefined","segment","getState","request","key","Key","cached","get","rehydrated","set","setState","state","ttl","getConfirmationState","Confirmation","value","setConfirmationState","confirmationState","clearState","yar","id","drop","getFlash","messages","flash","Array","isArray","length","at","setFlash","message","Error","params","slug","additionalIdentifier","baseKey","merge","update","mergeArrays"],"sources":["../../../src/server/services/cacheService.ts"],"sourcesContent":["import { type Request, type Server } from '@hapi/hapi'\nimport * as Hoek from '@hapi/hoek'\n\nimport { config } from '~/src/config/index.js'\nimport { type createServer } from '~/src/server/index.js'\nimport {\n type FormPayload,\n type FormState,\n type FormSubmissionError,\n type FormSubmissionState\n} from '~/src/server/plugins/engine/types.js'\nimport {\n type FormRequest,\n type FormRequestPayload\n} from '~/src/server/routes/types.js'\n\nconst partition = 'cache'\n\nexport enum ADDITIONAL_IDENTIFIER {\n Confirmation = ':confirmation'\n}\n\nexport class CacheService {\n /**\n * This service is responsible for getting, storing or deleting a user's session data in the cache. This service has been registered by {@link createServer}\n */\n cache\n generateKey?: (request: Request | FormRequest | FormRequestPayload) => string\n customFetcher?: (\n request: Request | FormRequest | FormRequestPayload\n ) => Promise<FormSubmissionState | null>\n\n customPersister?: (\n key: string,\n state: FormSubmissionState,\n request: Request | FormRequest | FormRequestPayload\n ) => Promise<void>\n\n logger: Server['logger']\n\n constructor({\n server,\n cacheName,\n options\n }: {\n server: Server\n cacheName?: string\n options?: {\n keyGenerator?: (\n request: Request | FormRequest | FormRequestPayload\n ) => string\n sessionHydrator?: (\n request: Request | FormRequest | FormRequestPayload\n ) => Promise<FormSubmissionState | null>\n sessionPersister?: (\n key: string,\n state: FormSubmissionState,\n request: Request | FormRequest | FormRequestPayload\n ) => Promise<void>\n }\n }) {\n const { keyGenerator, sessionHydrator, sessionPersister } = options ?? {}\n if (!cacheName) {\n server.log(\n 'warn',\n 'You are using the default hapi cache. Please provide a cache name in plugin registration options.'\n )\n }\n this.generateKey = keyGenerator ?? this.defaultKeyGenerator.bind(this)\n this.customFetcher = sessionHydrator ?? undefined\n this.customPersister = sessionPersister ?? undefined\n this.cache = server.cache({ cache: cacheName, segment: 'formSubmission' })\n this.logger = server.logger\n }\n\n async getState(\n request: Request | FormRequest | FormRequestPayload\n ): Promise<FormSubmissionState> {\n const key = this.Key(request)\n\n let cached = await this.cache.get(key)\n\n // If nothing in Redis, attempt to rehydrate from backend DB\n if (!cached && this.customFetcher) {\n const rehydrated = await this.customFetcher(request)\n\n if (rehydrated != null) {\n await this.cache.set(key, rehydrated, config.get('sessionTimeout'))\n cached = await this.getState(request)\n }\n }\n\n return cached ?? {}\n }\n\n async setState(\n request: FormRequest | FormRequestPayload,\n state: FormSubmissionState\n ) {\n const key = this.Key(request)\n const ttl = config.get('sessionTimeout')\n\n await this.cache.set(key, state, ttl)\n\n return this.getState(request)\n }\n\n async getConfirmationState(\n request: FormRequest | FormRequestPayload\n ): Promise<{ confirmed?: true }> {\n const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation)\n const value = await this.cache.get(key)\n\n return value ?? {}\n }\n\n async setConfirmationState(\n request: FormRequest | FormRequestPayload,\n confirmationState: { confirmed?: true }\n ) {\n const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation)\n const ttl = config.get('confirmationSessionTimeout')\n\n return this.cache.set(key, confirmationState, ttl)\n }\n\n async clearState(request: FormRequest | FormRequestPayload) {\n if (request.yar.id) {\n await this.cache.drop(this.Key(request))\n }\n }\n\n getFlash(\n request: FormRequest | FormRequestPayload\n ): { errors: FormSubmissionError[] } | undefined {\n const key = this.Key(request)\n const messages = request.yar.flash(key.id)\n\n if (Array.isArray(messages) && messages.length) {\n return messages.at(0) as { errors: FormSubmissionError[] }\n }\n }\n\n setFlash(\n request: FormRequest | FormRequestPayload,\n message: { errors: FormSubmissionError[] }\n ) {\n const key = this.Key(request)\n\n request.yar.flash(key.id, message)\n }\n\n private defaultKeyGenerator(\n request: Request | FormRequest | FormRequestPayload\n ): string {\n if (!request.yar.id) {\n throw new Error('No session ID found')\n }\n\n const state = (request.params.state as string) || ''\n const slug = (request.params.slug as string) || ''\n return `${request.yar.id}:${state}:${slug}:`\n }\n\n /**\n * The key used to store user session data against.\n * If there are multiple forms on the same runner instance, for example `form-a` and `form-a-feedback` this will prevent CacheService from clearing data from `form-a` if a user gave feedback before they finished `form-a`\n * @param request - hapi request object\n * @param additionalIdentifier - appended to the id\n */\n Key(\n request: Request | FormRequest | FormRequestPayload,\n additionalIdentifier?: ADDITIONAL_IDENTIFIER\n ) {\n const baseKey = this.generateKey\n ? this.generateKey(request)\n : this.defaultKeyGenerator(request)\n\n return {\n segment: partition,\n id: `${baseKey}${additionalIdentifier ?? ''}`\n }\n }\n}\n\n/**\n * State merge helper\n * 1. Merges objects (form fields)\n * 2. Overwrites arrays\n */\nexport function merge<StateType extends FormState | FormPayload>(\n state: StateType,\n update: object\n): StateType {\n return Hoek.merge(state, update, {\n mergeArrays: false\n })\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": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "Defra forms engine",
5
5
  "type": "module",
6
6
  "files": [
@@ -639,3 +639,42 @@ describe('prepareEnvironment', () => {
639
639
  )
640
640
  })
641
641
  })
642
+
643
+ describe('Exit route handlers', () => {
644
+ let server: Server
645
+
646
+ beforeAll(async () => {
647
+ server = await createServer({
648
+ services: defaultServices
649
+ })
650
+ await server.initialize()
651
+ })
652
+
653
+ afterAll(async () => {
654
+ await server.stop()
655
+ })
656
+
657
+ beforeEach(() => {
658
+ jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata)
659
+ server.app.models.clear()
660
+ })
661
+
662
+ test('GET /exit returns 200 with exit page content', async () => {
663
+ jest.mocked(getFormMetadata).mockResolvedValueOnce({
664
+ ...fixtures.form.metadata,
665
+ live: fixtures.form.state
666
+ })
667
+
668
+ jest.mocked(getFormDefinition).mockResolvedValue(fixtures.form.definition)
669
+
670
+ const options = {
671
+ method: 'GET',
672
+ url: `${FORM_PREFIX}/slug/exit`
673
+ }
674
+
675
+ const res = await server.inject(options)
676
+
677
+ expect(res.statusCode).toBe(StatusCodes.OK)
678
+ expect(res.result).toContain('Your progress has been saved')
679
+ })
680
+ })
@@ -1,5 +1,6 @@
1
1
  import { type ComponentDef } from '@defra/forms-model'
2
2
 
3
+ import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js'
3
4
  import { createComponent } from '~/src/server/plugins/engine/components/helpers.js'
4
5
  import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
5
6
  import definition from '~/test/form/definitions/basic.js'
@@ -22,3 +23,33 @@ describe('helpers tests', () => {
22
23
  ).toThrow('Component type invalid-type does not exist')
23
24
  })
24
25
  })
26
+
27
+ describe('ComponentBase tests', () => {
28
+ test('should handle save and return functionality', () => {
29
+ const mockComponentDef = {
30
+ type: 'TextField',
31
+ name: 'testField',
32
+ title: 'Test Field'
33
+ } as ComponentDef
34
+
35
+ const component = new ComponentBase(mockComponentDef, { model: formModel })
36
+
37
+ expect(component.name).toBe('testField')
38
+ expect(component.title).toBe('Test Field')
39
+ expect(component.type).toBe('TextField')
40
+ })
41
+
42
+ test('should handle context correctly', () => {
43
+ const mockComponentDef = {
44
+ type: 'TextField',
45
+ name: 'contextField',
46
+ title: 'Context Field'
47
+ } as ComponentDef
48
+
49
+ const component = new ComponentBase(mockComponentDef, { model: formModel })
50
+
51
+ expect(component.model).toBe(formModel)
52
+ expect(component.name).toBe('contextField')
53
+ expect(component.title).toBe('Context Field')
54
+ })
55
+ })
@@ -33,9 +33,7 @@ export const prepareNunjucksEnvironment = function (
33
33
  env.addFilter(name, nunjucksFilter)
34
34
  }
35
35
 
36
- env.addFilter('markdown', (text: string) =>
37
- markdownToHtml(text, pluginOptions.baseUrl)
38
- )
36
+ env.addFilter('markdown', (text: string) => markdownToHtml(text))
39
37
 
40
38
  for (const [name, nunjucksGlobal] of Object.entries(globals)) {
41
39
  env.addGlobal(name, nunjucksGlobal)
@@ -12,7 +12,7 @@ import definition from '~/test/form/definitions/conditions-escaping.js'
12
12
  import conditionsListDefinition from '~/test/form/definitions/conditions-list.js'
13
13
  import relativeDatesDefinition from '~/test/form/definitions/conditions-relative-dates-v2.js'
14
14
  import fieldsRequiredDefinition from '~/test/form/definitions/fields-required.js'
15
- import joinedConditionsDefinition from '~/test/form/definitions/joined-conditions-test.js'
15
+ import joinedConditionsDefinition from '~/test/form/definitions/joined-conditions-simple-v2.js'
16
16
 
17
17
  jest.mock('~/src/server/plugins/engine/date-helper.ts')
18
18
 
@@ -357,15 +357,15 @@ describe('FormModel - Joined Conditions', () => {
357
357
  const joinedCondition =
358
358
  model.conditions['db43c6bc-9ce6-478b-8345-4fff5eff2ba3']
359
359
  expect(joinedCondition).toBeDefined()
360
- expect(joinedCondition?.displayName).toBe('joined condition')
360
+ expect(joinedCondition?.displayName).toBe('is Bob AND over 18')
361
361
 
362
- const stateAllTrue = { fsZNJr: 'Bob', DaBGpS: true }
362
+ const stateAllTrue = { userName: 'Bob', isOverEighteen: true }
363
363
  expect(joinedCondition?.fn(stateAllTrue)).toBe(true)
364
364
 
365
- const statePartialTrue = { fsZNJr: 'Alice', DaBGpS: true }
365
+ const statePartialTrue = { userName: 'Alice', isOverEighteen: true }
366
366
  expect(joinedCondition?.fn(statePartialTrue)).toBe(false)
367
367
 
368
- const stateFalse = { fsZNJr: 'Alice', DaBGpS: false }
368
+ const stateFalse = { userName: 'Alice', isOverEighteen: false }
369
369
  expect(joinedCondition?.fn(stateFalse)).toBe(false)
370
370
  })
371
371
 
@@ -379,18 +379,92 @@ describe('FormModel - Joined Conditions', () => {
379
379
  })
380
380
 
381
381
  const joinedConditionPage = model.pages.find(
382
- (page) => page.path === '/joined-condition-page'
382
+ (page) => page.path === '/simple-and-page'
383
383
  )
384
384
 
385
385
  expect(joinedConditionPage?.condition).toBeDefined()
386
386
 
387
- const trueState = { fsZNJr: 'Bob', DaBGpS: true }
387
+ const trueState = { userName: 'Bob', isOverEighteen: true }
388
388
  expect(joinedConditionPage?.condition?.fn(trueState)).toBe(true)
389
389
 
390
- const falseState = { fsZNJr: 'Bob', DaBGpS: false }
390
+ const falseState = { userName: 'Bob', isOverEighteen: false }
391
391
  expect(joinedConditionPage?.condition?.fn(falseState)).toBe(false)
392
392
  })
393
393
 
394
+ it('should handle V1 joined conditions without aliases', () => {
395
+ formDefinitionV2Schema.validate = jest
396
+ .fn()
397
+ .mockReturnValue({ value: definition })
398
+
399
+ const model = new FormModel(definition, {
400
+ basePath: 'test'
401
+ })
402
+
403
+ expect(model.conditions).toBeDefined()
404
+ expect(Object.keys(model.conditions)).toHaveLength(1)
405
+
406
+ const joinedCondition = model.conditions.ZCXeMz
407
+ expect(joinedCondition).toBeDefined()
408
+ expect(joinedCondition?.displayName).toBe('test')
409
+
410
+ const testState = { NIJphU: "ap'ostrophe's", iraEpG: "shouldn't've" }
411
+ expect(joinedCondition?.fn(testState)).toBe(true)
412
+
413
+ const testStateFalse = { NIJphU: 'other', iraEpG: "shouldn't've" }
414
+ expect(joinedCondition?.fn(testStateFalse)).toBe(false)
415
+
416
+ const context = model.toConditionContext(testState, model.conditions)
417
+
418
+ expect(context).not.toHaveProperty('cond_ZCXeMz')
419
+
420
+ expect(context).toHaveProperty('ZCXeMz')
421
+
422
+ expect(context).toHaveProperty('NIJphU', "ap'ostrophe's")
423
+ expect(context).toHaveProperty('iraEpG', "shouldn't've")
424
+ })
425
+
426
+ it('should use schema version to determine condition aliases', () => {
427
+ const v1Definition = { ...definition, schema: SchemaVersion.V1 }
428
+ formDefinitionV2Schema.validate = jest
429
+ .fn()
430
+ .mockReturnValue({ value: v1Definition })
431
+
432
+ const v1Model = new FormModel(v1Definition, { basePath: 'test' })
433
+ expect(v1Model.schemaVersion).toBe(SchemaVersion.V1)
434
+
435
+ const v1TestState = { NIJphU: "ap'ostrophe's", iraEpG: "shouldn't've" }
436
+ const v1Context = v1Model.toConditionContext(
437
+ v1TestState,
438
+ v1Model.conditions
439
+ )
440
+
441
+ expect(v1Context).toHaveProperty('ZCXeMz')
442
+ expect(v1Context).not.toHaveProperty('cond_ZCXeMz')
443
+
444
+ formDefinitionV2Schema.validate = jest
445
+ .fn()
446
+ .mockReturnValue({ value: joinedConditionsDefinition })
447
+
448
+ const v2Model = new FormModel(joinedConditionsDefinition, {
449
+ basePath: 'test'
450
+ })
451
+ expect(v2Model.schemaVersion).toBe(SchemaVersion.V2)
452
+
453
+ const v2TestState = { userName: 'Bob', isOverEighteen: true }
454
+ const v2Context = v2Model.toConditionContext(
455
+ v2TestState,
456
+ v2Model.conditions
457
+ )
458
+
459
+ expect(v2Context).toHaveProperty('cond_d15aff7a622440a28e5f51a5af2f7910')
460
+ expect(v2Context).toHaveProperty('cond_d1f9fcc7f09847e79d314f5ee57ba985')
461
+ expect(v2Context).toHaveProperty('cond_db43c6bc9ce6478b83454fff5eff2ba3')
462
+
463
+ expect(v2Context).not.toHaveProperty('d15aff7a-6224-40a2-8e5f-51a5af2f7910')
464
+ expect(v2Context).not.toHaveProperty('d1f9fcc7-f098-47e7-9d31-4f5ee57ba985')
465
+ expect(v2Context).not.toHaveProperty('db43c6bc-9ce6-478b-8345-4fff5eff2ba3')
466
+ })
467
+
394
468
  describe('generateConditionAlias', () => {
395
469
  it('should generate valid JavaScript identifiers from condition IDs', () => {
396
470
  formDefinitionV2Schema.validate = jest
@@ -401,7 +475,7 @@ describe('FormModel - Joined Conditions', () => {
401
475
  basePath: 'test'
402
476
  })
403
477
 
404
- const evaluationState = { fsZNJr: 'Bob', DaBGpS: true }
478
+ const evaluationState = { userName: 'Bob', isOverEighteen: true }
405
479
 
406
480
  const context = model.toConditionContext(
407
481
  evaluationState,
@@ -428,8 +502,8 @@ describe('FormModel - Joined Conditions', () => {
428
502
  model.conditions['db43c6bc-9ce6-478b-8345-4fff5eff2ba3']
429
503
  expect(joinedCondition).toBeDefined()
430
504
 
431
- const stateTrue = { fsZNJr: 'Bob', DaBGpS: true }
432
- const stateFalse = { fsZNJr: 'Alice', DaBGpS: false }
505
+ const stateTrue = { userName: 'Bob', isOverEighteen: true }
506
+ const stateFalse = { userName: 'Alice', isOverEighteen: false }
433
507
 
434
508
  expect(joinedCondition?.fn(stateTrue)).toBe(true)
435
509
  expect(joinedCondition?.fn(stateFalse)).toBe(false)
@@ -278,9 +278,12 @@ export class FormModel {
278
278
  const context = { ...evaluationState }
279
279
 
280
280
  for (const conditionId in conditions) {
281
- const alias = generateConditionAlias(conditionId)
281
+ const propertyName =
282
+ this.schemaVersion === SchemaVersion.V2
283
+ ? generateConditionAlias(conditionId)
284
+ : conditionId
282
285
 
283
- Object.defineProperty(context, alias, {
286
+ Object.defineProperty(context, propertyName, {
284
287
  get() {
285
288
  return conditions[conditionId]?.fn(evaluationState)
286
289
  }
@@ -3,6 +3,7 @@ import {
3
3
  FormModel,
4
4
  SummaryViewModel
5
5
  } from '~/src/server/plugins/engine/models/index.js'
6
+ import { SummaryPageController } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js'
6
7
  import {
7
8
  createPage,
8
9
  type PageControllerClass
@@ -249,3 +250,61 @@ describe('SummaryViewModel', () => {
249
250
  })
250
251
  })
251
252
  })
253
+
254
+ describe('SummaryPageController', () => {
255
+ let model: FormModel
256
+ let controller: SummaryPageController
257
+ let request: FormContextRequest
258
+
259
+ beforeEach(() => {
260
+ model = new FormModel(definition, {
261
+ basePath: `${FORM_PREFIX}/test`
262
+ })
263
+
264
+ controller = new SummaryPageController(model, definition.pages[2])
265
+
266
+ request = {
267
+ method: 'get',
268
+ url: new URL('http://example.com/repeat/pizza-order/summary'),
269
+ path: '/repeat/pizza-order/summary',
270
+ params: {
271
+ path: 'pizza-order',
272
+ slug: 'repeat'
273
+ },
274
+ query: {},
275
+ app: { model }
276
+ }
277
+ })
278
+
279
+ describe('Save and Return functionality', () => {
280
+ it('should show save and return button on summary page', () => {
281
+ expect(controller.shouldShowSaveAndReturn()).toBe(true)
282
+ })
283
+
284
+ it('should handle save and return from summary page', () => {
285
+ const state: FormState = {
286
+ $$__referenceNumber: 'foobar',
287
+ orderType: 'collection',
288
+ pizza: []
289
+ }
290
+
291
+ const context = model.getFormContext(request, state)
292
+ const viewModel = controller.getViewModel(request, context)
293
+
294
+ expect(viewModel).toHaveProperty('allowSaveAndReturn', true)
295
+ })
296
+
297
+ it('should display correct page title', () => {
298
+ const state: FormState = {
299
+ $$__referenceNumber: 'foobar',
300
+ orderType: 'collection',
301
+ pizza: []
302
+ }
303
+
304
+ const context = model.getFormContext(request, state)
305
+ const viewModel = controller.getViewModel(request, context)
306
+
307
+ expect(viewModel.pageTitle).toBe('Check your answers')
308
+ })
309
+ })
310
+ })
@@ -51,6 +51,7 @@ export class SummaryViewModel {
51
51
  serviceUrl: string
52
52
  hasMissingNotificationEmail?: boolean
53
53
  components?: ComponentViewModel[]
54
+ allowSaveAndReturn?: boolean
54
55
 
55
56
  constructor(
56
57
  request: FormContextRequest,
@@ -19,7 +19,10 @@ const pluginRegistrationOptionsSchema = Joi.object({
19
19
  viewContext: Joi.function().required(),
20
20
  preparePageEventRequestOptions: Joi.function().optional(),
21
21
  onRequest: Joi.function().optional(),
22
- baseUrl: Joi.string().uri().required()
22
+ baseUrl: Joi.string().uri().required(),
23
+ keyGenerator: Joi.function().optional(),
24
+ sessionHydrator: Joi.function().optional(),
25
+ sessionPersister: Joi.function().optional()
23
26
  })
24
27
 
25
28
  /**
@@ -16,6 +16,26 @@ describe('validatePluginOptions', () => {
16
16
  expect(validatePluginOptions(validOptions)).toEqual(validOptions)
17
17
  })
18
18
 
19
+ it('accepts optional properties keyGenerator, sessionHydrator, and sessionPersister', () => {
20
+ const validOptionsWithOptionals = {
21
+ nunjucks: {
22
+ baseLayoutPath: 'dxt-devtool-baselayout.html',
23
+ paths: ['src/server/devserver']
24
+ },
25
+ viewContext: () => {
26
+ return { hello: 'world' }
27
+ },
28
+ baseUrl: 'http://localhost:3009',
29
+ keyGenerator: () => 'test-key',
30
+ sessionHydrator: () => Promise.resolve({ someState: 'value' }),
31
+ sessionPersister: () => Promise.resolve(undefined)
32
+ }
33
+
34
+ expect(validatePluginOptions(validOptionsWithOptionals)).toEqual(
35
+ validOptionsWithOptionals
36
+ )
37
+ })
38
+
19
39
  /**
20
40
  * tsc would usually check compliance with the type, but given a user might be using plain JS we still want a test
21
41
  */
@@ -204,5 +204,30 @@ describe('PageController', () => {
204
204
  'Unsupported POST route handler for this page'
205
205
  )
206
206
  })
207
+
208
+ it('supports save and return functionality', async () => {
209
+ const mockRequest = {
210
+ ...request,
211
+ payload: { saveAndReturn: true }
212
+ } as FormRequest
213
+
214
+ const mockResponse = {
215
+ redirect: jest.fn(),
216
+ view: jest.fn()
217
+ } as unknown as ResponseToolkit
218
+
219
+ await controller1.makeGetRouteHandler()(
220
+ mockRequest,
221
+ model.getFormContext(mockRequest, { $$__referenceNumber: 'test-ref' }),
222
+ mockResponse
223
+ )
224
+
225
+ expect(mockResponse.view).toHaveBeenCalledWith(
226
+ controller1.viewName,
227
+ expect.objectContaining({
228
+ pageTitle: 'Buy a rod fishing licence'
229
+ })
230
+ )
231
+ })
207
232
  })
208
233
  })
@@ -10,7 +10,10 @@ import {
10
10
  type FormState,
11
11
  type FormSubmissionState
12
12
  } from '~/src/server/plugins/engine/types.js'
13
- import { type FormRequest } from '~/src/server/routes/types.js'
13
+ import {
14
+ type FormRequest,
15
+ type FormRequestPayload
16
+ } from '~/src/server/routes/types.js'
14
17
  import { CacheService } from '~/src/server/services/cacheService.js'
15
18
  import conditionalReveal from '~/test/form/definitions/conditional-reveal.js'
16
19
  import definitionConditionsBasic, {
@@ -1274,3 +1277,177 @@ describe('QuestionPageController V2', () => {
1274
1277
  })
1275
1278
  })
1276
1279
  })
1280
+
1281
+ describe('Save and Return functionality', () => {
1282
+ let model: FormModel
1283
+ let controller1: QuestionPageController
1284
+ let requestPage1: FormRequest
1285
+
1286
+ beforeEach(() => {
1287
+ const { pages } = definitionConditionsBasic
1288
+
1289
+ model = new FormModel(definitionConditionsBasic, {
1290
+ basePath: 'test'
1291
+ })
1292
+
1293
+ controller1 = new QuestionPageController(model, pages[0])
1294
+
1295
+ requestPage1 = {
1296
+ method: 'get',
1297
+ url: new URL('http://example.com/test/first-page'),
1298
+ path: '/test/first-page',
1299
+ params: {
1300
+ path: 'first-page',
1301
+ slug: 'test'
1302
+ },
1303
+ query: {},
1304
+ app: { model }
1305
+ } as FormRequest
1306
+ })
1307
+
1308
+ const response = {
1309
+ code: jest.fn().mockImplementation(() => response)
1310
+ }
1311
+
1312
+ const h: Pick<ResponseToolkit, 'redirect' | 'view'> = {
1313
+ redirect: jest.fn().mockReturnValue(response),
1314
+ view: jest.fn()
1315
+ }
1316
+
1317
+ beforeEach(() => {
1318
+ jest.clearAllMocks()
1319
+ jest.spyOn(CacheService.prototype, 'setState')
1320
+ })
1321
+
1322
+ describe('shouldShowSaveAndReturn', () => {
1323
+ it('should return true by default', () => {
1324
+ expect(controller1.shouldShowSaveAndReturn()).toBe(true)
1325
+ })
1326
+ })
1327
+
1328
+ describe('handleSaveAndReturn', () => {
1329
+ it('should save state and redirect to exit page', async () => {
1330
+ const state: FormSubmissionState = {
1331
+ $$__referenceNumber: 'foobar',
1332
+ yesNoField: true
1333
+ }
1334
+ const request = {
1335
+ ...requestPage1,
1336
+ method: 'post',
1337
+ payload: { yesNoField: true, action: 'save-and-return' }
1338
+ } as unknown as FormRequestPayload
1339
+
1340
+ const context = model.getFormContext(request, state)
1341
+
1342
+ jest.spyOn(controller1, 'setState').mockResolvedValue(state)
1343
+
1344
+ await controller1.handleSaveAndReturn(request, context, h)
1345
+
1346
+ expect(controller1.setState).toHaveBeenCalledWith(request, state)
1347
+ expect(h.redirect).toHaveBeenCalledWith('/test/exit')
1348
+ })
1349
+
1350
+ it('should handle save-and-return with incomplete data', async () => {
1351
+ const state: FormSubmissionState = {
1352
+ $$__referenceNumber: 'foobar',
1353
+ yesNoField: null
1354
+ }
1355
+ const request = {
1356
+ ...requestPage1,
1357
+ method: 'post',
1358
+ payload: { yesNoField: '', action: 'save-and-return' }
1359
+ } as unknown as FormRequestPayload
1360
+
1361
+ const context = model.getFormContext(request, state)
1362
+
1363
+ jest.spyOn(controller1, 'setState').mockResolvedValue(state)
1364
+
1365
+ await controller1.handleSaveAndReturn(request, context, h)
1366
+
1367
+ expect(controller1.setState).toHaveBeenCalledWith(request, state)
1368
+ expect(h.redirect).toHaveBeenCalledWith('/test/exit')
1369
+ })
1370
+
1371
+ it('should handle save-and-return with validation errors', async () => {
1372
+ const state: FormSubmissionState = { $$__referenceNumber: 'foobar' }
1373
+ const request = {
1374
+ ...requestPage1,
1375
+ method: 'post',
1376
+ payload: { action: 'save-and-return' }
1377
+ } as unknown as FormRequestPayload
1378
+
1379
+ const context = model.getFormContext(request, state)
1380
+
1381
+ jest.spyOn(controller1, 'setState').mockResolvedValue(state)
1382
+
1383
+ await controller1.handleSaveAndReturn(request, context, h)
1384
+
1385
+ expect(controller1.setState).toHaveBeenCalledWith(request, state)
1386
+ expect(h.redirect).toHaveBeenCalledWith('/test/exit')
1387
+ })
1388
+ })
1389
+
1390
+ describe('POST handler with save-and-return action', () => {
1391
+ it('should handle FormAction.SaveAndReturn', async () => {
1392
+ const state: FormSubmissionState = {
1393
+ $$__referenceNumber: 'foobar',
1394
+ yesNoField: true
1395
+ }
1396
+ const request = {
1397
+ ...requestPage1,
1398
+ method: 'post',
1399
+ payload: { yesNoField: true, action: 'save-and-return' }
1400
+ } as unknown as FormRequestPayload
1401
+
1402
+ const context = model.getFormContext(request, state)
1403
+
1404
+ jest.spyOn(controller1, 'getState').mockResolvedValue({})
1405
+ jest
1406
+ .spyOn(controller1, 'handleSaveAndReturn')
1407
+ .mockResolvedValue(h.redirect('/test/exit'))
1408
+
1409
+ const postHandler = controller1.makePostRouteHandler()
1410
+ await postHandler(request, context, h)
1411
+
1412
+ expect(controller1.handleSaveAndReturn).toHaveBeenCalledWith(
1413
+ request,
1414
+ context,
1415
+ h
1416
+ )
1417
+ })
1418
+
1419
+ it('should not call handleSaveAndReturn for continue action', async () => {
1420
+ const state: FormSubmissionState = {
1421
+ $$__referenceNumber: 'foobar',
1422
+ yesNoField: true
1423
+ }
1424
+ const request = {
1425
+ ...requestPage1,
1426
+ method: 'post',
1427
+ payload: { yesNoField: true, action: 'continue' }
1428
+ } as unknown as FormRequestPayload
1429
+
1430
+ const context = model.getFormContext(request, state)
1431
+
1432
+ jest.spyOn(controller1, 'getState').mockResolvedValue({})
1433
+ jest
1434
+ .spyOn(controller1, 'handleSaveAndReturn')
1435
+ .mockResolvedValue(h.redirect('/test/exit'))
1436
+ jest.spyOn(controller1, 'setState').mockResolvedValue(state)
1437
+
1438
+ const mockResponse = {
1439
+ code: jest.fn().mockReturnValue({ redirect: jest.fn() })
1440
+ }
1441
+
1442
+ const mockH = {
1443
+ redirect: jest.fn().mockReturnValue(mockResponse),
1444
+ view: jest.fn()
1445
+ }
1446
+
1447
+ const postHandler = controller1.makePostRouteHandler()
1448
+ await postHandler(request, context, mockH)
1449
+
1450
+ expect(controller1.handleSaveAndReturn).not.toHaveBeenCalled()
1451
+ })
1452
+ })
1453
+ })