@nutin/cli 2.1.0 → 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.
- package/CHANGELOG.md +322 -0
- package/README.md +14 -6
- package/bin/add-feature.mjs +1 -1
- package/bin/cli.mjs +1 -1
- package/bin/create-app.mjs +1 -1
- package/bin/update-app.mjs +1 -1
- package/lib/index.mjs +6 -2
- package/lib/src/add-feature/base-template-patcher.mjs +139 -0
- package/lib/src/add-feature/feature-adder.mjs +20 -9
- package/lib/src/add-feature/feature-context-builder.mjs +1 -0
- package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
- package/lib/src/add-feature/package-json-updater.mjs +7 -9
- package/lib/src/common/feature-registry.mjs +4 -0
- package/lib/src/common/file-generator.mjs +25 -3
- package/lib/src/common/git.mjs +31 -0
- package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
- package/lib/src/common/package-json-helper.mjs +14 -4
- package/lib/src/common/project-meta.mjs +2 -2
- package/lib/src/common/template-compiler.mjs +2 -0
- package/lib/src/common/transpile-ts.mjs +94 -0
- package/lib/src/create-app/context-builder.mjs +1 -0
- package/lib/src/create-app/git-manager.mjs +12 -2
- package/lib/src/create-app/json-manager.mjs +21 -18
- package/lib/src/create-app/new-app-prompt.mjs +4 -2
- package/lib/src/create-app/success-message.mjs +1 -1
- package/lib/src/update-app/conflict-report-writer.mjs +33 -21
- package/lib/src/update-app/json-drift.mjs +54 -0
- package/lib/src/update-app/project-updater.mjs +123 -14
- package/lib/src/update-app/template-differ.mjs +5 -5
- package/lib/src/update-app/three-way-merge.mjs +42 -0
- package/lib/src/update-app/update-context-builder.mjs +1 -0
- package/lib/src/update-app/update-summary-printer.mjs +10 -2
- package/package.json +12 -11
- package/templates/base/.gitignore.hbs +1 -0
- package/templates/base/AGENTS.md.hbs +45 -43
- package/templates/base/GETTING_STARTED.md.hbs +32 -23
- package/templates/base/README.md.hbs +7 -4
- package/templates/base/nutin.config.js.hbs +1 -1
- package/templates/base/src/app/components/.gitkeep +0 -0
- package/templates/base/src/app/globals.d.ts.hbs +6 -3
- package/templates/base/src/app/guards.ts.hbs +1 -3
- package/templates/base/src/app/main.ts.hbs +2 -9
- package/templates/base/src/app/routes.ts.hbs +9 -3
- package/templates/base/src/app/services/.gitkeep +0 -0
- package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
- package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
- package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
- package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
- package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +307 -21
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +105 -4
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +136 -62
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +101 -51
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
- package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
- package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
- package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
- package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
- package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
- package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
- package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
- package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
- package/templates/base/src/core/index.ts.hbs +4 -2
- package/templates/base/src/core/internals.d.ts.hbs +199 -20
- package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
- package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
- package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
- package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
- package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
- package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
- package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
- package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
- package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
- package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
- package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +217 -4
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +210 -65
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
- package/templates/base/src/core/services/router/router.test.js.hbs +321 -0
- package/templates/base/src/core/services/router/router.ts.hbs +122 -54
- package/templates/base/src/index.html.hbs +1 -0
- package/templates/base/src/styles/_styles.scss.hbs +1 -113
- package/templates/base/src/styles/main.scss.hbs +1 -1
- package/templates/base/tools/builder/builder.config.js.hbs +6 -0
- package/templates/base/tools/builder/builder.js.hbs +26 -8
- package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
- package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
- package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
- package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
- package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
- package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
- package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
- package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
- package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
- package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
- package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
- package/templates/base/tools/builder/core/html-index/add-tags.js.hbs +1 -4
- package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
- package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
- package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
- package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
- package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
- package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
- package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
- package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
- package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
- package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +25 -8
- package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
- package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
- package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
- package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +61 -15
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
- package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +48 -18
- package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
- package/templates/base/tools/builder/core/seo/route-output.js.hbs +26 -8
- package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
- package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
- package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +26 -9
- package/templates/base/tools/builder/core/styles/sass.js.hbs +7 -5
- package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
- package/templates/base/tools/dev/dev-serve.js.hbs +71 -7
- package/templates/base/tools/dev/serve.js.hbs +152 -22
- package/templates/base/tools/dev/watcher.js.hbs +75 -28
- package/templates/base/tools/generator/generator.js.hbs +23 -18
- package/templates/base/tools/generator/handle-file.js.hbs +4 -24
- package/templates/base/tools/generator/handle-file.test.js.hbs +4 -23
- package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
- package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
- package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
- package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
- package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
- package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
- package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
- package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
- package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
- package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
- package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
- package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
- package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
- package/templates/base/tools/utils/build-lock.js.hbs +97 -0
- package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
- package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
- package/templates/base/tools/utils/format.js.hbs +1 -1
- package/templates/base/tools/utils/index.js.hbs +3 -1
- package/templates/base/tools/utils/print.js.hbs +2 -2
- package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
- package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
- package/templates/base/tools/utils/run-command.js.hbs +30 -1
- package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
- package/templates/base/tools/utils/run-script.js.hbs +2 -2
- package/templates/features/docker/tools/docker/Dockerfile.template.hbs +5 -2
- package/templates/features/docker/tools/docker/nginx.conf.template +19 -2
- package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
- package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
- package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
- package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
- package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
- package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +29 -0
- package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
- package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
- package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
- package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
- package/templates/base/src/app/components/index.ts.hbs +0 -1
- package/templates/base/src/app/services/index.ts.hbs +0 -1
- package/templates/base/src/app/views/index.ts.hbs +0 -3
- /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.
|
|
8
|
+
this.handlers = new Map();
|
|
7
9
|
this.calls = [];
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
this.
|
|
12
|
+
on(url, handler) {
|
|
13
|
+
this.handlers.set(url, handler);
|
|
12
14
|
return this;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
this.
|
|
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
|
-
|
|
44
|
-
this.
|
|
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 =
|
|
26
|
+
global.fetch = (url, options = {}) => {
|
|
59
27
|
this.calls.push({ url, options });
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
44
|
+
this.handlers.clear();
|
|
84
45
|
this.calls = [];
|
|
85
46
|
}
|
|
86
47
|
|
|
87
|
-
|
|
48
|
+
last() {
|
|
88
49
|
return this.calls[this.calls.length - 1];
|
|
89
50
|
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fetchMock = new FetchMock();
|
|
90
54
|
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
118
|
-
expect(httpClient._defaultHeaders
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
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).
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
|
208
|
-
|
|
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
|
|
169
|
+
await Api.getInstance().get('/users', { headers: { accept: 'text/plain', 'X-Trace': '1' } });
|
|
211
170
|
|
|
212
|
-
|
|
213
|
-
expect(
|
|
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
|
|
217
|
-
|
|
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('
|
|
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
|
-
|
|
222
|
-
expect(
|
|
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
|
|
227
|
-
|
|
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
|
|
201
|
+
await Api.getInstance().get('/users');
|
|
230
202
|
|
|
231
|
-
|
|
232
|
-
expect(
|
|
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
|
-
|
|
364
|
+
const api = clientClass().getInstance();
|
|
237
365
|
const calls = [];
|
|
238
|
-
|
|
366
|
+
api.addRequestInterceptor((url, options) => calls.push({ url, options }));
|
|
239
367
|
|
|
240
|
-
await
|
|
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
|
|
248
|
-
fetchMock.
|
|
249
|
-
const
|
|
250
|
-
|
|
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
|
|
382
|
+
const result = await api.get('https://api.example.com/users');
|
|
253
383
|
|
|
254
|
-
expect(
|
|
255
|
-
expect(
|
|
384
|
+
expect(result).toEqual({ id: 1 });
|
|
385
|
+
expect(seen).toEqual([{ id: 1 }, { id: 1 }]);
|
|
256
386
|
});
|
|
257
387
|
|
|
258
|
-
it('should
|
|
259
|
-
fetchMock.
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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(
|
|
297
|
-
expect(result).toContain('OK');
|
|
413
|
+
expect(error.name).toBe('AbortError');
|
|
298
414
|
});
|
|
299
415
|
|
|
300
|
-
it('should
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
});
|