@nutin/cli 2.1.1 → 3.0.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.
Files changed (229) hide show
  1. package/CHANGELOG.md +268 -0
  2. package/README.md +13 -5
  3. package/bin/add-feature.mjs +1 -1
  4. package/bin/cli.mjs +1 -1
  5. package/bin/create-app.mjs +1 -1
  6. package/bin/update-app.mjs +1 -1
  7. package/lib/index.mjs +6 -2
  8. package/lib/src/add-feature/base-template-patcher.mjs +139 -0
  9. package/lib/src/add-feature/feature-adder.mjs +20 -9
  10. package/lib/src/add-feature/feature-context-builder.mjs +1 -0
  11. package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
  12. package/lib/src/add-feature/package-json-updater.mjs +7 -9
  13. package/lib/src/common/feature-registry.mjs +4 -0
  14. package/lib/src/common/file-generator.mjs +25 -3
  15. package/lib/src/common/git.mjs +31 -0
  16. package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
  17. package/lib/src/common/package-json-helper.mjs +14 -4
  18. package/lib/src/common/project-meta.mjs +2 -2
  19. package/lib/src/common/template-compiler.mjs +2 -0
  20. package/lib/src/common/transpile-ts.mjs +94 -0
  21. package/lib/src/create-app/context-builder.mjs +1 -0
  22. package/lib/src/create-app/git-manager.mjs +12 -2
  23. package/lib/src/create-app/json-manager.mjs +21 -18
  24. package/lib/src/create-app/new-app-prompt.mjs +4 -2
  25. package/lib/src/create-app/success-message.mjs +1 -1
  26. package/lib/src/update-app/conflict-report-writer.mjs +33 -21
  27. package/lib/src/update-app/json-drift.mjs +54 -0
  28. package/lib/src/update-app/project-updater.mjs +123 -14
  29. package/lib/src/update-app/template-differ.mjs +5 -5
  30. package/lib/src/update-app/three-way-merge.mjs +42 -0
  31. package/lib/src/update-app/update-context-builder.mjs +1 -0
  32. package/lib/src/update-app/update-summary-printer.mjs +10 -2
  33. package/package.json +9 -8
  34. package/templates/base/AGENTS.md.hbs +42 -41
  35. package/templates/base/GETTING_STARTED.md.hbs +26 -20
  36. package/templates/base/README.md.hbs +7 -4
  37. package/templates/base/src/app/globals.d.ts.hbs +6 -3
  38. package/templates/base/src/app/guards.ts.hbs +1 -3
  39. package/templates/base/src/app/main.ts.hbs +2 -9
  40. package/templates/base/src/app/routes.ts.hbs +7 -2
  41. package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
  42. package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
  43. package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
  44. package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
  45. package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
  46. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
  47. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
  48. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +286 -24
  49. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
  50. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +66 -4
  51. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +135 -75
  52. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
  53. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +93 -54
  54. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
  55. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
  56. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
  57. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
  58. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
  59. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
  60. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
  61. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
  62. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
  63. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
  64. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
  65. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
  66. package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
  67. package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
  68. package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
  69. package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
  70. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
  71. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
  72. package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
  73. package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
  74. package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
  75. package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
  76. package/templates/base/src/core/index.ts.hbs +4 -2
  77. package/templates/base/src/core/internals.d.ts.hbs +199 -20
  78. package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
  79. package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
  80. package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
  81. package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
  82. package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
  83. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
  84. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
  85. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
  86. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
  87. package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
  88. package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
  89. package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
  90. package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
  91. package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
  92. package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
  93. package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
  94. package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
  95. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +183 -4
  96. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +207 -84
  97. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
  98. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
  99. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
  100. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
  101. package/templates/base/src/core/services/router/router.test.js.hbs +306 -0
  102. package/templates/base/src/core/services/router/router.ts.hbs +113 -53
  103. package/templates/base/tools/builder/builder.config.js.hbs +6 -0
  104. package/templates/base/tools/builder/builder.js.hbs +18 -6
  105. package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
  106. package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
  107. package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
  108. package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
  109. package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
  110. package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
  111. package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
  112. package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
  113. package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
  114. package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
  115. package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
  116. package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
  117. package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
  118. package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
  119. package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
  120. package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
  121. package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
  122. package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
  123. package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
  124. package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
  125. package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
  126. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +15 -7
  127. package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
  128. package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
  129. package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
  130. package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
  131. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +43 -13
  132. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
  133. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
  134. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +28 -21
  135. package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
  136. package/templates/base/tools/builder/core/seo/route-output.js.hbs +24 -6
  137. package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
  138. package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
  139. package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
  140. package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
  141. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
  142. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
  143. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +24 -11
  144. package/templates/base/tools/builder/core/styles/sass.js.hbs +3 -2
  145. package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
  146. package/templates/base/tools/dev/dev-serve.js.hbs +30 -5
  147. package/templates/base/tools/dev/serve.js.hbs +152 -22
  148. package/templates/base/tools/dev/watcher.js.hbs +34 -3
  149. package/templates/base/tools/generator/generator.js.hbs +1 -1
  150. package/templates/base/tools/generator/handle-file.js.hbs +4 -2
  151. package/templates/base/tools/generator/handle-file.test.js.hbs +3 -3
  152. package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
  153. package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
  154. package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
  155. package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
  156. package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
  157. package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
  158. package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
  159. package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
  160. package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
  161. package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
  162. package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
  163. package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
  164. package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
  165. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
  166. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
  167. package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
  168. package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
  169. package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
  170. package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
  171. package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
  172. package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
  173. package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
  174. package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
  175. package/templates/base/tools/utils/build-lock.js.hbs +30 -2
  176. package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
  177. package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
  178. package/templates/base/tools/utils/format.js.hbs +1 -1
  179. package/templates/base/tools/utils/index.js.hbs +2 -1
  180. package/templates/base/tools/utils/print.js.hbs +2 -2
  181. package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
  182. package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
  183. package/templates/base/tools/utils/run-command.js.hbs +30 -1
  184. package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
  185. package/templates/base/tools/utils/run-script.js.hbs +2 -2
  186. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +3 -0
  187. package/templates/features/docker/tools/docker/nginx.conf.template +6 -4
  188. package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
  189. package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
  190. package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
  191. package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
  192. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
  193. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
  194. package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
  195. package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
  196. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +21 -4
  197. package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
  198. package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
  199. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
  200. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
  201. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
  202. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
  203. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
  204. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
  205. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
  206. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
  207. package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
  208. package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
  209. package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
  210. package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
  211. package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
  212. package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
  213. package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
  214. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
  215. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
  216. package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
  217. package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
  218. package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
  219. package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
  220. package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
  221. package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
  222. package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
  223. package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
  224. package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
  225. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
  226. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
  227. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
  228. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
  229. /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
@@ -1,99 +1,84 @@
1
- import { HttpClient } from '#root/dist/src/core/services/index.js';
1
+ import { HttpClient, HttpError, path } from '#root/dist/src/core/services/index.js';
2
2
 
3
+ // Records fetch calls and answers with real Response objects. A handler can return a
4
+ // Response, or a promise; requests honour their AbortSignal like the real fetch.
3
5
  class FetchMock {
4
6
  constructor() {
5
7
  this.originalFetch = global.fetch;
6
- this.mockResponses = new Map();
8
+ this.handlers = new Map();
7
9
  this.calls = [];
8
10
  }
9
11
 
10
- mockResponse(url, response) {
11
- this.mockResponses.set(url, response);
12
+ on(url, handler) {
13
+ this.handlers.set(url, handler);
12
14
  return this;
13
15
  }
14
16
 
15
- mockJsonResponse(url, data, status = 200) {
16
- this.mockResponses.set(url, {
17
- ok: status >= 200 && status < 300,
18
- status,
19
- statusText: status === 200 ? 'OK' : 'Error',
20
- headers: {
21
- get: (key) => key === 'content-type' ? 'application/json' : null
22
- },
23
- json: () => Promise.resolve(data),
24
- text: () => Promise.resolve(JSON.stringify(data))
25
- });
26
- return this;
27
- }
28
-
29
- mockTextResponse(url, text, status = 200) {
30
- this.mockResponses.set(url, {
31
- ok: status >= 200 && status < 300,
32
- status,
33
- statusText: status === 200 ? 'OK' : 'Error',
34
- headers: {
35
- get: (key) => key === 'content-type' ? 'text/plain' : null
36
- },
37
- json: () => Promise.reject(new Error('Not JSON')),
38
- text: () => Promise.resolve(text)
39
- });
40
- return this;
17
+ json(url, data, status = 200, contentType = 'application/json') {
18
+ return this.on(url, () => new Response(JSON.stringify(data), { status, headers: { 'content-type': contentType } }));
41
19
  }
42
20
 
43
- mockError(url, status = 500, errorData = null) {
44
- this.mockResponses.set(url, {
45
- ok: false,
46
- status,
47
- statusText: 'Internal Server Error',
48
- headers: {
49
- get: (key) => key === 'content-type' ? 'application/json' : null
50
- },
51
- json: () => Promise.resolve(errorData),
52
- text: () => Promise.resolve(JSON.stringify(errorData))
53
- });
54
- return this;
21
+ text(url, text, status = 200) {
22
+ return this.on(url, () => new Response(text, { status, headers: { 'content-type': 'text/plain' } }));
55
23
  }
56
24
 
57
25
  install() {
58
- global.fetch = async (url, options) => {
26
+ global.fetch = (url, options = {}) => {
59
27
  this.calls.push({ url, options });
60
-
61
- const urlKey = url.toString();
62
- if (this.mockResponses.has(urlKey)) {
63
- return this.mockResponses.get(urlKey);
64
- }
65
-
66
- // Default response if not mocked
67
- return {
68
- ok: true,
69
- status: 200,
70
- statusText: 'OK',
71
- headers: {
72
- get: () => 'application/json'
73
- },
74
- json: () => Promise.resolve({}),
75
- text: () => Promise.resolve('{}')
76
- };
28
+ const handler = this.handlers.get(String(url));
29
+ const result = handler ? handler(options) : new Response('{}', { headers: { 'content-type': 'application/json' } });
30
+ return new Promise((resolve, reject) => {
31
+ const signal = options.signal;
32
+ if (signal) {
33
+ if (signal.aborted) return reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
34
+ signal.addEventListener('abort', () => reject(signal.reason ?? new DOMException('Aborted', 'AbortError')), { once: true });
35
+ }
36
+ Promise.resolve(result).then(resolve, reject);
37
+ });
77
38
  };
78
39
  return this;
79
40
  }
80
41
 
81
42
  restore() {
82
43
  global.fetch = this.originalFetch;
83
- this.mockResponses.clear();
44
+ this.handlers.clear();
84
45
  this.calls = [];
85
46
  }
86
47
 
87
- getLastCall() {
48
+ last() {
88
49
  return this.calls[this.calls.length - 1];
89
50
  }
51
+ }
52
+
53
+ const fetchMock = new FetchMock();
90
54
 
91
- getCalls() {
92
- return this.calls;
55
+ // Same-origin as the test page (jsdom: http://localhost) unless a test says otherwise.
56
+ function clientClass(baseUrl = '', defaultHeaders = {}, options = {}) {
57
+ return class extends HttpClient {
58
+ constructor() {
59
+ super(baseUrl, defaultHeaders, options);
60
+ }
61
+ };
62
+ }
63
+
64
+ async function errorOf(promise) {
65
+ try {
66
+ await promise;
67
+ } catch (error) {
68
+ return error;
93
69
  }
70
+ return null;
94
71
  }
95
72
 
96
- const fetchMock = new FetchMock();
73
+ const header = (call, name) => new Headers(call.options.headers).get(name);
74
+
75
+ // A Response as fetch returns it after following a redirect (redirected/url can't be set via the constructor).
76
+ function redirectedTo(finalUrl, data) {
77
+ const response = new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } });
78
+ Object.defineProperty(response, 'redirected', { value: true });
79
+ Object.defineProperty(response, 'url', { value: finalUrl });
80
+ return response;
81
+ }
97
82
 
98
83
  describe('HttpClient', () => {
99
84
  const httpClient = HttpClient.getInstance();
@@ -108,232 +93,354 @@ describe('HttpClient', () => {
108
93
  });
109
94
 
110
95
  it('should create a singleton instance', () => {
111
- const instance1 = HttpClient.getInstance();
112
- const instance2 = HttpClient.getInstance();
113
-
114
- expect(instance1).toBe(instance2);
96
+ expect(HttpClient.getInstance()).toBe(HttpClient.getInstance());
115
97
  });
116
98
 
117
- it('should initialize with default headers', () => {
118
- expect(httpClient._defaultHeaders['Content-Type']).toBe('application/json');
99
+ it('should start without default headers', () => {
100
+ expect(Object.keys(httpClient._defaultHeaders).length).toBe(0);
119
101
  });
120
102
 
121
- it('should make GET request successfully', async () => {
122
- const testData = { id: 1, name: 'test' };
123
- fetchMock.mockJsonResponse('https://api.example.com/users', testData);
124
-
103
+ it('should make a GET request and parse the JSON response', async () => {
104
+ fetchMock.json('https://api.example.com/users', { id: 1 });
105
+
125
106
  const result = await httpClient.get('https://api.example.com/users');
126
-
127
- expect(result).toBe(testData);
128
-
129
- const lastCall = fetchMock.getLastCall();
130
- expect(lastCall.options.method).toBe('GET');
131
- });
132
-
133
- it('should make POST request with data', async () => {
134
- const requestData = { name: 'John', email: 'john@example.com' };
135
- const responseData = { id: 1, ...requestData };
136
-
137
- fetchMock.mockJsonResponse('https://api.example.com/users', responseData);
138
-
139
- const result = await httpClient.post('https://api.example.com/users', requestData);
140
-
141
- expect(result).toBe(responseData);
142
-
143
- const lastCall = fetchMock.getLastCall();
144
- expect(lastCall.options.method).toBe('POST');
145
- expect(lastCall.options.body).toBe(JSON.stringify(requestData));
146
- });
147
-
148
- it('should make PUT request with data', async () => {
149
- const requestData = { id: 1, name: 'Updated Name' };
150
-
151
- fetchMock.mockJsonResponse('https://api.example.com/users/1', requestData);
152
-
153
- const result = await httpClient.put('https://api.example.com/users/1', requestData);
154
-
155
- expect(result).toBe(requestData);
156
-
157
- const lastCall = fetchMock.getLastCall();
158
- expect(lastCall.options.method).toBe('PUT');
159
- });
160
-
161
- it('should make PATCH request with data', async () => {
162
- const requestData = { name: 'Patched Name' };
163
- const responseData = { id: 1, name: 'Patched Name', email: 'john@example.com' };
164
-
165
- fetchMock.mockJsonResponse('https://api.example.com/users/1', responseData);
166
-
167
- const result = await httpClient.patch('https://api.example.com/users/1', requestData);
168
-
169
- expect(result).toBe(responseData);
170
-
171
- const lastCall = fetchMock.getLastCall();
172
- expect(lastCall.options.method).toBe('PATCH');
173
- });
174
-
175
- it('should make DELETE request', async () => {
176
- fetchMock.mockJsonResponse('https://api.example.com/users/1', { success: true });
177
-
107
+
108
+ expect(result).toEqual({ id: 1 });
109
+ expect(fetchMock.last().options.method).toBe('GET');
110
+ expect(fetchMock.last().options.body).toBeUndefined();
111
+ });
112
+
113
+ it('should send POST, PUT and PATCH data as JSON with a JSON content type', async () => {
114
+ for (const method of ['post', 'put', 'patch']) {
115
+ await httpClient[method]('https://api.example.com/users', { name: 'Ada' });
116
+
117
+ expect(fetchMock.last().options.method).toBe(method.toUpperCase());
118
+ expect(fetchMock.last().options.body).toBe('{"name":"Ada"}');
119
+ expect(header(fetchMock.last(), 'content-type')).toBe('application/json');
120
+ }
121
+ });
122
+
123
+ it('should make a DELETE request without a body', async () => {
124
+ fetchMock.json('https://api.example.com/users/1', { success: true });
125
+
178
126
  const result = await httpClient.delete('https://api.example.com/users/1');
179
-
180
- expect(result).toBeDefined();
127
+
181
128
  expect(result.success).toBe(true);
182
-
183
- const lastCall = fetchMock.getLastCall();
184
- expect(lastCall.options.method).toBe('DELETE');
185
- expect(lastCall.options.body).toBeUndefined();
129
+ expect(fetchMock.last().options.method).toBe('DELETE');
130
+ expect(fetchMock.last().options.body).toBeUndefined();
186
131
  });
187
132
 
188
- it('should handle query parameters', async () => {
189
- fetchMock.mockJsonResponse('https://api.example.com/users?page=1&limit=10', []);
190
-
191
- await httpClient.get('https://api.example.com/users', { queryParams: { page: '1', limit: '10' } });
192
-
193
- const lastCall = fetchMock.getLastCall();
194
- expect(lastCall.url).toContain('page=1');
195
- expect(lastCall.url).toContain('limit=10');
133
+ it('should not send a content type on requests without a body', async () => {
134
+ await httpClient.get('https://api.example.com/users');
135
+ expect(header(fetchMock.last(), 'content-type')).toBe(null);
136
+
137
+ await httpClient.delete('https://api.example.com/users/1');
138
+ expect(header(fetchMock.last(), 'content-type')).toBe(null);
139
+ });
140
+
141
+ it('should keep falsy JSON bodies', async () => {
142
+ for (const data of [0, false, '']) {
143
+ await httpClient.post('https://api.example.com/flags', data);
144
+ expect(fetchMock.last().options.body).toBe(JSON.stringify(data));
145
+ }
196
146
  });
197
147
 
198
- it('should handle custom headers', async () => {
199
- fetchMock.mockJsonResponse('https://api.example.com/users', []);
148
+ it('should send FormData, Blob, URLSearchParams and binary bodies as they are, without a JSON content type', async () => {
149
+ const form = new FormData();
150
+ form.append('name', 'Ada');
151
+ const bodies = [form, new Blob(['x']), new URLSearchParams({ a: '1' }), new ArrayBuffer(2), new Uint8Array(2)];
200
152
 
201
- await httpClient.get('https://api.example.com/users', { headers: { 'Authorization': 'Bearer custom-token' } });
153
+ for (const body of bodies) {
154
+ await httpClient.post('https://api.example.com/upload', body);
155
+ expect(fetchMock.last().options.body).toBe(body);
156
+ expect(header(fetchMock.last(), 'content-type')).toBe(null);
157
+ }
158
+ });
202
159
 
203
- const lastCall = fetchMock.getLastCall();
204
- expect(lastCall.options.headers['Authorization']).toBe('Bearer custom-token');
160
+ it('should let an explicit content type win over the JSON default', async () => {
161
+ await httpClient.post('https://api.example.com/users', { a: 1 }, { headers: { 'Content-Type': 'application/merge-patch+json' } });
162
+
163
+ expect(header(fetchMock.last(), 'content-type')).toBe('application/merge-patch+json');
205
164
  });
206
165
 
207
- it('should send the constructor default headers on a request with no custom headers', async () => {
208
- fetchMock.mockJsonResponse('https://api.example.com/users', []);
166
+ it('should merge default and per-call headers case-insensitively, per-call winning', async () => {
167
+ const Api = clientClass('', { 'X-Client': 'nutin', Accept: 'application/json' });
209
168
 
210
- await httpClient.get('https://api.example.com/users');
169
+ await Api.getInstance().get('/users', { headers: { accept: 'text/plain', 'X-Trace': '1' } });
211
170
 
212
- const lastCall = fetchMock.getLastCall();
213
- expect(lastCall.options.headers['Content-Type']).toBe('application/json');
171
+ expect(header(fetchMock.last(), 'x-client')).toBe('nutin');
172
+ expect(header(fetchMock.last(), 'accept')).toBe('text/plain');
173
+ expect(header(fetchMock.last(), 'x-trace')).toBe('1');
214
174
  });
215
175
 
216
- it('should merge default headers with per-call headers on different keys', async () => {
217
- fetchMock.mockJsonResponse('https://api.example.com/users', []);
176
+ it('should resolve a relative endpoint against the page origin when there is no base URL', async () => {
177
+ await httpClient.get('/api/users');
178
+ expect(fetchMock.last().url).toBe('http://localhost/api/users');
218
179
 
219
- await httpClient.get('https://api.example.com/users', { headers: { 'Authorization': 'Bearer custom-token' } });
180
+ await httpClient.get('api/users');
181
+ expect(fetchMock.last().url).toBe('http://localhost/api/users');
182
+ });
183
+
184
+ it('should send default headers only to the page origin when there is no base URL', async () => {
185
+ const Api = clientClass('', { Authorization: 'Bearer t' });
186
+ const api = Api.getInstance();
187
+
188
+ await api.get('/me');
189
+ expect(header(fetchMock.last(), 'authorization')).toBe('Bearer t');
190
+
191
+ await api.get('https://third-party.example/data');
192
+ expect(header(fetchMock.last(), 'authorization')).toBe(null);
220
193
 
221
- const lastCall = fetchMock.getLastCall();
222
- expect(lastCall.options.headers['Content-Type']).toBe('application/json');
223
- expect(lastCall.options.headers['Authorization']).toBe('Bearer custom-token');
194
+ await api.get('https://third-party.example/data', { headers: { Authorization: 'Bearer explicit' } });
195
+ expect(header(fetchMock.last(), 'authorization')).toBe('Bearer explicit');
224
196
  });
225
197
 
226
- it('should let a per-call header override a default header on the same key', async () => {
227
- fetchMock.mockJsonResponse('https://api.example.com/users', []);
198
+ it('should resolve endpoints against a base URL and send default headers there', async () => {
199
+ const Api = clientClass('https://api.example.com/v1', { Authorization: 'Bearer t' });
228
200
 
229
- await httpClient.get('https://api.example.com/users', { headers: { 'Content-Type': 'text/plain' } });
201
+ await Api.getInstance().get('/users');
230
202
 
231
- const lastCall = fetchMock.getLastCall();
232
- expect(lastCall.options.headers['Content-Type']).toBe('text/plain');
203
+ expect(fetchMock.last().url).toBe('https://api.example.com/v1/users');
204
+ expect(header(fetchMock.last(), 'authorization')).toBe('Bearer t');
205
+ });
206
+
207
+ it('should keep the base URL query before the endpoint query and query params', async () => {
208
+ const Api = clientClass('https://api.example.com/v1?version=2');
209
+
210
+ await Api.getInstance().get('/users?sort=name', { queryParams: { page: 1 } });
211
+
212
+ expect(fetchMock.last().url).toBe('https://api.example.com/v1/users?version=2&sort=name&page=1');
213
+ });
214
+
215
+ it('should reject an endpoint outside the base URL without calling fetch', async () => {
216
+ const Api = clientClass('https://api.example.com/v1', { Authorization: 'Bearer t' });
217
+
218
+ const error = await errorOf(Api.getInstance().get('/../admin'));
219
+
220
+ expect(error.message).toContain('outside the HttpClient base URL');
221
+ expect(fetchMock.calls.length).toBe(0);
222
+ });
223
+
224
+ it('should reject non-http(s) URLs without calling fetch', async () => {
225
+ const error = await errorOf(httpClient.get('ftp://files.example.com/report.csv'));
226
+
227
+ expect(error.message).toContain('must use http or https');
228
+ expect(fetchMock.calls.length).toBe(0);
229
+ });
230
+
231
+ it('should keep the query string out of error messages', async () => {
232
+ const Api = clientClass('https://api.example.com/v1?key=secret');
233
+
234
+ const error = await errorOf(Api.getInstance().get('https://other.example.com/x?token=secret'));
235
+
236
+ expect(error.message.includes('secret')).toBe(false);
237
+ });
238
+
239
+ it('should reject an encoded "/" in the path unless the API is trusted', async () => {
240
+ const Api = clientClass('https://api.example.com/v1');
241
+ const error = await errorOf(Api.getInstance().get('/files/a%2Fb'));
242
+ expect(error.message).toContain('trustedAPIs');
243
+ expect(fetchMock.calls.length).toBe(0);
244
+
245
+ const Trusted = clientClass('https://api.example.com/v1', {}, { trustedAPIs: ['https://api.example.com/v1/files/'] });
246
+ await Trusted.getInstance().get('/files/a%2Fb');
247
+ expect(fetchMock.last().url).toBe('https://api.example.com/v1/files/a%2Fb');
248
+ });
249
+
250
+ it('path`` encodes each value as one path segment', async () => {
251
+ const Api = clientClass('https://api.example.com/v1');
252
+
253
+ await Api.getInstance().get(path`/users/${'Ada Lovelace'}/posts/${42}`);
254
+
255
+ expect(fetchMock.last().url).toBe('https://api.example.com/v1/users/Ada%20Lovelace/posts/42');
256
+ });
257
+
258
+ it('path`` rejects "." and ".." values, and a value containing "/" needs a trusted API', async () => {
259
+ expect(() => path`/users/${'..'}`).toThrow();
260
+ expect(() => path`/users/${'.'}`).toThrow();
261
+
262
+ const Api = clientClass('https://api.example.com/v1');
263
+ const error = await errorOf(Api.getInstance().get(path`/users/${'a/b'}`));
264
+ expect(error.message).toContain('trustedAPIs');
265
+ });
266
+
267
+ it('should add query params, skipping null/undefined and repeating arrays', async () => {
268
+ await httpClient.get('https://api.example.com/users', {
269
+ queryParams: { page: 1, active: true, q: 'a b', skip: undefined, none: null, tag: ['x', 'y'] },
270
+ });
271
+
272
+ expect(fetchMock.last().url).toBe('https://api.example.com/users?page=1&active=true&q=a+b&tag=x&tag=y');
273
+ });
274
+
275
+ it('should accept a followed redirect that stays under the base URL', async () => {
276
+ const Api = clientClass('https://api.example.com/v1');
277
+ fetchMock.on('https://api.example.com/v1/old', () => redirectedTo('https://api.example.com/v1/new', { ok: 1 }));
278
+
279
+ expect(await Api.getInstance().get('/old')).toEqual({ ok: 1 });
280
+ });
281
+
282
+ it('should reject a followed redirect that leaves the base URL, before response interceptors', async () => {
283
+ const Api = clientClass('https://api.example.com/v1', { 'X-Api-Key': 'k' });
284
+ const api = Api.getInstance();
285
+ const seen = [];
286
+ api.addResponseInterceptor((response) => seen.push(response.url));
287
+ fetchMock.on('https://api.example.com/v1/moved', () => redirectedTo('https://other.example.com/data', { ok: 'other' }));
288
+ fetchMock.on('https://api.example.com/v1/up', () => redirectedTo('https://api.example.com/admin', { ok: 'admin' }));
289
+
290
+ const away = await errorOf(api.get('/moved'));
291
+ const up = await errorOf(api.get('/up'));
292
+
293
+ expect(away.message).toContain('was redirected to "https://other.example.com/data"');
294
+ expect(up.message).toContain('outside the HttpClient base URL');
295
+ expect(seen.length).toBe(0);
296
+ });
297
+
298
+ it('should reject a followed redirect to another origin when there is no base URL', async () => {
299
+ fetchMock.on('http://localhost/api/me', () => redirectedTo('https://other.example.com/me', {}));
300
+ fetchMock.on('http://localhost/api/old', () => redirectedTo('http://localhost/api/new', { ok: 1 }));
301
+
302
+ const error = await errorOf(httpClient.get('/api/me'));
303
+
304
+ expect(error.message).toContain('outside its origin');
305
+ expect(await httpClient.get('/api/old')).toEqual({ ok: 1 });
306
+ });
307
+
308
+ it('should pass credentials, cache, referrerPolicy and redirect to fetch', async () => {
309
+ await httpClient.get('https://api.example.com/users', {
310
+ credentials: 'include',
311
+ cache: 'no-store',
312
+ referrerPolicy: 'no-referrer',
313
+ redirect: 'error',
314
+ });
315
+
316
+ const options = fetchMock.last().options;
317
+ expect(options.credentials).toBe('include');
318
+ expect(options.cache).toBe('no-store');
319
+ expect(options.referrerPolicy).toBe('no-referrer');
320
+ expect(options.redirect).toBe('error');
321
+ });
322
+
323
+ it('should resolve 204 and empty responses to undefined', async () => {
324
+ fetchMock.on('https://api.example.com/a', () => new Response(null, { status: 204, headers: { 'content-type': 'application/json' } }));
325
+ fetchMock.on('https://api.example.com/b', () => new Response('', { status: 200, headers: { 'content-type': 'application/json' } }));
326
+
327
+ expect(await httpClient.delete('https://api.example.com/a')).toBe(undefined);
328
+ expect(await httpClient.get('https://api.example.com/b')).toBe(undefined);
329
+ });
330
+
331
+ it('should parse +json and charset-qualified JSON responses', async () => {
332
+ fetchMock.json('https://api.example.com/a', { a: 1 }, 200, 'application/vnd.api+json');
333
+ fetchMock.json('https://api.example.com/b', { b: 2 }, 200, 'Application/JSON; charset=utf-8');
334
+
335
+ expect(await httpClient.get('https://api.example.com/a')).toEqual({ a: 1 });
336
+ expect(await httpClient.get('https://api.example.com/b')).toEqual({ b: 2 });
337
+ });
338
+
339
+ it('should return non-JSON responses as text', async () => {
340
+ fetchMock.text('https://api.example.com/health', 'OK');
341
+
342
+ expect(await httpClient.get('https://api.example.com/health')).toBe('OK');
343
+ });
344
+
345
+ it('should reject a JSON response that does not parse', async () => {
346
+ fetchMock.on('https://api.example.com/broken', () => new Response('not json', { headers: { 'content-type': 'application/json' } }));
347
+
348
+ const error = await errorOf(httpClient.get('https://api.example.com/broken'));
349
+
350
+ expect(error instanceof SyntaxError).toBe(true);
351
+ });
352
+
353
+ it('should throw HttpError with the parsed error body', async () => {
354
+ fetchMock.json('https://api.example.com/users', { message: 'Not found' }, 404);
355
+
356
+ const error = await errorOf(httpClient.get('https://api.example.com/users'));
357
+
358
+ expect(error instanceof HttpError).toBe(true);
359
+ expect(error.status).toBe(404);
360
+ expect(error.response).toEqual({ message: 'Not found' });
233
361
  });
234
362
 
235
363
  it('should invoke request interceptors with the final url and options before sending', async () => {
236
- fetchMock.mockJsonResponse('https://api.example.com/users', []);
364
+ const api = clientClass().getInstance();
237
365
  const calls = [];
238
- httpClient.addRequestInterceptor((url, options) => calls.push({ url, options }));
366
+ api.addRequestInterceptor((url, options) => calls.push({ url, options }));
239
367
 
240
- await httpClient.get('https://api.example.com/users');
368
+ await api.get('https://api.example.com/users');
241
369
 
242
370
  expect(calls.length).toBe(1);
243
371
  expect(calls[0].url).toBe('https://api.example.com/users');
244
372
  expect(calls[0].options.method).toBe('GET');
245
373
  });
246
374
 
247
- it('should invoke response interceptors with the raw fetch response', async () => {
248
- fetchMock.mockJsonResponse('https://api.example.com/users', []);
249
- const received = [];
250
- httpClient.addResponseInterceptor((response) => received.push(response));
375
+ it('should give response interceptors a clone they can read without consuming the response', async () => {
376
+ fetchMock.json('https://api.example.com/users', { id: 1 });
377
+ const api = clientClass().getInstance();
378
+ const seen = [];
379
+ api.addResponseInterceptor(async (response) => seen.push(await response.json()));
380
+ api.addResponseInterceptor(async (response) => seen.push(await response.json()));
251
381
 
252
- await httpClient.get('https://api.example.com/users');
382
+ const result = await api.get('https://api.example.com/users');
253
383
 
254
- expect(received.length).toBe(1);
255
- expect(received[0].ok).toBe(true);
384
+ expect(result).toEqual({ id: 1 });
385
+ expect(seen).toEqual([{ id: 1 }, { id: 1 }]);
256
386
  });
257
387
 
258
- it('should propagate a non-timeout error thrown while parsing the response', async () => {
259
- fetchMock.mockResponse('https://api.example.com/broken', {
260
- ok: true,
261
- status: 200,
262
- statusText: 'OK',
263
- headers: { get: () => 'application/json' },
264
- json: () => Promise.reject(new Error('Malformed JSON')),
265
- text: () => Promise.resolve('not json'),
266
- });
388
+ it('should report its own timeout as "Request timed out"', async () => {
389
+ fetchMock.on('https://api.example.com/slow', () => new Promise(() => {}));
267
390
 
268
- let thrown = false;
269
- try {
270
- await httpClient.get('https://api.example.com/broken');
271
- } catch (error) {
272
- thrown = true;
273
- expect(error.message).toBe('Malformed JSON');
274
- }
275
- expect(thrown).toBe(true);
276
- });
277
-
278
- it('should handle HTTP errors', async () => {
279
- fetchMock.mockError('https://api.example.com/users', 404, { message: 'Not found' });
280
-
281
- try {
282
- await httpClient.get('https://api.example.com/users');
283
- // Should not reach here
284
- expect(false).toBeTruthy();
285
- } catch (error) {
286
- expect(error).toBeDefined();
287
- expect(error.message).toBe('HTTP Error 404: Internal Server Error');
288
- }
391
+ const error = await errorOf(httpClient.get('https://api.example.com/slow', { timeout: 20 }));
392
+
393
+ expect(error.message).toBe('Request timed out');
394
+ });
395
+
396
+ it('should rethrow an abort from the caller signal as it is, not as a timeout', async () => {
397
+ fetchMock.on('https://api.example.com/slow', () => new Promise(() => {}));
398
+ const controller = new AbortController();
399
+
400
+ const pending = errorOf(httpClient.get('https://api.example.com/slow', { signal: controller.signal, timeout: 5000 }));
401
+ controller.abort();
402
+ const error = await pending;
403
+
404
+ expect(error.name).toBe('AbortError');
289
405
  });
290
406
 
291
- it('should handle text responses', async () => {
292
- fetchMock.mockTextResponse('http://test.eu/health', 'OK');
293
-
294
- const result = await httpClient.get('http://test.eu/health');
407
+ it('should not call fetch when the caller signal is already aborted', async () => {
408
+ const controller = new AbortController();
409
+ controller.abort();
410
+
411
+ const error = await errorOf(httpClient.get('https://api.example.com/users', { signal: controller.signal }));
295
412
 
296
- expect(result).toBeDefined();
297
- expect(result).toContain('OK');
413
+ expect(error.name).toBe('AbortError');
298
414
  });
299
415
 
300
- it('should handle timeout', async () => {
301
- // Mock a delayed response
302
- fetchMock.mockResponse('https://api.example.com/slow', new Promise(resolve => {
303
- setTimeout(() => resolve({ ok: true, json: () => Promise.resolve({}) }), 200);
304
- }));
305
-
306
- try {
307
- await httpClient.get('/slow', { timeout: 100 });
308
- // Should not reach here if timeout works
309
- } catch (error) {
310
- // Timeout should trigger an abort error
311
- expect(error.name).toBeDefined();
416
+ it('should reject a timeout that is not a positive number', async () => {
417
+ for (const timeout of [0, -1, Number.NaN]) {
418
+ const error = await errorOf(httpClient.get('https://api.example.com/users', { timeout }));
419
+ expect(error.message).toContain('timeout must be a positive number');
312
420
  }
421
+ expect(fetchMock.calls.length).toBe(0);
313
422
  });
314
423
 
315
424
  it('should reset specific service instance', () => {
316
425
  const instance1 = HttpClient.getInstance();
317
-
318
426
  HttpClient.testingReset();
319
-
320
- const instance2 = HttpClient.getInstance();
321
- expect(instance1).not.toBe(instance2);
427
+ expect(HttpClient.getInstance()).not.toBe(instance1);
322
428
  });
323
429
 
324
430
  it('should reset all service instances', () => {
325
431
  const client1 = HttpClient.getInstance();
326
-
327
432
  HttpClient.testingResetAll();
328
-
329
- const client2 = HttpClient.getInstance();
330
- expect(client1).not.toBe(client2);
433
+ expect(HttpClient.getInstance()).not.toBe(client1);
331
434
  });
332
435
 
333
436
  it('should cleanup on destroy', () => {
334
- httpClient.onDestroy();
335
-
336
- expect(httpClient._baseUrl).toBe('');
337
- expect(Object.keys(httpClient._defaultHeaders).length).toBe(0);
437
+ const Api = clientClass('https://api.example.com', { A: '1' }, { trustedAPIs: ['https://api.example.com/'] });
438
+ const api = Api.getInstance();
439
+
440
+ api.onDestroy();
441
+
442
+ expect(api._baseUrl).toBe('');
443
+ expect(Object.keys(api._defaultHeaders).length).toBe(0);
444
+ expect(api._trustedAPIs.length).toBe(0);
338
445
  });
339
446
  });