@defra/forms-engine-plugin 0.0.4 → 0.0.6

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 (260) hide show
  1. package/.server/server/index.js +0 -4
  2. package/.server/server/index.js.map +1 -1
  3. package/.server/server/plugins/engine/helpers.js +3 -0
  4. package/.server/server/plugins/engine/helpers.js.map +1 -1
  5. package/.server/server/plugins/engine/index.js +27 -1
  6. package/.server/server/plugins/engine/index.js.map +1 -1
  7. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js +2 -4
  8. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js.map +1 -1
  9. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +4 -10
  10. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  11. package/.server/server/plugins/engine/pageControllers/StatusPageController.js +2 -3
  12. package/.server/server/plugins/engine/pageControllers/StatusPageController.js.map +1 -1
  13. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +2 -4
  14. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  15. package/.server/server/plugins/engine/plugin.js +65 -6
  16. package/.server/server/plugins/engine/plugin.js.map +1 -1
  17. package/.server/server/plugins/engine/types.js.map +1 -1
  18. package/.server/server/{views → plugins/engine/views}/components/service-banner/template.test.js +1 -1
  19. package/.server/server/plugins/engine/views/components/service-banner/template.test.js.map +1 -0
  20. package/.server/server/{views → plugins/engine/views}/components/tag-env/template.test.js +1 -1
  21. package/.server/server/plugins/engine/views/components/tag-env/template.test.js.map +1 -0
  22. package/.server/server/services/cacheService.js +5 -2
  23. package/.server/server/services/cacheService.js.map +1 -1
  24. package/.server/typings/hapi/index.d.js.map +1 -1
  25. package/README.md +215 -4
  26. package/package.json +3 -3
  27. package/src/client/javascripts/application.js +87 -0
  28. package/src/client/javascripts/file-upload.js +386 -0
  29. package/src/client/stylesheets/_code.scss +33 -0
  30. package/src/client/stylesheets/_govuk-frontend.scss +4 -0
  31. package/src/client/stylesheets/_prose.scss +56 -0
  32. package/src/client/stylesheets/_service-banner.scss +24 -0
  33. package/src/client/stylesheets/_summary-list.scss +28 -0
  34. package/src/client/stylesheets/_tag-env.scss +24 -0
  35. package/src/client/stylesheets/application.scss +14 -0
  36. package/src/common/cookies.js +58 -0
  37. package/src/common/cookies.test.js +23 -0
  38. package/src/common/types.js +5 -0
  39. package/src/config/index.ts +271 -0
  40. package/src/index.ts +31 -0
  41. package/src/server/common/helpers/logging/logger-options.test.ts +50 -0
  42. package/src/server/common/helpers/logging/logger-options.ts +46 -0
  43. package/src/server/common/helpers/logging/logger.ts +7 -0
  44. package/src/server/common/helpers/logging/request-logger.ts +9 -0
  45. package/src/server/common/helpers/logging/request-tracing.js +10 -0
  46. package/src/server/common/helpers/redis-client.js +70 -0
  47. package/src/server/constants.js +1 -0
  48. package/src/server/forms/README.md +10 -0
  49. package/src/server/forms/components.json +1015 -0
  50. package/src/server/forms/report-a-terrorist.json +270 -0
  51. package/src/server/forms/runner-components-test.json +365 -0
  52. package/src/server/forms/test.json +581 -0
  53. package/src/server/index.test.ts +582 -0
  54. package/src/server/index.ts +135 -0
  55. package/src/server/plugins/blankie.test.ts +73 -0
  56. package/src/server/plugins/blankie.ts +48 -0
  57. package/src/server/plugins/crumb.ts +20 -0
  58. package/src/server/plugins/engine/README.md +87 -0
  59. package/src/server/plugins/engine/components/AutocompleteField.test.ts +294 -0
  60. package/src/server/plugins/engine/components/AutocompleteField.ts +49 -0
  61. package/src/server/plugins/engine/components/CheckboxesField.test.ts +379 -0
  62. package/src/server/plugins/engine/components/CheckboxesField.ts +106 -0
  63. package/src/server/plugins/engine/components/ComponentBase.ts +97 -0
  64. package/src/server/plugins/engine/components/ComponentCollection.ts +278 -0
  65. package/src/server/plugins/engine/components/DatePartsField.test.ts +822 -0
  66. package/src/server/plugins/engine/components/DatePartsField.ts +264 -0
  67. package/src/server/plugins/engine/components/Details.test.ts +49 -0
  68. package/src/server/plugins/engine/components/Details.ts +30 -0
  69. package/src/server/plugins/engine/components/EmailAddressField.test.ts +395 -0
  70. package/src/server/plugins/engine/components/EmailAddressField.ts +55 -0
  71. package/src/server/plugins/engine/components/FileUploadField.test.ts +778 -0
  72. package/src/server/plugins/engine/components/FileUploadField.ts +262 -0
  73. package/src/server/plugins/engine/components/FormComponent.ts +249 -0
  74. package/src/server/plugins/engine/components/Html.test.ts +48 -0
  75. package/src/server/plugins/engine/components/Html.ts +29 -0
  76. package/src/server/plugins/engine/components/InsetText.test.ts +48 -0
  77. package/src/server/plugins/engine/components/InsetText.ts +27 -0
  78. package/src/server/plugins/engine/components/List.test.ts +76 -0
  79. package/src/server/plugins/engine/components/List.ts +72 -0
  80. package/src/server/plugins/engine/components/ListFormComponent.ts +140 -0
  81. package/src/server/plugins/engine/components/MonthYearField.test.ts +567 -0
  82. package/src/server/plugins/engine/components/MonthYearField.ts +222 -0
  83. package/src/server/plugins/engine/components/MultilineTextField.test.ts +558 -0
  84. package/src/server/plugins/engine/components/MultilineTextField.ts +138 -0
  85. package/src/server/plugins/engine/components/NumberField.test.ts +701 -0
  86. package/src/server/plugins/engine/components/NumberField.ts +163 -0
  87. package/src/server/plugins/engine/components/RadiosField.test.ts +288 -0
  88. package/src/server/plugins/engine/components/RadiosField.ts +24 -0
  89. package/src/server/plugins/engine/components/SelectField.test.ts +288 -0
  90. package/src/server/plugins/engine/components/SelectField.ts +47 -0
  91. package/src/server/plugins/engine/components/SelectionControlField.ts +43 -0
  92. package/src/server/plugins/engine/components/TelephoneNumberField.test.ts +356 -0
  93. package/src/server/plugins/engine/components/TelephoneNumberField.ts +67 -0
  94. package/src/server/plugins/engine/components/TextField.test.ts +489 -0
  95. package/src/server/plugins/engine/components/TextField.ts +96 -0
  96. package/src/server/plugins/engine/components/UkAddressField.test.ts +623 -0
  97. package/src/server/plugins/engine/components/UkAddressField.ts +172 -0
  98. package/src/server/plugins/engine/components/YesNoField.test.ts +248 -0
  99. package/src/server/plugins/engine/components/YesNoField.ts +31 -0
  100. package/src/server/plugins/engine/components/constants.ts +1 -0
  101. package/src/server/plugins/engine/components/helpers.ts +330 -0
  102. package/src/server/plugins/engine/components/index.ts +24 -0
  103. package/src/server/plugins/engine/components/types.ts +117 -0
  104. package/src/server/plugins/engine/configureEnginePlugin.ts +47 -0
  105. package/src/server/plugins/engine/helpers.test.ts +791 -0
  106. package/src/server/plugins/engine/helpers.ts +384 -0
  107. package/src/server/plugins/engine/index.ts +47 -0
  108. package/src/server/plugins/engine/models/FormModel.test.ts +42 -0
  109. package/src/server/plugins/engine/models/FormModel.ts +443 -0
  110. package/src/server/plugins/engine/models/RepeatingSummaryViewModel.ts +0 -0
  111. package/src/server/plugins/engine/models/Section.ts +0 -0
  112. package/src/server/plugins/engine/models/SummaryViewModel.test.ts +209 -0
  113. package/src/server/plugins/engine/models/SummaryViewModel.ts +220 -0
  114. package/src/server/plugins/engine/models/index.ts +2 -0
  115. package/src/server/plugins/engine/models/types.ts +114 -0
  116. package/src/server/plugins/engine/outputFormatters/human/v1.test.ts +143 -0
  117. package/src/server/plugins/engine/outputFormatters/human/v1.ts +73 -0
  118. package/src/server/plugins/engine/outputFormatters/index.test.ts +17 -0
  119. package/src/server/plugins/engine/outputFormatters/index.ts +44 -0
  120. package/src/server/plugins/engine/outputFormatters/machine/v1.test.ts +229 -0
  121. package/src/server/plugins/engine/outputFormatters/machine/v1.ts +140 -0
  122. package/src/server/plugins/engine/outputFormatters/machine/v2.test.ts +229 -0
  123. package/src/server/plugins/engine/outputFormatters/machine/v2.ts +153 -0
  124. package/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +1116 -0
  125. package/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +447 -0
  126. package/src/server/plugins/engine/pageControllers/PageController.test.ts +205 -0
  127. package/src/server/plugins/engine/pageControllers/PageController.ts +176 -0
  128. package/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +1264 -0
  129. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +565 -0
  130. package/src/server/plugins/engine/pageControllers/README.md +28 -0
  131. package/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts +264 -0
  132. package/src/server/plugins/engine/pageControllers/RepeatPageController.ts +458 -0
  133. package/src/server/plugins/engine/pageControllers/StartPageController.ts +18 -0
  134. package/src/server/plugins/engine/pageControllers/StatusPageController.ts +51 -0
  135. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +262 -0
  136. package/src/server/plugins/engine/pageControllers/TerminalController.test.ts +28 -0
  137. package/src/server/plugins/engine/pageControllers/TerminalPageController.ts +19 -0
  138. package/src/server/plugins/engine/pageControllers/helpers.test.ts +198 -0
  139. package/src/server/plugins/engine/pageControllers/helpers.ts +101 -0
  140. package/src/server/plugins/engine/pageControllers/index.ts +10 -0
  141. package/src/server/plugins/engine/pageControllers/validationOptions.ts +89 -0
  142. package/src/server/plugins/engine/plugin.ts +753 -0
  143. package/src/server/plugins/engine/services/formSubmissionService.js +46 -0
  144. package/src/server/plugins/engine/services/formsService.js +46 -0
  145. package/src/server/plugins/engine/services/formsService.test.js +90 -0
  146. package/src/server/plugins/engine/services/index.js +3 -0
  147. package/src/server/plugins/engine/services/notifyService.test.ts +132 -0
  148. package/src/server/plugins/engine/services/notifyService.ts +64 -0
  149. package/src/server/plugins/engine/services/uploadService.js +60 -0
  150. package/src/server/plugins/engine/types.ts +317 -0
  151. package/src/server/plugins/engine/views/components/autocompletefield.html +5 -0
  152. package/src/server/plugins/engine/views/components/checkboxesfield.html +5 -0
  153. package/src/server/plugins/engine/views/components/datepartsfield.html +5 -0
  154. package/src/server/plugins/engine/views/components/debug/macro.njk +3 -0
  155. package/src/server/plugins/engine/views/components/debug/template.njk +13 -0
  156. package/src/server/plugins/engine/views/components/details.html +6 -0
  157. package/src/server/plugins/engine/views/components/emailaddressfield.html +5 -0
  158. package/src/server/plugins/engine/views/components/fileuploadfield-key.html +8 -0
  159. package/src/server/plugins/engine/views/components/fileuploadfield-value.html +3 -0
  160. package/src/server/plugins/engine/views/components/fileuploadfield.html +24 -0
  161. package/src/server/plugins/engine/views/components/html.html +3 -0
  162. package/src/server/plugins/engine/views/components/insettext.html +7 -0
  163. package/src/server/plugins/engine/views/components/list.html +36 -0
  164. package/src/server/plugins/engine/views/components/monthyearfield.html +5 -0
  165. package/src/server/plugins/engine/views/components/multilinetextfield.html +10 -0
  166. package/src/server/plugins/engine/views/components/numberfield.html +5 -0
  167. package/src/server/plugins/engine/views/components/radiosfield.html +5 -0
  168. package/src/server/plugins/engine/views/components/selectfield.html +5 -0
  169. package/src/server/plugins/engine/views/components/service-banner/macro.njk +3 -0
  170. package/src/server/plugins/engine/views/components/service-banner/template.njk +20 -0
  171. package/src/server/plugins/engine/views/components/service-banner/template.test.js +43 -0
  172. package/src/server/plugins/engine/views/components/tag-env/macro.njk +3 -0
  173. package/src/server/plugins/engine/views/components/tag-env/template.njk +30 -0
  174. package/src/server/plugins/engine/views/components/tag-env/template.test.js +66 -0
  175. package/src/server/plugins/engine/views/components/telephonenumberfield.html +5 -0
  176. package/src/server/plugins/engine/views/components/textfield.html +5 -0
  177. package/src/server/plugins/engine/views/components/ukaddressfield.html +25 -0
  178. package/src/server/plugins/engine/views/components/yesnofield.html +5 -0
  179. package/src/server/plugins/engine/views/confirmation.html +19 -0
  180. package/src/server/plugins/engine/views/file-upload.html +45 -0
  181. package/src/server/plugins/engine/views/index.html +39 -0
  182. package/src/server/plugins/engine/views/item-delete.html +56 -0
  183. package/src/server/plugins/engine/views/layout.html +199 -0
  184. package/src/server/plugins/engine/views/partials/components.html +6 -0
  185. package/src/server/plugins/engine/views/partials/conditional-components.html +3 -0
  186. package/src/server/plugins/engine/views/partials/debug.html +44 -0
  187. package/src/server/plugins/engine/views/partials/form.html +15 -0
  188. package/src/server/plugins/engine/views/partials/heading.html +16 -0
  189. package/src/server/plugins/engine/views/partials/preview-banner.html +32 -0
  190. package/src/server/plugins/engine/views/partials/preview-banner.test.js +122 -0
  191. package/src/server/plugins/engine/views/partials/warn-missing-notification-email.html +10 -0
  192. package/src/server/plugins/engine/views/repeat-list-summary.html +53 -0
  193. package/src/server/plugins/engine/views/summary.html +50 -0
  194. package/src/server/plugins/errorPages.ts +58 -0
  195. package/src/server/plugins/nunjucks/context.js +88 -0
  196. package/src/server/plugins/nunjucks/context.test.js +142 -0
  197. package/src/server/plugins/nunjucks/enviroment.test.js +201 -0
  198. package/src/server/plugins/nunjucks/environment.js +116 -0
  199. package/src/server/plugins/nunjucks/filters/answer.js +27 -0
  200. package/src/server/plugins/nunjucks/filters/answer.test.js +89 -0
  201. package/src/server/plugins/nunjucks/filters/evaluate.js +21 -0
  202. package/src/server/plugins/nunjucks/filters/field.js +28 -0
  203. package/src/server/plugins/nunjucks/filters/field.test.js +75 -0
  204. package/src/server/plugins/nunjucks/filters/highlight.js +11 -0
  205. package/src/server/plugins/nunjucks/filters/href.js +30 -0
  206. package/src/server/plugins/nunjucks/filters/href.test.js +80 -0
  207. package/src/server/plugins/nunjucks/filters/index.js +8 -0
  208. package/src/server/plugins/nunjucks/filters/inspect.js +15 -0
  209. package/src/server/plugins/nunjucks/filters/page.js +24 -0
  210. package/src/server/plugins/nunjucks/filters/page.test.js +65 -0
  211. package/src/server/plugins/nunjucks/index.js +3 -0
  212. package/src/server/plugins/nunjucks/plugin.js +40 -0
  213. package/src/server/plugins/nunjucks/render.js +42 -0
  214. package/src/server/plugins/nunjucks/types.js +40 -0
  215. package/src/server/plugins/pulse.ts +11 -0
  216. package/src/server/plugins/router.ts +201 -0
  217. package/src/server/plugins/session.ts +28 -0
  218. package/src/server/routes/health.js +13 -0
  219. package/src/server/routes/health.test.js +35 -0
  220. package/src/server/routes/index.test.ts +125 -0
  221. package/src/server/routes/index.ts +2 -0
  222. package/src/server/routes/public.ts +47 -0
  223. package/src/server/routes/types.ts +48 -0
  224. package/src/server/schemas/index.ts +34 -0
  225. package/src/server/secure-context.js +43 -0
  226. package/src/server/services/cacheService.test.ts +277 -0
  227. package/src/server/services/cacheService.ts +138 -0
  228. package/src/server/services/httpService.test.js +491 -0
  229. package/src/server/services/httpService.ts +50 -0
  230. package/src/server/services/index.ts +1 -0
  231. package/src/server/types.ts +54 -0
  232. package/src/server/utils/notify.test.ts +37 -0
  233. package/src/server/utils/notify.ts +50 -0
  234. package/src/server/utils/secure-context/get-trust-store-certs.js +11 -0
  235. package/src/server/utils/secure-context/get-trust-store-certs.test.js +19 -0
  236. package/src/server/utils/utils.js +24 -0
  237. package/src/server/utils/utils.test.js +54 -0
  238. package/src/server/views/404.html +16 -0
  239. package/src/server/views/500.html +19 -0
  240. package/src/server/views/help/accessibility-statement.html +58 -0
  241. package/src/server/views/help/cookie-preferences.html +57 -0
  242. package/src/server/views/help/cookies.html +71 -0
  243. package/src/server/views/help/get-support.html +37 -0
  244. package/src/server/views/help/privacy-notice.html +68 -0
  245. package/src/server/views/help/terms-and-conditions.html +83 -0
  246. package/src/typings/hapi/index.d.ts +87 -0
  247. package/src/typings/hapi-tracing/index.d.ts +6 -0
  248. package/src/typings/index.d.ts +3 -0
  249. package/src/typings/joi/index.d.ts +22 -0
  250. package/.server/server/views/components/service-banner/template.test.js.map +0 -1
  251. package/.server/server/views/components/tag-env/template.test.js.map +0 -1
  252. /package/.server/server/{views → plugins/engine/views}/components/debug/macro.njk +0 -0
  253. /package/.server/server/{views → plugins/engine/views}/components/debug/template.njk +0 -0
  254. /package/.server/server/{views → plugins/engine/views}/components/service-banner/macro.njk +0 -0
  255. /package/.server/server/{views → plugins/engine/views}/components/service-banner/template.njk +0 -0
  256. /package/.server/server/{views → plugins/engine/views}/components/tag-env/macro.njk +0 -0
  257. /package/.server/server/{views → plugins/engine/views}/components/tag-env/template.njk +0 -0
  258. /package/.server/server/{views → plugins/engine/views}/confirmation.html +0 -0
  259. /package/.server/server/{views → plugins/engine/views}/layout.html +0 -0
  260. /package/.server/server/{views → plugins/engine/views}/summary.html +0 -0
@@ -0,0 +1,1116 @@
1
+ /* eslint-disable @typescript-eslint/dot-notation */
2
+ import { ComponentType, type ComponentDef } from '@defra/forms-model'
3
+ import { type ResponseToolkit } from '@hapi/hapi'
4
+ import { type ValidationErrorItem, type ValidationResult } from 'joi'
5
+
6
+ import { tempItemSchema } from '~/src/server/plugins/engine/components/FileUploadField.js'
7
+ import {
8
+ getCacheService,
9
+ getError
10
+ } from '~/src/server/plugins/engine/helpers.js'
11
+ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
12
+ import {
13
+ FileUploadPageController,
14
+ prepareStatus
15
+ } from '~/src/server/plugins/engine/pageControllers/FileUploadPageController.js'
16
+ import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js'
17
+ import * as pageHelpers from '~/src/server/plugins/engine/pageControllers/helpers.js'
18
+ import * as uploadService from '~/src/server/plugins/engine/services/uploadService.js'
19
+ import {
20
+ FileStatus,
21
+ UploadStatus,
22
+ type FeaturedFormPageViewModel,
23
+ type FormContext,
24
+ type FormContextRequest,
25
+ type FormParams,
26
+ type FormSubmissionState,
27
+ type UploadStatusFileResponse,
28
+ type UploadStatusResponse
29
+ } from '~/src/server/plugins/engine/types.js'
30
+ import {
31
+ type FormRequest,
32
+ type FormRequestPayload
33
+ } from '~/src/server/routes/types.js'
34
+ import definition from '~/test/form/definitions/file-upload-basic.js'
35
+
36
+ type TestableFileUploadPageController = FileUploadPageController & {
37
+ initiateAndStoreNewUpload(
38
+ req: FormRequest,
39
+ state: FormSubmissionState
40
+ ): Promise<FormSubmissionState>
41
+ mergeState(
42
+ req: FormRequest,
43
+ state: FormSubmissionState,
44
+ merge: object
45
+ ): Promise<FormSubmissionState>
46
+ checkUploadStatus(
47
+ request: FormRequest,
48
+ state: FormSubmissionState,
49
+ depth?: number
50
+ ): Promise<FormSubmissionState>
51
+ prepareStatus(status: UploadStatusFileResponse): UploadStatusFileResponse
52
+ }
53
+
54
+ describe('FileUploadPageController', () => {
55
+ let model: FormModel
56
+ let controller: FileUploadPageController
57
+ let request: FormRequest
58
+
59
+ beforeEach(() => {
60
+ const { pages } = structuredClone(definition)
61
+
62
+ model = new FormModel(definition, {
63
+ basePath: 'test'
64
+ })
65
+
66
+ controller = new FileUploadPageController(model, pages[0])
67
+ request = {
68
+ logger: {
69
+ info: jest.fn(),
70
+ error: jest.fn(),
71
+ fatal: jest.fn(),
72
+ warn: jest.fn(),
73
+ debug: jest.fn(),
74
+ trace: jest.fn(),
75
+ level: 'info'
76
+ },
77
+ server: {
78
+ plugins: {
79
+ 'forms-engine-plugin': {
80
+ cacheService: {
81
+ setFlash: jest.fn(),
82
+ setState: jest
83
+ .fn()
84
+ .mockImplementation((req, updated) => Promise.resolve(updated))
85
+ }
86
+ }
87
+ }
88
+ },
89
+ query: {}
90
+ } as unknown as FormRequest
91
+ })
92
+
93
+ afterEach(() => {
94
+ jest.restoreAllMocks()
95
+ jest.clearAllMocks()
96
+ })
97
+
98
+ describe('Constructor', () => {
99
+ const textComponent: ComponentDef = {
100
+ name: 'fullName',
101
+ title: 'Full name',
102
+ type: ComponentType.TextField,
103
+ options: {},
104
+ schema: {}
105
+ }
106
+
107
+ it('throws unless there is exactly 1 file upload component', () => {
108
+ const { pages } = structuredClone(definition)
109
+
110
+ // @ts-expect-error - Allow invalid component for test
111
+ pages[0].components = [textComponent]
112
+
113
+ expect(() => new FileUploadPageController(model, pages[0])).toThrow(
114
+ `Expected 1 FileUploadFieldComponent in FileUploadPageController '${pages[0].path}'`
115
+ )
116
+ })
117
+
118
+ it('throws unless file upload component is the first in the form', () => {
119
+ const { pages } = structuredClone(definition)
120
+
121
+ // @ts-expect-error - Allow invalid component for test
122
+ pages[0].components.unshift(textComponent)
123
+
124
+ expect(() => new FileUploadPageController(model, pages[0])).toThrow(
125
+ `Expected 'fileUpload' to be the first form component in FileUploadPageController '${pages[0].path}'`
126
+ )
127
+ })
128
+ })
129
+
130
+ describe('Form validation', () => {
131
+ it('includes title text and error', () => {
132
+ const result = controller.collection.validate()
133
+
134
+ expect(result.errors).toEqual([
135
+ {
136
+ path: ['fileUpload'],
137
+ href: '#fileUpload',
138
+ name: 'fileUpload',
139
+ text: 'Select upload something',
140
+ context: {
141
+ key: 'fileUpload',
142
+ label: 'Upload something',
143
+ title: 'Upload something'
144
+ }
145
+ }
146
+ ])
147
+ })
148
+
149
+ it('includes all field errors', () => {
150
+ const result = controller.collection.validate()
151
+ expect(result.errors).toHaveLength(1)
152
+ })
153
+ })
154
+
155
+ describe('checkUploadStatus', () => {
156
+ describe('error handling', () => {
157
+ it('throws error when getUploadStatus returns empty response', async () => {
158
+ const state = {
159
+ upload: {
160
+ [controller.path]: {
161
+ upload: {
162
+ uploadId: 'some-id',
163
+ uploadUrl: 'some-url',
164
+ statusUrl: 'some-status-url'
165
+ },
166
+ files: []
167
+ }
168
+ }
169
+ } as unknown as FormSubmissionState
170
+
171
+ jest
172
+ .spyOn(uploadService, 'getUploadStatus')
173
+ .mockResolvedValue(undefined)
174
+
175
+ await expect(
176
+ controller['checkUploadStatus'](request, state, 1)
177
+ ).rejects.toThrow(
178
+ 'Unexpected empty response from getUploadStatus for some-id'
179
+ )
180
+ })
181
+
182
+ it('handles pending upload with backoff and retries', async () => {
183
+ const state = {
184
+ upload: {
185
+ [controller.path]: {
186
+ upload: {
187
+ uploadId: 'some-id',
188
+ uploadUrl: 'some-url',
189
+ statusUrl: 'some-status-url'
190
+ },
191
+ files: []
192
+ }
193
+ }
194
+ } as unknown as FormSubmissionState
195
+
196
+ const pendingStatus = {
197
+ uploadStatus: UploadStatus.pending,
198
+ form: { file: { fileStatus: FileStatus.complete } }
199
+ }
200
+
201
+ const getUploadStatusSpy = jest
202
+ .spyOn(uploadService, 'getUploadStatus')
203
+ .mockResolvedValueOnce(pendingStatus as UploadStatusResponse)
204
+ .mockResolvedValueOnce({
205
+ uploadStatus: UploadStatus.initiated
206
+ } as UploadStatusResponse)
207
+
208
+ await controller['checkUploadStatus'](request, state, 1)
209
+
210
+ expect(getUploadStatusSpy).toHaveBeenCalledTimes(2)
211
+ expect(request.logger.info).toHaveBeenCalled()
212
+
213
+ const logMsg = (request.logger.info as jest.Mock).mock.calls[0][0]
214
+ expect(logMsg).toEqual(expect.stringContaining('Waiting'))
215
+ expect(logMsg).toEqual(expect.stringContaining('some-id'))
216
+ }, 3000)
217
+
218
+ it('throws gateway timeout when maximum retry depth is exceeded, logs an error, and re-initiates a new upload', async () => {
219
+ const state = {
220
+ upload: {
221
+ [controller.path]: {
222
+ upload: {
223
+ uploadId: 'some-id',
224
+ uploadUrl: 'some-url',
225
+ statusUrl: 'some-status-url'
226
+ },
227
+ files: []
228
+ }
229
+ }
230
+ } as unknown as FormSubmissionState
231
+
232
+ const pendingStatus = {
233
+ uploadStatus: UploadStatus.pending,
234
+ form: { file: { fileStatus: FileStatus.pending } }
235
+ }
236
+
237
+ jest
238
+ .spyOn(uploadService, 'getUploadStatus')
239
+ .mockResolvedValue(pendingStatus as UploadStatusResponse)
240
+
241
+ const initiateSpy = jest
242
+ .spyOn(
243
+ controller as TestableFileUploadPageController,
244
+ 'initiateAndStoreNewUpload'
245
+ )
246
+ .mockResolvedValue(state as never)
247
+
248
+ await expect(
249
+ controller['checkUploadStatus'](request, state, 7)
250
+ ).rejects.toThrow(
251
+ 'Timed out waiting for some-id after cumulative retries exceeding 55 seconds'
252
+ )
253
+
254
+ expect(request.logger.error).toHaveBeenCalledWith(
255
+ expect.stringContaining(
256
+ 'Exceeded cumulative retry delay for some-id (depth: 7). Re-initiating a new upload.'
257
+ )
258
+ )
259
+
260
+ expect(initiateSpy).toHaveBeenCalledWith(request, state)
261
+ })
262
+
263
+ it('throws error when initiateUpload returns undefined', async () => {
264
+ const state = {
265
+ upload: {
266
+ [controller.path]: {
267
+ upload: {},
268
+ files: []
269
+ }
270
+ }
271
+ } as unknown as FormSubmissionState
272
+
273
+ jest.spyOn(uploadService, 'initiateUpload').mockResolvedValue(undefined)
274
+
275
+ await expect(
276
+ controller['checkUploadStatus'](request, state, 1)
277
+ ).rejects.toThrow('Unexpected empty response from initiateUpload')
278
+ })
279
+
280
+ it('handles pending file status with custom error message', async () => {
281
+ const state = {
282
+ upload: {
283
+ [controller.path]: {
284
+ upload: {
285
+ uploadId: 'some-id',
286
+ uploadUrl: 'some-url',
287
+ statusUrl: 'some-status-url'
288
+ },
289
+ files: []
290
+ }
291
+ }
292
+ } as unknown as FormSubmissionState
293
+
294
+ const pendingStatus = {
295
+ uploadStatus: UploadStatus.ready,
296
+ form: {
297
+ file: {
298
+ fileStatus: FileStatus.pending,
299
+ errorMessage: 'Custom error message'
300
+ }
301
+ }
302
+ }
303
+
304
+ jest
305
+ .spyOn(uploadService, 'getUploadStatus')
306
+ .mockResolvedValue(pendingStatus as UploadStatusResponse)
307
+
308
+ jest.spyOn(tempItemSchema, 'validate').mockReturnValue({
309
+ value: {
310
+ uploadId: 'some-id',
311
+ status: pendingStatus,
312
+ type: 'object.unknown',
313
+ path: ['fileUpload', 'errorMessage'],
314
+ context: { value: 'Custom error message' }
315
+ },
316
+ error: undefined
317
+ } as ValidationResult)
318
+
319
+ const testController = controller as TestableFileUploadPageController
320
+ const initiateSpy = jest.spyOn(
321
+ testController,
322
+ 'initiateAndStoreNewUpload'
323
+ )
324
+ initiateSpy.mockResolvedValue(state as never)
325
+
326
+ const cacheService = getCacheService(request.server)
327
+ await controller['checkUploadStatus'](request, state, 1)
328
+
329
+ expect(cacheService.setFlash).toHaveBeenCalledWith(request, {
330
+ errors: [
331
+ {
332
+ path: ['fileUpload'],
333
+ href: '#fileUpload',
334
+ name: 'fileUpload',
335
+ text: 'Custom error message'
336
+ }
337
+ ]
338
+ })
339
+ })
340
+ })
341
+
342
+ describe('state management', () => {
343
+ it('returns existing state when upload status is initiated', async () => {
344
+ const state = {
345
+ upload: {
346
+ [controller.path]: {
347
+ upload: {
348
+ uploadId: 'some-id',
349
+ uploadUrl: 'some-url',
350
+ statusUrl: 'some-status-url'
351
+ },
352
+ files: []
353
+ }
354
+ }
355
+ } as unknown as FormSubmissionState
356
+
357
+ jest.spyOn(uploadService, 'getUploadStatus').mockResolvedValue({
358
+ uploadStatus: UploadStatus.initiated
359
+ } as UploadStatusResponse)
360
+ const result = await controller['checkUploadStatus'](request, state, 1)
361
+ expect(result).toBe(state)
362
+ })
363
+
364
+ it('returns early when all files are updated', async () => {
365
+ const files = ['file1', 'file2']
366
+ const filesUpdated = [...files]
367
+ const state = {
368
+ upload: {
369
+ [controller.path]: {
370
+ upload: {
371
+ uploadId: 'some-id',
372
+ uploadUrl: 'some-url',
373
+ statusUrl: 'some-status-url'
374
+ },
375
+ files,
376
+ filesUpdated
377
+ }
378
+ }
379
+ } as unknown as FormSubmissionState
380
+
381
+ const readyStatus = {
382
+ uploadStatus: UploadStatus.ready,
383
+ form: { file: { fileStatus: FileStatus.complete } }
384
+ }
385
+
386
+ jest
387
+ .spyOn(uploadService, 'getUploadStatus')
388
+ .mockResolvedValue(readyStatus as UploadStatusResponse)
389
+
390
+ jest.spyOn(tempItemSchema, 'validate').mockReturnValue({
391
+ value: { status: readyStatus },
392
+ error: undefined
393
+ } as ValidationResult)
394
+
395
+ const testController = controller as TestableFileUploadPageController
396
+ const initiateSpy = jest.spyOn(
397
+ testController,
398
+ 'initiateAndStoreNewUpload'
399
+ ) as jest.SpyInstance<
400
+ Promise<FormSubmissionState>,
401
+ [FormRequest, FormSubmissionState]
402
+ >
403
+
404
+ initiateSpy.mockResolvedValue(state)
405
+
406
+ const result = await controller['checkUploadStatus'](request, state, 1)
407
+
408
+ expect(result).toBe(state)
409
+ })
410
+
411
+ it('initiates new upload when no upload exists', async () => {
412
+ const state = {
413
+ upload: {
414
+ [controller.path]: {
415
+ upload: {},
416
+ files: []
417
+ }
418
+ }
419
+ } as unknown as FormSubmissionState
420
+
421
+ const testController = controller as TestableFileUploadPageController
422
+
423
+ const initiateSpy = jest.spyOn(
424
+ testController,
425
+ 'initiateAndStoreNewUpload'
426
+ ) as jest.SpyInstance<
427
+ Promise<FormSubmissionState>,
428
+ [FormRequest, FormSubmissionState]
429
+ >
430
+
431
+ initiateSpy.mockImplementation(
432
+ (_req: FormRequest, s: FormSubmissionState) =>
433
+ Promise.resolve(Object.assign({}, s, { initiated: true }))
434
+ )
435
+
436
+ const result = await controller['checkUploadStatus'](request, state, 1)
437
+
438
+ expect(initiateSpy).toHaveBeenCalled()
439
+ expect(result.initiated).toBe(true)
440
+ })
441
+
442
+ it('initiates new upload when file validation fails', async () => {
443
+ const state = {
444
+ upload: {
445
+ [controller.path]: {
446
+ upload: {
447
+ uploadId: 'some-id',
448
+ uploadUrl: 'some-url',
449
+ statusUrl: 'some-status-url'
450
+ },
451
+ files: []
452
+ }
453
+ }
454
+ } as unknown as FormSubmissionState
455
+
456
+ jest.spyOn(uploadService, 'getUploadStatus').mockResolvedValue({
457
+ uploadStatus: UploadStatus.ready,
458
+ form: { file: { fileStatus: FileStatus.complete } }
459
+ } as UploadStatusResponse)
460
+
461
+ jest.spyOn(tempItemSchema, 'validate').mockReturnValue({
462
+ value: {},
463
+ error: new Error('Validation failed')
464
+ } as ValidationResult)
465
+
466
+ const testController = controller as TestableFileUploadPageController
467
+
468
+ const initiateSpy = jest.spyOn(
469
+ testController,
470
+ 'initiateAndStoreNewUpload'
471
+ ) as jest.SpyInstance<
472
+ Promise<FormSubmissionState>,
473
+ [FormRequest, FormSubmissionState]
474
+ >
475
+
476
+ initiateSpy.mockImplementation(
477
+ (
478
+ _req: FormRequest,
479
+ s: FormSubmissionState
480
+ ): Promise<FormSubmissionState> =>
481
+ Promise.resolve(Object.assign({}, s, { newUpload: true }))
482
+ )
483
+ const result = await controller['checkUploadStatus'](request, state, 1)
484
+
485
+ expect(initiateSpy).toHaveBeenCalled()
486
+ expect(result.newUpload).toBe(true)
487
+ })
488
+
489
+ it('merges state when file upload is complete', async () => {
490
+ const state = {
491
+ upload: {
492
+ [controller.path]: {
493
+ upload: {
494
+ uploadId: 'some-id',
495
+ uploadUrl: 'some-url',
496
+ statusUrl: 'some-status-url'
497
+ },
498
+ files: []
499
+ }
500
+ }
501
+ } as unknown as FormSubmissionState
502
+
503
+ const completeStatus = {
504
+ uploadStatus: UploadStatus.ready,
505
+ form: { file: { fileStatus: FileStatus.complete } }
506
+ }
507
+
508
+ jest
509
+ .spyOn(uploadService, 'getUploadStatus')
510
+ .mockResolvedValue(completeStatus as UploadStatusResponse)
511
+
512
+ jest.spyOn(tempItemSchema, 'validate').mockReturnValue({
513
+ value: {
514
+ status: completeStatus,
515
+ uploadId: 'some-id'
516
+ },
517
+ error: undefined
518
+ } as ValidationResult)
519
+
520
+ const testController = controller as TestableFileUploadPageController
521
+
522
+ const mergeStateSpy = jest.spyOn(
523
+ testController,
524
+ 'mergeState'
525
+ ) as jest.SpyInstance<
526
+ Promise<FormSubmissionState>,
527
+ [FormRequest, FormSubmissionState, object]
528
+ >
529
+
530
+ mergeStateSpy.mockImplementation(
531
+ (
532
+ _req: FormRequest,
533
+ s: FormSubmissionState,
534
+ _merge: object
535
+ ): Promise<FormSubmissionState> =>
536
+ Promise.resolve(Object.assign({}, s, { merged: true }))
537
+ )
538
+
539
+ const initiateSpy = jest.spyOn(
540
+ testController,
541
+ 'initiateAndStoreNewUpload'
542
+ ) as jest.SpyInstance<
543
+ Promise<FormSubmissionState>,
544
+ [FormRequest, FormSubmissionState]
545
+ >
546
+
547
+ initiateSpy.mockImplementation(
548
+ (
549
+ _req: FormRequest,
550
+ s: FormSubmissionState
551
+ ): Promise<FormSubmissionState> =>
552
+ Promise.resolve(Object.assign({}, s, { newUpload: true }))
553
+ )
554
+
555
+ const result = await controller['checkUploadStatus'](request, state, 1)
556
+
557
+ expect(mergeStateSpy).toHaveBeenCalled()
558
+ expect(result.newUpload).toBe(true)
559
+ })
560
+ })
561
+
562
+ describe('error messaging', () => {
563
+ describe('when file status is not complete', () => {
564
+ it('sets flash error with provided message', async () => {
565
+ const state = {
566
+ upload: {
567
+ [controller.path]: {
568
+ upload: {
569
+ uploadId: 'some-id',
570
+ uploadUrl: 'some-url',
571
+ statusUrl: 'some-status-url'
572
+ },
573
+ files: []
574
+ }
575
+ }
576
+ } as unknown as FormSubmissionState
577
+
578
+ const errorStatus = {
579
+ uploadStatus: UploadStatus.ready,
580
+ form: {
581
+ file: {
582
+ fileStatus: FileStatus.rejected,
583
+ errorMessage: 'Test error'
584
+ }
585
+ }
586
+ }
587
+
588
+ jest
589
+ .spyOn(uploadService, 'getUploadStatus')
590
+ .mockResolvedValue(errorStatus as UploadStatusResponse)
591
+
592
+ jest.spyOn(tempItemSchema, 'validate').mockReturnValue({
593
+ value: {
594
+ status: errorStatus,
595
+ uploadId: 'some-id'
596
+ },
597
+ error: undefined
598
+ } as ValidationResult)
599
+
600
+ const testController = controller as TestableFileUploadPageController
601
+
602
+ const initiateSpy = jest.spyOn(
603
+ testController,
604
+ 'initiateAndStoreNewUpload'
605
+ ) as jest.SpyInstance<
606
+ Promise<FormSubmissionState>,
607
+ [FormRequest, FormSubmissionState]
608
+ >
609
+
610
+ initiateSpy.mockImplementation(
611
+ (
612
+ _req: FormRequest,
613
+ s: FormSubmissionState
614
+ ): Promise<FormSubmissionState> =>
615
+ Promise.resolve(Object.assign({}, s, { newUpload: true }))
616
+ )
617
+
618
+ const cacheService = getCacheService(request.server)
619
+
620
+ await controller['checkUploadStatus'](request, state, 1)
621
+
622
+ expect(cacheService.setFlash).toHaveBeenCalledWith(request, {
623
+ errors: [
624
+ {
625
+ path: ['fileUpload'],
626
+ href: '#fileUpload',
627
+ name: 'fileUpload',
628
+ text: 'Test error'
629
+ }
630
+ ]
631
+ })
632
+ })
633
+ })
634
+
635
+ describe('when file has error status', () => {
636
+ it('sets flash error with error message', async () => {
637
+ const state = {
638
+ upload: {
639
+ [controller.path]: {
640
+ upload: {
641
+ uploadId: 'some-id',
642
+ uploadUrl: 'some-url',
643
+ statusUrl: 'some-status-url'
644
+ },
645
+ files: []
646
+ }
647
+ }
648
+ } as unknown as FormSubmissionState
649
+
650
+ const errorStatus = {
651
+ uploadStatus: UploadStatus.ready,
652
+ form: {
653
+ file: {
654
+ fileStatus: FileStatus.rejected,
655
+ errorMessage: 'Test error message'
656
+ }
657
+ }
658
+ }
659
+
660
+ jest
661
+ .spyOn(uploadService, 'getUploadStatus')
662
+ .mockResolvedValue(errorStatus as UploadStatusResponse)
663
+
664
+ jest.spyOn(tempItemSchema, 'validate').mockReturnValue({
665
+ value: { status: errorStatus },
666
+ error: undefined
667
+ } as ValidationResult)
668
+
669
+ const testController = controller as TestableFileUploadPageController
670
+
671
+ const initiateSpy = jest.spyOn(
672
+ testController,
673
+ 'initiateAndStoreNewUpload'
674
+ ) as jest.SpyInstance<
675
+ Promise<FormSubmissionState>,
676
+ [FormRequest, FormSubmissionState]
677
+ >
678
+
679
+ initiateSpy.mockResolvedValue(state)
680
+
681
+ const cacheService = getCacheService(request.server)
682
+ await controller['checkUploadStatus'](request, state, 1)
683
+
684
+ expect(cacheService.setFlash).toHaveBeenCalledWith(request, {
685
+ errors: [
686
+ {
687
+ path: ['fileUpload'],
688
+ href: '#fileUpload',
689
+ name: 'fileUpload',
690
+ text: 'Test error message'
691
+ }
692
+ ]
693
+ })
694
+ })
695
+
696
+ it('sets default error message when none provided', async () => {
697
+ const state = {
698
+ upload: {
699
+ [controller.path]: {
700
+ upload: {
701
+ uploadId: 'some-id',
702
+ uploadUrl: 'some-url',
703
+ statusUrl: 'some-status-url'
704
+ },
705
+ files: []
706
+ }
707
+ }
708
+ } as unknown as FormSubmissionState
709
+
710
+ const errorStatus = {
711
+ uploadStatus: UploadStatus.ready,
712
+ form: {
713
+ file: {
714
+ fileStatus: FileStatus.rejected
715
+ }
716
+ }
717
+ }
718
+
719
+ jest
720
+ .spyOn(uploadService, 'getUploadStatus')
721
+ .mockResolvedValue(errorStatus as UploadStatusResponse)
722
+
723
+ jest.spyOn(tempItemSchema, 'validate').mockReturnValue({
724
+ value: { status: errorStatus },
725
+ error: undefined
726
+ } as ValidationResult)
727
+
728
+ const testController = controller as TestableFileUploadPageController
729
+
730
+ const initiateSpy = jest.spyOn(
731
+ testController,
732
+ 'initiateAndStoreNewUpload'
733
+ ) as jest.SpyInstance<
734
+ Promise<FormSubmissionState>,
735
+ [FormRequest, FormSubmissionState]
736
+ >
737
+
738
+ initiateSpy.mockResolvedValue(state)
739
+
740
+ const cacheService = getCacheService(request.server)
741
+
742
+ await controller['checkUploadStatus'](request, state, 1)
743
+
744
+ expect(cacheService.setFlash).toHaveBeenCalledWith(request, {
745
+ errors: [
746
+ {
747
+ path: ['fileUpload'],
748
+ href: '#fileUpload',
749
+ name: 'fileUpload',
750
+ text: 'Unknown error'
751
+ }
752
+ ]
753
+ })
754
+ })
755
+ })
756
+ })
757
+
758
+ describe('file removal', () => {
759
+ it('returns early when no file is removed', async () => {
760
+ const files = [{ uploadId: 'file1' }, { uploadId: 'file2' }]
761
+
762
+ Object.defineProperty(request, 'params', {
763
+ value: { itemId: 'nonexistent-file' },
764
+ writable: true,
765
+ configurable: true
766
+ })
767
+
768
+ const state = {
769
+ upload: {
770
+ [controller.path]: {
771
+ upload: {
772
+ uploadId: 'upload-123',
773
+ uploadUrl: 'some-url',
774
+ statusUrl: 'some-status-url'
775
+ },
776
+ files
777
+ }
778
+ }
779
+ } as unknown as FormSubmissionState
780
+
781
+ const testController = controller as TestableFileUploadPageController
782
+ const mergeStateSpy = jest.spyOn(testController, 'mergeState')
783
+
784
+ await controller['checkRemovedFiles'](
785
+ request as FormRequestPayload,
786
+ state
787
+ )
788
+
789
+ expect(mergeStateSpy).not.toHaveBeenCalled()
790
+ })
791
+
792
+ it('merges state when file is removed', async () => {
793
+ const files = [{ uploadId: 'file1' }, { uploadId: 'file2' }]
794
+
795
+ Object.defineProperty(request, 'params', {
796
+ value: { itemId: 'file1' },
797
+ writable: true,
798
+ configurable: true
799
+ })
800
+
801
+ const state = {
802
+ upload: {
803
+ [controller.path]: {
804
+ upload: {
805
+ uploadId: 'upload-123',
806
+ uploadUrl: 'some-url',
807
+ statusUrl: 'some-status-url'
808
+ },
809
+ files
810
+ }
811
+ }
812
+ } as unknown as FormSubmissionState
813
+
814
+ const testController = controller as TestableFileUploadPageController
815
+ const mergeStateSpy = jest.spyOn(testController, 'mergeState')
816
+
817
+ await controller['checkRemovedFiles'](
818
+ request as FormRequestPayload,
819
+ state
820
+ )
821
+
822
+ expect(mergeStateSpy).toHaveBeenCalledWith(request, state, {
823
+ upload: {
824
+ [controller.path]: {
825
+ files: [{ uploadId: 'file2' }],
826
+ upload: {
827
+ uploadId: 'upload-123',
828
+ uploadUrl: 'some-url',
829
+ statusUrl: 'some-status-url'
830
+ }
831
+ }
832
+ }
833
+ })
834
+ })
835
+ })
836
+ })
837
+
838
+ describe('prepareStatus', () => {
839
+ describe('when file is pending', () => {
840
+ it('adds error message when no error message exists', () => {
841
+ const status = {
842
+ form: {
843
+ file: {
844
+ fileStatus: FileStatus.pending,
845
+ errorMessage: undefined
846
+ }
847
+ }
848
+ } as UploadStatusFileResponse
849
+
850
+ const result = prepareStatus(status)
851
+
852
+ expect(result.form.file.errorMessage).toBe(
853
+ 'The selected file has not fully uploaded'
854
+ )
855
+ })
856
+
857
+ it('preserves existing error message', () => {
858
+ const existingError = 'Existing error message'
859
+ const status = {
860
+ form: {
861
+ file: {
862
+ fileStatus: FileStatus.pending,
863
+ errorMessage: existingError
864
+ }
865
+ }
866
+ } as UploadStatusFileResponse
867
+
868
+ const result = prepareStatus(status)
869
+
870
+ expect(result.form.file.errorMessage).toBe(existingError)
871
+ })
872
+ })
873
+
874
+ describe('when file is not pending', () => {
875
+ it('does not add error message', () => {
876
+ const status = {
877
+ form: {
878
+ file: {
879
+ fileStatus: FileStatus.complete,
880
+ errorMessage: undefined
881
+ }
882
+ }
883
+ } as UploadStatusFileResponse
884
+
885
+ const result = prepareStatus(status)
886
+
887
+ expect(result.form.file.errorMessage).toBeUndefined()
888
+ })
889
+ })
890
+ })
891
+
892
+ describe('getErrors', () => {
893
+ let controller: FileUploadPageController
894
+
895
+ beforeEach(() => {
896
+ const { pages } = structuredClone(definition)
897
+ const model = new FormModel(definition, { basePath: 'test' })
898
+ controller = new FileUploadPageController(model, pages[0])
899
+ })
900
+
901
+ describe('when no details provided', () => {
902
+ it('returns undefined', () => {
903
+ const errors = controller.getErrors()
904
+ expect(errors).toBeUndefined()
905
+ })
906
+ })
907
+
908
+ describe('error handling', () => {
909
+ it('handles non-upload errors using getError helper', () => {
910
+ const errorDetail = {
911
+ message: 'some error',
912
+ path: ['otherField'],
913
+ type: 'any.required'
914
+ }
915
+ const errors = controller.getErrors([errorDetail])
916
+ expect(errors).toEqual([getError(errorDetail)])
917
+ })
918
+
919
+ it('handles upload root errors using getError helper', () => {
920
+ const errorDetail = {
921
+ message: 'some error',
922
+ path: ['fileUpload'],
923
+ type: 'any.required'
924
+ }
925
+ const errors = controller.getErrors([errorDetail])
926
+ expect(errors).toEqual([getError(errorDetail)])
927
+ })
928
+ })
929
+
930
+ describe('object.unknown type errors', () => {
931
+ it('pushes an error with errorMessage', () => {
932
+ const errorDetail = {
933
+ message: 'some error',
934
+ path: ['fileUpload', 'errorMessage'],
935
+ type: 'object.unknown',
936
+ context: { value: 'some error text' }
937
+ }
938
+ const errors = controller.getErrors([errorDetail])
939
+ expect(errors).toEqual([
940
+ {
941
+ path: ['fileUpload', 'errorMessage'],
942
+ href: '#fileUpload',
943
+ name: 'fileUpload',
944
+ text: 'some error text'
945
+ }
946
+ ])
947
+ })
948
+
949
+ it('handles non-string error message values with default text', () => {
950
+ const errorDetail = {
951
+ message: 'some error',
952
+ path: ['fileUpload', 'errorMessage'],
953
+ type: 'object.unknown',
954
+ context: { value: { some: 'object' } }
955
+ }
956
+ const errors = controller.getErrors([errorDetail])
957
+ expect(errors).toEqual([
958
+ {
959
+ path: ['fileUpload', 'errorMessage'],
960
+ href: '#fileUpload',
961
+ name: 'fileUpload',
962
+ text: 'Unknown error'
963
+ }
964
+ ])
965
+ })
966
+
967
+ it('handles object.unknown error type with errorMessage path', () => {
968
+ const details = [
969
+ {
970
+ type: 'object.unknown',
971
+ path: ['fileUpload', 'errorMessage'],
972
+ context: { value: 'Custom error message' }
973
+ }
974
+ ] as ValidationErrorItem[]
975
+
976
+ const errors = controller.getErrors(details)
977
+
978
+ expect(errors).toEqual([
979
+ {
980
+ path: ['fileUpload', 'errorMessage'],
981
+ href: '#fileUpload',
982
+ name: 'fileUpload',
983
+ text: 'Custom error message'
984
+ }
985
+ ])
986
+ })
987
+ })
988
+ })
989
+
990
+ describe('initiateAndStoreNewUpload', () => {
991
+ it('throws error when initiateUpload returns undefined', async () => {
992
+ const state = {
993
+ upload: {
994
+ '/test/file-upload': {
995
+ upload: {},
996
+ files: []
997
+ }
998
+ }
999
+ } as unknown as FormSubmissionState
1000
+
1001
+ jest.spyOn(uploadService, 'initiateUpload').mockResolvedValue(undefined)
1002
+
1003
+ await expect(
1004
+ (
1005
+ controller['initiateAndStoreNewUpload'] as (
1006
+ req: FormRequest,
1007
+ state: FormSubmissionState
1008
+ ) => Promise<FormSubmissionState>
1009
+ )(request, state)
1010
+ ).rejects.toThrow('Unexpected empty response from initiateUpload')
1011
+ })
1012
+ })
1013
+
1014
+ describe('makeGetItemDeleteRouteHandler', () => {
1015
+ it('throws notFound error when file to delete does not exist', () => {
1016
+ const state = {
1017
+ upload: {
1018
+ [controller.path]: {
1019
+ files: [
1020
+ {
1021
+ uploadId: 'file-1',
1022
+ status: { form: { file: { filename: 'file-1.pdf' } } }
1023
+ },
1024
+ {
1025
+ uploadId: 'file-2',
1026
+ status: { form: { file: { filename: 'file-2.pdf' } } }
1027
+ }
1028
+ ]
1029
+ }
1030
+ }
1031
+ }
1032
+
1033
+ const request = {
1034
+ params: { itemId: 'I do not exist' }
1035
+ } as unknown as FormRequest
1036
+
1037
+ const context = { state } as unknown as FormContext
1038
+ const h = {} as unknown as Pick<ResponseToolkit, 'redirect' | 'view'>
1039
+
1040
+ const handler = controller.makeGetItemDeleteRouteHandler()
1041
+
1042
+ expect(() => handler(request, context, h)).toThrow(
1043
+ 'File to delete not found'
1044
+ )
1045
+ })
1046
+ })
1047
+
1048
+ describe('makePostItemDeleteRouteHandler', () => {
1049
+ it('proceeds without deleting when confirm is false', async () => {
1050
+ const request = {
1051
+ params: { itemId: 'file-1' }
1052
+ } as unknown as FormRequestPayload
1053
+
1054
+ const h = {
1055
+ redirect: jest.fn()
1056
+ } as unknown as Pick<ResponseToolkit, 'redirect' | 'view'>
1057
+
1058
+ const context = {
1059
+ state: {}
1060
+ } as unknown as FormContext
1061
+
1062
+ jest
1063
+ .spyOn(controller, 'getFormParams')
1064
+ .mockReturnValue({ confirm: false } as unknown as FormParams)
1065
+
1066
+ const proceedSpy = jest
1067
+ .spyOn(controller, 'proceed')
1068
+ .mockResolvedValue({ statusCode: 302 } as never)
1069
+
1070
+ const handler = controller.makePostItemDeleteRouteHandler()
1071
+ await handler(request, context, h)
1072
+
1073
+ expect(proceedSpy).toHaveBeenCalledWith(request, h)
1074
+ })
1075
+ })
1076
+
1077
+ describe('getViewModel', () => {
1078
+ it('includes uploadId and proxyUrl in the view model', () => {
1079
+ const state = {
1080
+ upload: {
1081
+ [controller.path]: {
1082
+ upload: {
1083
+ uploadId: 'some-upload-id',
1084
+ uploadUrl: 'https://cdp-upload-and-scan.com/upload',
1085
+ statusUrl: 'https://cdp-upload-and-scan.com/status'
1086
+ },
1087
+ files: []
1088
+ }
1089
+ }
1090
+ } as unknown as FormSubmissionState
1091
+
1092
+ const context = { state } as FormContext
1093
+
1094
+ jest
1095
+ .spyOn(QuestionPageController.prototype, 'getViewModel')
1096
+ .mockReturnValue({
1097
+ components: [{ model: { id: 'fileUpload' } }]
1098
+ } as unknown as FeaturedFormPageViewModel)
1099
+
1100
+ jest
1101
+ .spyOn(pageHelpers, 'getProxyUrlForLocalDevelopment')
1102
+ .mockReturnValue('http://uploader.127.0.0.1.sslip.io:7300')
1103
+
1104
+ const viewModel = controller.getViewModel(
1105
+ request as FormContextRequest,
1106
+ context
1107
+ )
1108
+
1109
+ expect(viewModel.uploadId).toBe('some-upload-id')
1110
+ expect(viewModel.proxyUrl).toBe('http://uploader.127.0.0.1.sslip.io:7300')
1111
+ expect(viewModel.formAction).toBe(
1112
+ 'https://cdp-upload-and-scan.com/upload'
1113
+ )
1114
+ })
1115
+ })
1116
+ })