diff --git a/jest.config.js b/jest.config.js index 4117797..46d5fae 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,4 +4,13 @@ module.exports = { testMatch: ['**/*.test.ts'], clearMocks: true, testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + globals: { + 'ts-jest': { + useESM: true, + }, + }, + transformIgnorePatterns: [ + 'node_modules/(?!(msw|@mswjs|@bundled-es-modules|until-async|chalk|@open-draft|@inquirer|strict-event-emitter)/)', + ], } diff --git a/package-lock.json b/package-lock.json index e59b509..e7286c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,6 @@ "version": "5.10.0", "license": "MIT", "dependencies": { - "axios": "^1.0.0", - "axios-case-converter": "^1.0.0", - "axios-retry": "^4.0.0", "camelcase": "6.3.0", "emoji-regex": "10.6.0", "form-data": "4.0.4", @@ -33,6 +30,7 @@ "husky": "9.1.7", "jest": "30.1.3", "lint-staged": "16.1.6", + "msw": "2.11.6", "npm-run-all2": "8.0.4", "prettier": "3.3.2", "rimraf": "6.0.1", @@ -41,6 +39,9 @@ "type-fest": "^4.12.0", "typescript": "5.9.3" }, + "engines": { + "node": ">=8.0.0" + }, "peerDependencies": { "type-fest": "^4.12.0" } @@ -815,6 +816,122 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@inquirer/ansi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.1.tgz", + "integrity": "sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.19", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.19.tgz", + "integrity": "sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.0", + "@inquirer/type": "^3.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.0.tgz", + "integrity": "sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.1", + "@inquirer/figures": "^1.0.14", + "@inquirer/type": "^3.0.9", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.14.tgz", + "integrity": "sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.9.tgz", + "integrity": "sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -1497,6 +1614,24 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", + "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1544,6 +1679,31 @@ "node": ">= 8" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1750,10 +1910,14 @@ "dev": true }, "node_modules/@types/node": { - "version": "14.14.20", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz", - "integrity": "sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A==", - "dev": true + "version": "24.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", + "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } }, "node_modules/@types/semver": { "version": "7.5.8", @@ -1768,6 +1932,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -2706,41 +2877,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/axios-case-converter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/axios-case-converter/-/axios-case-converter-1.1.1.tgz", - "integrity": "sha512-v13pB7cYryh/7f4TKxN/gniD2hwqPQcjip29Hk3J9iwsnA37Rht2Hkn5VyrxynxlKdMNSIfGk6I9D6G28oTRyQ==", - "dependencies": { - "camel-case": "^4.1.1", - "header-case": "^2.0.3", - "snake-case": "^3.0.3", - "tslib": "^2.3.0" - }, - "peerDependencies": { - "axios": ">=1.0.0 <2.0.0" - } - }, - "node_modules/axios-retry": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", - "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", - "dependencies": { - "is-retry-allowed": "^2.2.0" - }, - "peerDependencies": { - "axios": "0.x || 1.x" - } - }, "node_modules/babel-jest": { "version": "30.1.2", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.1.2.tgz", @@ -2957,15 +3093,6 @@ "node": ">=6" } }, - "node_modules/camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } - }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -2998,16 +3125,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/capital-case": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", - "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3", - "upper-case-first": "^2.0.2" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3140,6 +3257,16 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3228,6 +3355,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -3361,15 +3498,6 @@ "node": ">=6.0.0" } }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4430,25 +4558,6 @@ "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==", "dev": true }, - "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -4792,6 +4901,16 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/graphql": { + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", + "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -4903,14 +5022,12 @@ "node": ">= 0.4" } }, - "node_modules/header-case": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", - "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==", - "dependencies": { - "capital-case": "^1.0.4", - "tslib": "^2.0.3" - } + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" }, "node_modules/html-escaper": { "version": "2.0.2", @@ -5165,6 +5282,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5214,17 +5338,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-retry-allowed": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", - "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-shared-array-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", @@ -6648,14 +6761,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dependencies": { - "tslib": "^2.0.3" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6821,6 +6926,61 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/msw": { + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.11.6.tgz", + "integrity": "sha512-MCYMykvmiYScyUm7I6y0VCxpNq1rgd5v7kG8ks5dKtvmxRUUPjribX6mUoUNBbM5/3PhUyoelEWiKXGOz84c+w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.40.0", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.4", + "cookie": "^1.0.2", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.7.0", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^4.26.1", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/nano-spawn": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.3.tgz", @@ -6860,15 +7020,6 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7135,6 +7286,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -7208,15 +7366,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7272,6 +7421,13 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -7418,11 +7574,6 @@ "dev": true, "peer": true }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "node_modules/punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -7657,6 +7808,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rettime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -8009,15 +8167,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/snake-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8058,6 +8207,23 @@ "node": ">=10" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -8370,6 +8536,26 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "node_modules/tldts": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", + "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.17" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", + "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -8388,6 +8574,19 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -8539,7 +8738,8 @@ "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true }, "node_modules/type-check": { "version": "0.4.0", @@ -8629,6 +8829,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -8663,6 +8870,16 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -8694,14 +8911,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/upper-case-first": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", - "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", - "dependencies": { - "tslib": "^2.0.3" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -9077,6 +9286,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", @@ -9629,6 +9851,70 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "@inquirer/ansi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.1.tgz", + "integrity": "sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==", + "dev": true + }, + "@inquirer/confirm": { + "version": "5.1.19", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.19.tgz", + "integrity": "sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ==", + "dev": true, + "requires": { + "@inquirer/core": "^10.3.0", + "@inquirer/type": "^3.0.9" + } + }, + "@inquirer/core": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.0.tgz", + "integrity": "sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==", + "dev": true, + "requires": { + "@inquirer/ansi": "^1.0.1", + "@inquirer/figures": "^1.0.14", + "@inquirer/type": "^3.0.9", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "dependencies": { + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + } + } + }, + "@inquirer/figures": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.14.tgz", + "integrity": "sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==", + "dev": true + }, + "@inquirer/type": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.9.tgz", + "integrity": "sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==", + "dev": true, + "requires": {} + }, "@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -10160,6 +10446,20 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@mswjs/interceptors": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", + "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", + "dev": true, + "requires": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + } + }, "@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -10198,6 +10498,28 @@ "fastq": "^1.6.0" } }, + "@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true + }, + "@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "requires": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true + }, "@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -10386,10 +10708,13 @@ "dev": true }, "@types/node": { - "version": "14.14.20", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz", - "integrity": "sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A==", - "dev": true + "version": "24.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", + "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", + "dev": true, + "requires": { + "undici-types": "~7.16.0" + } }, "@types/semver": { "version": "7.5.8", @@ -10403,6 +10728,12 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, + "@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true + }, "@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -11052,35 +11383,6 @@ "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", "dev": true }, - "axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", - "requires": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, - "axios-case-converter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/axios-case-converter/-/axios-case-converter-1.1.1.tgz", - "integrity": "sha512-v13pB7cYryh/7f4TKxN/gniD2hwqPQcjip29Hk3J9iwsnA37Rht2Hkn5VyrxynxlKdMNSIfGk6I9D6G28oTRyQ==", - "requires": { - "camel-case": "^4.1.1", - "header-case": "^2.0.3", - "snake-case": "^3.0.3", - "tslib": "^2.3.0" - } - }, - "axios-retry": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", - "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", - "requires": { - "is-retry-allowed": "^2.2.0" - } - }, "babel-jest": { "version": "30.1.2", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.1.2.tgz", @@ -11239,15 +11541,6 @@ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true }, - "camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "requires": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } - }, "camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -11259,16 +11552,6 @@ "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", "dev": true }, - "capital-case": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", - "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", - "requires": { - "no-case": "^3.0.4", - "tslib": "^2.0.3", - "upper-case-first": "^2.0.2" - } - }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -11351,6 +11634,12 @@ } } }, + "cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true + }, "cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -11421,6 +11710,12 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "dev": true + }, "create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -11511,15 +11806,6 @@ "esutils": "^2.0.2" } }, - "dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "requires": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -12318,11 +12604,6 @@ "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==", "dev": true }, - "follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==" - }, "for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -12557,6 +12838,12 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "graphql": { + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", + "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", + "dev": true + }, "handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -12627,14 +12914,11 @@ "function-bind": "^1.1.2" } }, - "header-case": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", - "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==", - "requires": { - "capital-case": "^1.0.4", - "tslib": "^2.0.3" - } + "headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true }, "html-escaper": { "version": "2.0.2", @@ -12809,6 +13093,12 @@ "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", "dev": true }, + "is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -12840,11 +13130,6 @@ "has-tostringtag": "^1.0.0" } }, - "is-retry-allowed": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", - "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==" - }, "is-shared-array-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", @@ -13882,14 +14167,6 @@ "js-tokens": "^3.0.0 || ^4.0.0" } }, - "lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "requires": { - "tslib": "^2.0.3" - } - }, "lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -14014,6 +14291,38 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "msw": { + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.11.6.tgz", + "integrity": "sha512-MCYMykvmiYScyUm7I6y0VCxpNq1rgd5v7kG8ks5dKtvmxRUUPjribX6mUoUNBbM5/3PhUyoelEWiKXGOz84c+w==", + "dev": true, + "requires": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.40.0", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.4", + "cookie": "^1.0.2", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.7.0", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^4.26.1", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + } + }, + "mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true + }, "nano-spawn": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.3.tgz", @@ -14038,15 +14347,6 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, - "no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "requires": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -14234,6 +14534,12 @@ "word-wrap": "^1.2.3" } }, + "outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true + }, "p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -14285,15 +14591,6 @@ "lines-and-columns": "^1.1.6" } }, - "pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "requires": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -14336,6 +14633,12 @@ } } }, + "path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true + }, "path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -14436,11 +14739,6 @@ } } }, - "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -14590,6 +14888,12 @@ } } }, + "rettime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "dev": true + }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -14818,15 +15122,6 @@ } } }, - "snake-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "requires": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -14859,6 +15154,18 @@ "escape-string-regexp": "^2.0.0" } }, + "statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true + }, + "strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true + }, "string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -15085,6 +15392,21 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "tldts": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", + "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", + "dev": true, + "requires": { + "tldts-core": "^7.0.17" + } + }, + "tldts-core": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", + "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", + "dev": true + }, "tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -15100,6 +15422,15 @@ "is-number": "^7.0.0" } }, + "tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "requires": { + "tldts": "^7.0.5" + } + }, "ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -15182,7 +15513,8 @@ "tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true }, "type-check": { "version": "0.4.0", @@ -15241,6 +15573,12 @@ "which-boxed-primitive": "^1.0.2" } }, + "undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true + }, "unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -15269,6 +15607,12 @@ "napi-postinstall": "^0.3.0" } }, + "until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true + }, "update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -15279,14 +15623,6 @@ "picocolors": "^1.1.1" } }, - "upper-case-first": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", - "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", - "requires": { - "tslib": "^2.0.3" - } - }, "uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -15556,6 +15892,12 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true }, + "yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true + }, "zod": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", diff --git a/package.json b/package.json index 8af0d63..6a4bcef 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,6 @@ "prepare": "npm run build" }, "dependencies": { - "axios": "^1.0.0", - "axios-case-converter": "^1.0.0", - "axios-retry": "^4.0.0", "camelcase": "6.3.0", "emoji-regex": "10.6.0", "form-data": "4.0.4", @@ -51,6 +48,7 @@ "husky": "9.1.7", "jest": "30.1.3", "lint-staged": "16.1.6", + "msw": "2.11.6", "npm-run-all2": "8.0.4", "prettier": "3.3.2", "rimraf": "6.0.1", diff --git a/src/rest-client.axios.test.ts b/src/rest-client.axios.test.ts deleted file mode 100644 index 566973a..0000000 --- a/src/rest-client.axios.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import axios from 'axios' -import { paramsSerializer } from './rest-client' - -const DEFAULT_BASE_URI = 'https://api.todoist.com/rest/v2/tasks' - -describe('axios tests without mocking', () => { - test('GET calls serialise arrays correctly', () => { - const requestUri = axios.create().getUri({ - method: 'GET', - baseURL: DEFAULT_BASE_URI, - params: { - ids: ['12345', '56789'], - }, - paramsSerializer: { - serialize: paramsSerializer, - }, - }) - expect(requestUri).toEqual('https://api.todoist.com/rest/v2/tasks?ids=12345%2C56789') - }) - - test('GET calls do not serialise null values', () => { - const requestUri = axios.create().getUri({ - method: 'GET', - baseURL: DEFAULT_BASE_URI, - params: { - ids: null, - }, - paramsSerializer: { - serialize: paramsSerializer, - }, - }) - expect(requestUri).toEqual('https://api.todoist.com/rest/v2/tasks') - }) - - test('GET calls do not serialise undefined values', () => { - const requestUri = axios.create().getUri({ - method: 'GET', - baseURL: DEFAULT_BASE_URI, - params: { - ids: undefined, - }, - paramsSerializer: { - serialize: paramsSerializer, - }, - }) - expect(requestUri).toEqual('https://api.todoist.com/rest/v2/tasks') - }) -}) diff --git a/src/rest-client.test.ts b/src/rest-client.test.ts index f5957a8..ddeb6c7 100644 --- a/src/rest-client.test.ts +++ b/src/rest-client.test.ts @@ -1,15 +1,13 @@ -// eslint-disable-next-line import/no-named-as-default -import Axios, { AxiosStatic, AxiosResponse, AxiosError } from 'axios' import { request, isSuccess, paramsSerializer } from './rest-client' import { TodoistRequestError } from './types/errors' -import * as caseConverter from 'axios-case-converter' -import { assertInstance } from './test-utils/asserts' -import { DEFAULT_REQUEST_ID } from './test-utils/test-defaults' -import { API_BASE_URI } from './consts/endpoints' +import type { HttpResponse as TodoistHttpResponse } from './types/http' + +// Mock fetch globally +const mockFetch = jest.fn() +global.fetch = mockFetch as unknown as typeof fetch const RANDOM_ID = 'SomethingRandom' -jest.mock('axios') jest.mock('uuid', () => ({ v4: () => RANDOM_ID })) const DEFAULT_BASE_URI = 'https://someapi.com/' @@ -25,179 +23,106 @@ const AUTHORIZATION_HEADERS = { Authorization: `Bearer ${DEFAULT_AUTH_TOKEN}`, } -const HEADERS_WITH_REQUEST_ID = { - ...DEFAULT_HEADERS, - 'X-Request-Id': DEFAULT_REQUEST_ID, -} - const DEFAULT_PAYLOAD = { someKey: 'someValue', } -const DEFAULT_RESPONSE = { - data: DEFAULT_PAYLOAD, -} as AxiosResponse - -const DEFAULT_ERROR_MESSAGE = 'There was an error' - -function setupAxiosMock(response = DEFAULT_RESPONSE) { - const axiosMock = Axios as jest.Mocked - - axiosMock.create = jest.fn(() => axiosMock) - axiosMock.get.mockResolvedValue(response) - axiosMock.post.mockResolvedValue(response) - axiosMock.delete.mockResolvedValue(response) +const DEFAULT_RESPONSE_DATA = DEFAULT_PAYLOAD + +// Helper to mock successful fetch responses +function mockSuccessfulResponse(responseData = DEFAULT_RESPONSE_DATA, status = 200) { + const mockResponse = { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + headers: new Map([['content-type', 'application/json']]), + text: jest.fn().mockResolvedValue(JSON.stringify(responseData)), + json: jest.fn().mockResolvedValue(responseData), + } - jest.spyOn(caseConverter, 'default').mockImplementation(() => axiosMock) - return axiosMock + mockFetch.mockResolvedValue(mockResponse as unknown as Response) + return mockResponse } -function setupAxiosMockWithError(statusCode: number, responseData: unknown) { - const axiosMock = Axios as jest.Mocked - axiosMock.create = jest.fn(() => axiosMock) - - const axiosError = { - message: DEFAULT_ERROR_MESSAGE, - response: { status: statusCode, data: responseData } as AxiosResponse, - isAxiosError: true, - } as AxiosError - - function errorFunc(): Promise { - throw axiosError +// Helper to mock error responses +function mockErrorResponse(responseData: unknown, status: number) { + const mockResponse = { + ok: false, + status, + statusText: 'Error', + headers: new Map([['content-type', 'application/json']]), + text: jest.fn().mockResolvedValue(JSON.stringify(responseData)), + json: jest.fn().mockResolvedValue(responseData), } - axiosMock.get.mockImplementation(errorFunc) - axiosMock.post.mockImplementation(errorFunc) - axiosMock.delete.mockImplementation(errorFunc) - return axiosMock + mockFetch.mockResolvedValue(mockResponse as unknown as Response) + return mockResponse } describe('restClient', () => { - let axiosMock: jest.Mocked - beforeEach(() => { - axiosMock = setupAxiosMock() + mockFetch.mockClear() }) - test('request creates axios client with default headers', async () => { - await request({ - httpMethod: 'GET', - baseUri: DEFAULT_BASE_URI, - relativePath: DEFAULT_ENDPOINT, - }) - - expect(axiosMock.create).toHaveBeenCalledTimes(1) - expect(axiosMock.create).toHaveBeenCalledWith({ - baseURL: DEFAULT_BASE_URI, - headers: DEFAULT_HEADERS, - }) - }) + test('request makes GET request with correct URL and headers', async () => { + mockSuccessfulResponse(DEFAULT_RESPONSE_DATA) - test('request adds authorization header to config if token is passed', async () => { - await request({ + const result = await request({ httpMethod: 'GET', baseUri: DEFAULT_BASE_URI, relativePath: DEFAULT_ENDPOINT, - apiToken: DEFAULT_AUTH_TOKEN, - }) - - expect(axiosMock.create).toHaveBeenCalledTimes(1) - expect(axiosMock.create).toHaveBeenCalledWith({ - baseURL: DEFAULT_BASE_URI, - headers: AUTHORIZATION_HEADERS, }) - }) - test('request adds request ID header to config if ID is passed', async () => { - await request({ - httpMethod: 'GET', - baseUri: DEFAULT_BASE_URI, - relativePath: DEFAULT_ENDPOINT, - requestId: DEFAULT_REQUEST_ID, - }) + // Verify the fetch was called with correct parameters + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith( + `${DEFAULT_BASE_URI}${DEFAULT_ENDPOINT}`, + expect.objectContaining({ + method: 'GET', + headers: DEFAULT_HEADERS, + }), + ) - expect(axiosMock.create).toHaveBeenCalledTimes(1) - expect(axiosMock.create).toHaveBeenCalledWith({ - baseURL: DEFAULT_BASE_URI, - headers: HEADERS_WITH_REQUEST_ID, - }) + // Verify the response structure + expect(result.data).toEqual(DEFAULT_RESPONSE_DATA) + expect(result.status).toBe(200) + expect(result.statusText).toBe('OK') }) - test('get calls axios with expected endpoint', async () => { - await request({ - httpMethod: 'GET', - baseUri: DEFAULT_BASE_URI, - relativePath: DEFAULT_ENDPOINT, - apiToken: DEFAULT_AUTH_TOKEN, - }) - - expect(axiosMock.get).toHaveBeenCalledTimes(1) - expect(axiosMock.get).toHaveBeenCalledWith(DEFAULT_ENDPOINT, { - params: undefined, - paramsSerializer: { - serialize: paramsSerializer, - }, - }) - }) + test('request adds authorization header if token is passed', async () => { + mockSuccessfulResponse(DEFAULT_RESPONSE_DATA) - test('get passes params to axios', async () => { await request({ httpMethod: 'GET', baseUri: DEFAULT_BASE_URI, relativePath: DEFAULT_ENDPOINT, apiToken: DEFAULT_AUTH_TOKEN, - payload: DEFAULT_PAYLOAD, }) - expect(axiosMock.get).toHaveBeenCalledTimes(1) - expect(axiosMock.get).toHaveBeenCalledWith(DEFAULT_ENDPOINT, { - params: DEFAULT_PAYLOAD, - paramsSerializer: { - serialize: paramsSerializer, - }, - }) + expect(mockFetch).toHaveBeenCalledWith( + `${DEFAULT_BASE_URI}${DEFAULT_ENDPOINT}`, + expect.objectContaining({ + method: 'GET', + headers: AUTHORIZATION_HEADERS, + }), + ) }) - test('get returns response from axios', async () => { - const result = await request({ - httpMethod: 'GET', - baseUri: DEFAULT_BASE_URI, - relativePath: DEFAULT_ENDPOINT, - apiToken: DEFAULT_AUTH_TOKEN, - }) - - expect(axiosMock.get).toHaveBeenCalledTimes(1) - expect(result).toEqual(DEFAULT_RESPONSE) - }) - - test('post sends expected endpoint and payload to axios', async () => { - await request({ - httpMethod: 'POST', - baseUri: DEFAULT_BASE_URI, - relativePath: DEFAULT_ENDPOINT, - apiToken: DEFAULT_AUTH_TOKEN, - payload: DEFAULT_PAYLOAD, - }) + test('paramsSerializer works correctly', () => { + const params = { + filter: 'today', + ids: [1, 2, 3], + nullValue: null, + undefinedValue: undefined, + } - expect(axiosMock.post).toHaveBeenCalledTimes(1) - expect(axiosMock.post).toHaveBeenCalledWith(DEFAULT_ENDPOINT, DEFAULT_PAYLOAD) + const result = paramsSerializer(params) + expect(result).toBe('filter=today&ids=1%2C2%2C3') }) - test('post sends expected endpoint and payload to axios when sync commands are used', async () => { - await request({ - httpMethod: 'POST', - baseUri: DEFAULT_BASE_URI, - relativePath: DEFAULT_ENDPOINT, - apiToken: DEFAULT_AUTH_TOKEN, - payload: DEFAULT_PAYLOAD, - hasSyncCommands: true, - }) - - expect(axiosMock.post).toHaveBeenCalledTimes(1) - expect(axiosMock.post).toHaveBeenCalledWith(DEFAULT_ENDPOINT, '{"someKey":"someValue"}') - }) + test('POST request with JSON payload', async () => { + mockSuccessfulResponse(DEFAULT_RESPONSE_DATA) - test('post returns response from axios', async () => { const result = await request({ httpMethod: 'POST', baseUri: DEFAULT_BASE_URI, @@ -206,80 +131,31 @@ describe('restClient', () => { payload: DEFAULT_PAYLOAD, }) - expect(axiosMock.post).toHaveBeenCalledTimes(1) - expect(result).toEqual(DEFAULT_RESPONSE) - }) + expect(mockFetch).toHaveBeenCalledWith( + `${DEFAULT_BASE_URI}${DEFAULT_ENDPOINT}`, + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining(AUTHORIZATION_HEADERS), + body: JSON.stringify({ some_key: 'someValue' }), // Should be snake_case + }), + ) - test('random request ID is created if none provided for POST request', async () => { - await request({ - httpMethod: 'POST', - baseUri: DEFAULT_BASE_URI, - relativePath: DEFAULT_ENDPOINT, - apiToken: DEFAULT_AUTH_TOKEN, - payload: DEFAULT_PAYLOAD, - }) - - expect(axiosMock.create).toHaveBeenCalledWith({ - baseURL: DEFAULT_BASE_URI, - headers: { ...AUTHORIZATION_HEADERS, 'X-Request-Id': RANDOM_ID }, - }) + expect(result.data).toEqual(DEFAULT_RESPONSE_DATA) }) - test('random request ID is not created if none provided for POST request on the sync endpoint', async () => { - const syncUrl = new URL(API_BASE_URI, DEFAULT_BASE_URI).toString() - await request({ - httpMethod: 'POST', - baseUri: syncUrl, - relativePath: DEFAULT_ENDPOINT, - apiToken: DEFAULT_AUTH_TOKEN, - payload: DEFAULT_PAYLOAD, - }) - - expect(axiosMock.create).toHaveBeenCalledWith({ - baseURL: syncUrl, - headers: { ...AUTHORIZATION_HEADERS }, - }) - }) - - test('delete calls axios with expected endpoint', async () => { - await request({ - httpMethod: 'DELETE', - baseUri: DEFAULT_BASE_URI, - relativePath: DEFAULT_ENDPOINT, - apiToken: DEFAULT_AUTH_TOKEN, - }) - - expect(axiosMock.delete).toHaveBeenCalledTimes(1) - expect(axiosMock.delete).toHaveBeenCalledWith(DEFAULT_ENDPOINT) - }) - - test('request throws TodoistRequestError on axios error with expected values', async () => { + test('Error handling returns TodoistRequestError', async () => { const statusCode = 403 - const responseData = 'Some Data' - axiosMock = setupAxiosMockWithError(statusCode, responseData) - - expect.assertions(3) + const responseData = 'Forbidden' + mockErrorResponse(responseData, statusCode) - try { - await request({ + await expect( + request({ httpMethod: 'GET', baseUri: DEFAULT_BASE_URI, relativePath: DEFAULT_ENDPOINT, apiToken: DEFAULT_AUTH_TOKEN, - }) - } catch (e: unknown) { - assertInstance(e, TodoistRequestError) - expect(e.message).toEqual(DEFAULT_ERROR_MESSAGE) - expect(e.httpStatusCode).toEqual(statusCode) - expect(e.responseData).toEqual(responseData) - } - }) - - test('TodoistRequestError reports isAuthenticationError for relevant status codes', () => { - const statusCode = 403 - - const requestError = new TodoistRequestError('An Error', statusCode, undefined) - expect(requestError.isAuthenticationError()).toBeTruthy() + }), + ).rejects.toThrow(TodoistRequestError) }) const responseStatusTheories = [ @@ -290,7 +166,12 @@ describe('restClient', () => { ] as const test.each(responseStatusTheories)('status code %p returns isSuccess %p', (status, expected) => { - const response = { status } as AxiosResponse + const response: TodoistHttpResponse = { + status, + statusText: 'Test', + headers: {}, + data: null, + } const success = isSuccess(response) expect(success).toEqual(expected) }) diff --git a/src/rest-client.ts b/src/rest-client.ts index b252d2f..4be92e9 100644 --- a/src/rest-client.ts +++ b/src/rest-client.ts @@ -1,15 +1,12 @@ -// eslint-disable-next-line import/no-named-as-default -import Axios, { AxiosResponse, AxiosError } from 'axios' -import applyCaseMiddleware from 'axios-case-converter' import { TodoistRequestError } from './types/errors' -import { HttpMethod } from './types/http' +import { HttpMethod, HttpResponse, isNetworkError, isHttpError } from './types/http' import { v4 as uuidv4 } from 'uuid' -import axiosRetry from 'axios-retry' import { API_BASE_URI } from './consts/endpoints' -import { customCamelCase } from './utils/processing-helpers' +import { camelCaseKeys, snakeCaseKeys } from './utils/case-conversion' +import { fetchWithRetry } from './utils/fetch-with-retry' type GetTodoistRequestErrorArgs = { - error: Error | AxiosError + error: Error originalStack?: Error } @@ -20,7 +17,7 @@ type GetRequestConfigurationArgs = { customHeaders?: Record } -type GetAxiosClientArgs = { +type GetHttpClientArgs = { baseURL: string apiToken?: string requestId?: string @@ -63,27 +60,19 @@ function getAuthHeader(apiKey: string) { return `Bearer ${apiKey}` } -function isNetworkError(error: AxiosError) { - return Boolean(!error.response && error.code !== 'ECONNABORTED') -} - function getRetryDelay(retryCount: number) { return retryCount === 1 ? 0 : 500 } -function isAxiosError(error: unknown): error is AxiosError { - return Boolean((error as AxiosError)?.isAxiosError) -} - function getTodoistRequestError(args: GetTodoistRequestErrorArgs): TodoistRequestError { const { error, originalStack } = args const requestError = new TodoistRequestError(error.message) - requestError.stack = isAxiosError(error) && originalStack ? originalStack.stack : error.stack + requestError.stack = originalStack ? originalStack.stack : error.stack - if (isAxiosError(error) && error.response) { - requestError.httpStatusCode = error.response.status - requestError.responseData = error.response.data + if (isHttpError(error)) { + requestError.httpStatusCode = error.status + requestError.responseData = error.data } return requestError @@ -98,29 +87,26 @@ function getRequestConfiguration(args: GetRequestConfigurationArgs) { return { baseURL, headers } } -function getAxiosClient(args: GetAxiosClientArgs) { +function getHttpClientConfig(args: GetHttpClientArgs) { const { baseURL, apiToken, requestId, customHeaders } = args const configuration = getRequestConfiguration({ baseURL, apiToken, requestId, customHeaders }) - const client = applyCaseMiddleware(Axios.create(configuration), { - caseFunctions: { - camel: customCamelCase, - }, - }) - - axiosRetry(client, { - retries: 3, - retryCondition: isNetworkError, - retryDelay: getRetryDelay, - }) - return client + return { + ...configuration, + timeout: 30000, // 30 second timeout + retry: { + retries: 3, + retryCondition: isNetworkError, + retryDelay: getRetryDelay, + }, + } } -export function isSuccess(response: AxiosResponse): boolean { +export function isSuccess(response: HttpResponse): boolean { return response.status >= 200 && response.status < 300 } -export async function request(args: RequestArgs): Promise> { +export async function request(args: RequestArgs): Promise> { const { httpMethod, baseUri, @@ -132,9 +118,7 @@ export async function request(args: RequestArgs): Promise> { customHeaders, } = args - // axios loses the original stack when returning errors, for the sake of better reporting - // we capture it here and reapply it to any thrown errors. - // Ref: https://github.com/axios/axios/issues/2387 + // Capture original stack for better error reporting const originalStack = new Error() try { @@ -144,31 +128,56 @@ export async function request(args: RequestArgs): Promise> { requestId = uuidv4() } - const axiosClient = getAxiosClient({ baseURL: baseUri, apiToken, requestId, customHeaders }) + const config = getHttpClientConfig({ baseURL: baseUri, apiToken, requestId, customHeaders }) + const url = `${baseUri}${relativePath}` + + const fetchOptions: RequestInit & { timeout?: number } = { + method: httpMethod, + headers: config.headers, + timeout: config.timeout, + } + + let finalUrl = url switch (httpMethod) { case 'GET': - return await axiosClient.get(relativePath, { - params: payload, - paramsSerializer: { - serialize: paramsSerializer, - }, - }) + // For GET requests, add query parameters to URL + if (payload) { + const queryString = paramsSerializer(payload) + if (queryString) { + const separator = url.includes('?') ? '&' : '?' + finalUrl = `${url}${separator}${queryString}` + } + } + break case 'POST': - return await axiosClient.post( - relativePath, - hasSyncCommands ? JSON.stringify(payload) : payload, - ) - case 'PUT': - return await axiosClient.put( - relativePath, - hasSyncCommands ? JSON.stringify(payload) : payload, - ) + case 'PUT': { + // Convert payload from camelCase to snake_case + const convertedPayload = payload ? snakeCaseKeys(payload) : payload + const body = hasSyncCommands + ? JSON.stringify(convertedPayload) + : JSON.stringify(convertedPayload) + + fetchOptions.body = body + break + } case 'DELETE': - return await axiosClient.delete(relativePath) + // DELETE requests don't have a body + break } + + // Make the request + const response = await fetchWithRetry({ + url: finalUrl, + options: fetchOptions, + retryConfig: config.retry, + }) + + // Convert snake_case response to camelCase + const convertedData = camelCaseKeys(response.data) + return { ...response, data: convertedData } } catch (error: unknown) { - if (!isAxiosError(error) && !(error instanceof Error)) { + if (!(error instanceof Error)) { throw new Error('An unknown error occurred during the request') } diff --git a/src/test-utils/mocks.ts b/src/test-utils/mocks.ts index f64a972..1f1b060 100644 --- a/src/test-utils/mocks.ts +++ b/src/test-utils/mocks.ts @@ -1,7 +1,12 @@ -import { AxiosResponse } from 'axios' +import type { HttpResponse } from '../types/http' import * as restClient from '../rest-client' export function setupRestClientMock(responseData: unknown, status = 200): jest.SpyInstance { - const response = { status, data: responseData } as AxiosResponse + const response: HttpResponse = { + status, + statusText: status === 200 ? 'OK' : 'Error', + headers: {}, + data: responseData, + } return jest.spyOn(restClient, 'request').mockResolvedValue(response) } diff --git a/src/test-utils/msw-setup.ts b/src/test-utils/msw-setup.ts new file mode 100644 index 0000000..d2560c2 --- /dev/null +++ b/src/test-utils/msw-setup.ts @@ -0,0 +1,28 @@ +import { setupServer } from 'msw/node' + +// Default handlers for common API responses +export const handlers = [ + // Default handlers can be added here for common endpoints + // Individual test files will add their own specific handlers +] + +// Create MSW server instance +export const server = setupServer(...handlers) + +// Setup MSW for tests +beforeAll(() => { + server.listen({ + onUnhandledRequest: 'warn', // Log warnings for unhandled requests during development + }) +}) + +afterEach(() => { + server.resetHandlers() // Reset handlers between tests +}) + +afterAll(() => { + server.close() // Clean up after all tests +}) + +// Export MSW utilities for use in tests +export { http, HttpResponse } from 'msw' diff --git a/src/todoist-api.uploads.test.ts b/src/todoist-api.uploads.test.ts index 98e10b0..658d0af 100644 --- a/src/todoist-api.uploads.test.ts +++ b/src/todoist-api.uploads.test.ts @@ -1,13 +1,12 @@ import { TodoistApi } from './todoist-api' import { setupRestClientMock } from './test-utils/mocks' import { getSyncBaseUri } from './consts/endpoints' -import axios from 'axios' import * as fs from 'fs' import { Readable } from 'stream' -// Mock axios -jest.mock('axios') -const mockedAxios = axios as jest.Mocked +// Mock fetch globally +const mockFetch = jest.fn() +global.fetch = mockFetch as unknown as typeof fetch // Mock fs jest.mock('fs') @@ -29,9 +28,17 @@ describe('TodoistApi uploads', () => { beforeEach(() => { jest.clearAllMocks() - mockedAxios.post.mockResolvedValue({ - data: mockUploadResult, - }) + + // Mock successful fetch response + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Map([['content-type', 'application/json']]), + text: jest.fn().mockResolvedValue(JSON.stringify(mockUploadResult)), + json: jest.fn().mockResolvedValue(mockUploadResult), + } + mockFetch.mockResolvedValue(mockResponse as unknown as Response) }) test('uploads file from Buffer with fileName', async () => { @@ -44,11 +51,11 @@ describe('TodoistApi uploads', () => { projectId: '12345', }) - expect(mockedAxios.post).toHaveBeenCalledTimes(1) - const [url, , config] = mockedAxios.post.mock.calls[0] + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, config] = mockFetch.mock.calls[0] expect(url).toBe(`${getSyncBaseUri()}uploads`) - expect(config?.headers?.Authorization).toBe('Bearer token') + expect((config as RequestInit)?.headers).toHaveProperty('Authorization', 'Bearer token') expect(result).toEqual(mockUploadResult) }) @@ -64,7 +71,7 @@ describe('TodoistApi uploads', () => { }) expect(mockedFs.createReadStream).toHaveBeenCalledWith('/path/to/document.pdf') - expect(mockedAxios.post).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledTimes(1) expect(result).toEqual(mockUploadResult) }) @@ -80,7 +87,7 @@ describe('TodoistApi uploads', () => { }) expect(mockedFs.createReadStream).toHaveBeenCalledWith('/path/to/document.pdf') - expect(mockedAxios.post).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledTimes(1) }) test('uploads file from stream with fileName', async () => { @@ -92,7 +99,7 @@ describe('TodoistApi uploads', () => { fileName: 'stream-file.pdf', }) - expect(mockedAxios.post).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledTimes(1) }) test.each([ @@ -132,9 +139,9 @@ describe('TodoistApi uploads', () => { requestId, ) - expect(mockedAxios.post).toHaveBeenCalledTimes(1) - const [, , config] = mockedAxios.post.mock.calls[0] - expect(config?.headers?.['X-Request-Id']).toBe(requestId) + expect(mockFetch).toHaveBeenCalledTimes(1) + const [, config] = mockFetch.mock.calls[0] + expect((config as RequestInit)?.headers).toHaveProperty('X-Request-Id', requestId) }) test('uploads file without projectId', async () => { @@ -146,7 +153,7 @@ describe('TodoistApi uploads', () => { fileName: 'test.pdf', }) - expect(mockedAxios.post).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledTimes(1) }) }) diff --git a/src/types/http.ts b/src/types/http.ts index 5e77a9d..93aaf26 100644 --- a/src/types/http.ts +++ b/src/types/http.ts @@ -1 +1,89 @@ export type HttpMethod = 'POST' | 'GET' | 'DELETE' | 'PUT' + +/** + * HTTP response type that replaces AxiosResponse + */ +export type HttpResponse = { + data: T + status: number + statusText: string + headers: Record +} + +/** + * HTTP request configuration + */ +export type HttpRequestConfig = { + method?: HttpMethod + headers?: Record + timeout?: number + signal?: AbortSignal +} + +/** + * Internal HTTP request options + */ +export type HttpRequestOptions = HttpRequestConfig & { + url: string + params?: Record + data?: unknown +} + +/** + * Configuration for retry behavior + */ +export type RetryConfig = { + retries: number + retryCondition: (error: Error) => boolean + retryDelay: (retryNumber: number) => number +} + +/** + * Configuration for the HTTP client + */ +export type HttpClientConfig = { + baseURL?: string + headers?: Record + timeout?: number + retry?: RetryConfig +} + +/** + * Network error type for retry logic + */ +export type NetworkError = Error & { + code?: string + isNetworkError: true +} + +/** + * HTTP error with status code and response data + */ +export type HttpError = Error & { + status?: number + statusText?: string + response?: HttpResponse + data?: unknown +} + +/** + * Type guard to check if an error is a network error + */ +export function isNetworkError(error: Error): error is NetworkError { + // Network errors in fetch are typically TypeError with specific messages + return ( + (error instanceof TypeError && + (error.message.includes('fetch') || + error.message.includes('network') || + error.message.includes('Failed to fetch') || + error.message.includes('NetworkError'))) || + (error as NetworkError).isNetworkError === true + ) +} + +/** + * Type guard to check if an error is an HTTP error + */ +export function isHttpError(error: Error): error is HttpError { + return 'status' in error && typeof (error as HttpError).status === 'number' +} diff --git a/src/utils/case-conversion.ts b/src/utils/case-conversion.ts new file mode 100644 index 0000000..ee82806 --- /dev/null +++ b/src/utils/case-conversion.ts @@ -0,0 +1,71 @@ +import camelcase from 'camelcase' +import emojiRegex from 'emoji-regex' + +/** + * Checks if a string is a solitary emoji + */ +function isEmojiKey(input: string): boolean { + const regex = emojiRegex() + const match = input.match(regex) + return match !== null && match.join('') === input +} + +/** + * Custom camelCase function that preserves emoji strings + */ +export function customCamelCase(input: string): string { + // If the value is a solitary emoji string, return the key as-is + if (isEmojiKey(input)) { + return input + } + return camelcase(input) +} + +/** + * Converts object keys from snake_case to camelCase recursively + */ +export function camelCaseKeys(obj: T): T { + if (obj === null || obj === undefined) { + return obj + } + + if (Array.isArray(obj)) { + return obj.map((item: unknown) => camelCaseKeys(item)) as T + } + + if (typeof obj === 'object' && obj.constructor === Object) { + const converted: Record = {} + for (const [key, value] of Object.entries(obj)) { + const camelKey = customCamelCase(key) + converted[camelKey] = camelCaseKeys(value) + } + return converted as T + } + + return obj +} + +/** + * Converts object keys from camelCase to snake_case recursively + */ +export function snakeCaseKeys(obj: T): T { + if (obj === null || obj === undefined) { + return obj + } + + if (Array.isArray(obj)) { + return obj.map((item: unknown) => snakeCaseKeys(item)) as T + } + + if (typeof obj === 'object' && obj.constructor === Object) { + const converted: Record = {} + for (const [key, value] of Object.entries(obj)) { + // Convert camelCase to snake_case + const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase() + converted[snakeKey] = snakeCaseKeys(value) + } + return converted as T + } + + return obj +} diff --git a/src/utils/fetch-with-retry.ts b/src/utils/fetch-with-retry.ts new file mode 100644 index 0000000..c9f7ec6 --- /dev/null +++ b/src/utils/fetch-with-retry.ts @@ -0,0 +1,169 @@ +import type { HttpResponse, RetryConfig } from '../types/http' +import { isNetworkError } from '../types/http' + +/** + * Default retry configuration matching the original axios-retry behavior + */ +const DEFAULT_RETRY_CONFIG: RetryConfig = { + retries: 3, + retryCondition: isNetworkError, + retryDelay: (retryNumber: number) => { + // First retry: immediate (0ms delay) + // Subsequent retries: 500ms delay + return retryNumber === 1 ? 0 : 500 + }, +} + +/** + * Converts Headers object to a plain object + */ +function headersToObject(headers: Headers): Record { + const result: Record = {} + headers.forEach((value, key) => { + result[key] = value + }) + return result +} + +/** + * Creates an AbortSignal that times out after the specified duration + */ +function createTimeoutSignal(timeoutMs: number, existingSignal?: AbortSignal): AbortSignal { + const controller = new AbortController() + + // Timeout logic + const timeoutId = setTimeout(() => { + controller.abort(new Error(`Request timeout after ${timeoutMs}ms`)) + }, timeoutMs) + + // If there's an existing signal, forward its abort + if (existingSignal) { + if (existingSignal.aborted) { + clearTimeout(timeoutId) + controller.abort(existingSignal.reason) + } else { + existingSignal.addEventListener( + 'abort', + () => { + clearTimeout(timeoutId) + controller.abort(existingSignal.reason) + }, + { once: true }, + ) + } + } + + // Clean up timeout when request completes + controller.signal.addEventListener('abort', () => { + clearTimeout(timeoutId) + }) + + return controller.signal +} + +/** + * Performs a fetch request with retry logic and timeout support + */ +export async function fetchWithRetry(args: { + url: string + options?: RequestInit & { timeout?: number } + retryConfig?: Partial +}): Promise> { + const { url, options = {}, retryConfig = {} } = args + const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig } + const { timeout, signal: userSignal, ...fetchOptions } = options + + let lastError: Error | undefined + + for (let attempt = 0; attempt <= config.retries; attempt++) { + try { + // Set up timeout and signal handling + let requestSignal = userSignal || undefined + if (timeout && timeout > 0) { + requestSignal = createTimeoutSignal(timeout, requestSignal) + } + + const response = await fetch(url, { + ...fetchOptions, + signal: requestSignal, + }) + + // Check if the response is successful + if (!response.ok) { + const errorMessage = `HTTP ${response.status}: ${response.statusText}` + const error = new Error(errorMessage) as Error & { + status: number + statusText: string + response: HttpResponse + data?: unknown + } + + error.status = response.status + error.statusText = response.statusText + error.response = { + data: undefined, // Will be set below if we can parse the response + status: response.status, + statusText: response.statusText, + headers: headersToObject(response.headers), + } + + // Try to get response body for error details + try { + const responseText = await response.text() + let responseData: unknown + try { + responseData = responseText ? JSON.parse(responseText) : undefined + } catch { + responseData = responseText + } + error.data = responseData + error.response.data = responseData + } catch { + // If we can't read the response body, that's OK + } + + throw error + } + + // Parse response + const responseText = await response.text() + let data: T + try { + data = responseText ? (JSON.parse(responseText) as T) : (undefined as T) + } catch { + // If JSON parsing fails, return the raw text + data = responseText as T + } + + return { + data, + status: response.status, + statusText: response.statusText, + headers: headersToObject(response.headers), + } + } catch (error) { + lastError = error as Error + + // Check if this error should trigger a retry + const shouldRetry = attempt < config.retries && config.retryCondition(lastError) + + if (!shouldRetry) { + // Add network error flag for network errors + if (isNetworkError(lastError)) { + const networkError = lastError + networkError.isNetworkError = true + } + throw lastError + } + + // Wait before retrying + const delay = config.retryDelay(attempt + 1) + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + // This should never be reached, but just in case + throw lastError || new Error('Request failed after retries') +} diff --git a/src/utils/multipart-upload.test.ts b/src/utils/multipart-upload.test.ts index 51ca101..8a9df58 100644 --- a/src/utils/multipart-upload.test.ts +++ b/src/utils/multipart-upload.test.ts @@ -1,11 +1,10 @@ import { uploadMultipartFile } from './multipart-upload' -import axios from 'axios' import * as fs from 'fs' import { Readable } from 'stream' -// Mock axios -jest.mock('axios') -const mockedAxios = axios as jest.Mocked +// Mock fetch globally +const mockFetch = jest.fn() +global.fetch = mockFetch as unknown as typeof fetch // Mock fs jest.mock('fs') @@ -15,11 +14,21 @@ describe('uploadMultipartFile', () => { const baseUrl = 'https://api.todoist.com/api/v1/' const authToken = 'test-token' const endpoint = 'test-endpoint' - const mockResponse = { data: { fileUrl: 'https://example.com/file.pdf' } } + const mockResponseData = { fileUrl: 'https://example.com/file.pdf' } beforeEach(() => { jest.clearAllMocks() - mockedAxios.post.mockResolvedValue(mockResponse) + + // Mock successful fetch response + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Map([['content-type', 'application/json']]), + text: jest.fn().mockResolvedValue(JSON.stringify(mockResponseData)), + json: jest.fn().mockResolvedValue(mockResponseData), + } + mockFetch.mockResolvedValue(mockResponse as unknown as Response) }) describe('file path uploads', () => { @@ -38,13 +47,16 @@ describe('uploadMultipartFile', () => { }) expect(mockedFs.createReadStream).toHaveBeenCalledWith('/path/to/document.pdf') - expect(mockedAxios.post).toHaveBeenCalledTimes(1) - expect(result).toEqual(mockResponse.data) + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(result).toEqual(mockResponseData) - const [url, , config] = mockedAxios.post.mock.calls[0] + const [url, config] = mockFetch.mock.calls[0] expect(url).toBe(`${baseUrl}${endpoint}`) - expect(config?.headers?.Authorization).toBe('Bearer test-token') - expect(config?.headers?.['X-Request-Id']).toBe('req-123') + expect((config as RequestInit)?.headers).toHaveProperty( + 'Authorization', + 'Bearer test-token', + ) + expect((config as RequestInit)?.headers).toHaveProperty('X-Request-Id', 'req-123') }) test('uploads file from path with custom fileName', async () => { @@ -61,7 +73,7 @@ describe('uploadMultipartFile', () => { }) expect(mockedFs.createReadStream).toHaveBeenCalledWith('/path/to/document.pdf') - expect(mockedAxios.post).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledTimes(1) }) }) @@ -78,12 +90,15 @@ describe('uploadMultipartFile', () => { additionalFields: { workspace_id: 456 }, }) - expect(mockedAxios.post).toHaveBeenCalledTimes(1) - expect(result).toEqual(mockResponse.data) + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(result).toEqual(mockResponseData) - const [url, , config] = mockedAxios.post.mock.calls[0] + const [url, config] = mockFetch.mock.calls[0] expect(url).toBe(`${baseUrl}${endpoint}`) - expect(config?.headers?.Authorization).toBe('Bearer test-token') + expect((config as RequestInit)?.headers).toHaveProperty( + 'Authorization', + 'Bearer test-token', + ) }) test('throws error when Buffer provided without fileName', async () => { @@ -115,8 +130,8 @@ describe('uploadMultipartFile', () => { additionalFields: { delete: true }, }) - expect(mockedAxios.post).toHaveBeenCalledTimes(1) - expect(result).toEqual(mockResponse.data) + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(result).toEqual(mockResponseData) }) test('throws error when stream provided without fileName', async () => { @@ -163,7 +178,7 @@ describe('uploadMultipartFile', () => { additionalFields: additionalFields, }) - expect(mockedAxios.post).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledTimes(1) // We can't easily test FormData contents, but we verified the method doesn't throw }) @@ -179,7 +194,7 @@ describe('uploadMultipartFile', () => { additionalFields: {}, }) - expect(mockedAxios.post).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledTimes(1) }) }) @@ -196,8 +211,11 @@ describe('uploadMultipartFile', () => { additionalFields: {}, }) - const [, , config] = mockedAxios.post.mock.calls[0] - expect(config?.headers?.Authorization).toBe('Bearer test-token') + const [, config] = mockFetch.mock.calls[0] + expect((config as RequestInit)?.headers).toHaveProperty( + 'Authorization', + 'Bearer test-token', + ) // FormData.getHeaders() is mocked, so we can't test specific multipart headers }) @@ -213,8 +231,8 @@ describe('uploadMultipartFile', () => { additionalFields: {}, }) - const [, , config] = mockedAxios.post.mock.calls[0] - expect(config?.headers?.['X-Request-Id']).toBeUndefined() + const [, config] = mockFetch.mock.calls[0] + expect((config as RequestInit)?.headers).not.toHaveProperty('X-Request-Id') }) }) }) diff --git a/src/utils/multipart-upload.ts b/src/utils/multipart-upload.ts index 860994a..665f760 100644 --- a/src/utils/multipart-upload.ts +++ b/src/utils/multipart-upload.ts @@ -1,7 +1,8 @@ import FormData from 'form-data' import { createReadStream } from 'fs' import { basename } from 'path' -import axios, { type AxiosResponse } from 'axios' +import { fetchWithRetry } from './fetch-with-retry' +import type { HttpResponse } from '../types/http' type UploadMultipartFileArgs = { baseUrl: string @@ -127,8 +128,16 @@ export async function uploadMultipartFile(args: UploadMultipartFileArgs): Pro headers['X-Request-Id'] = requestId } - // Make the request using axios - const response: AxiosResponse = await axios.post(url, form, { headers }) + // Make the request using fetch + const response: HttpResponse = await fetchWithRetry({ + url, + options: { + method: 'POST', + body: form as unknown as BodyInit, // FormData from 'form-data' package is compatible with fetch + headers, + timeout: 30000, // 30 second timeout for file uploads + }, + }) return response.data }