diff --git a/package-lock.json b/package-lock.json index 5835b85..e72d938 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "eslint": "^10.1.0", "eslint-plugin-vuetify": "^2.7.2", "happy-dom": "^20.0.10", + "jsdom": "^29.1.1", "npm-run-all2": "^8.0.4", "pinia": "^2.3.1", "sass": "^1.77.8", @@ -51,11 +52,63 @@ "unplugin-vue-router": "^0.19.0", "vite": "^7.3.2", "vite-plugin-vuetify": "^2.1.2", + "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.0.15", "vue-router": "^4.6.3", "vue-tsc": "^3.2.6" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/generator": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", @@ -119,6 +172,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.1.tgz", @@ -126,6 +192,146 @@ "devOptional": true, "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -722,6 +928,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2680,6 +2904,16 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -2880,6 +3114,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2899,6 +3147,30 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2916,6 +3188,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3644,6 +3923,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3743,6 +4029,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3846,6 +4145,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3883,6 +4189,67 @@ "node": ">=0.1.90" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4018,6 +4385,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4052,6 +4429,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -4395,6 +4779,32 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -4671,6 +5081,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requireindex": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", @@ -5211,6 +5631,19 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scule": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", @@ -5428,6 +5861,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/sync-child-process": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", @@ -5530,6 +5970,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5542,6 +6002,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -5555,6 +6041,27 @@ "typescript": ">=4.8.4" } }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5633,6 +6140,16 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -6133,6 +6650,21 @@ "vuetify": "^3.0.0" } }, + "node_modules/vite-tsconfig-paths": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.1.1.tgz", + "integrity": "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + } + }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -6422,6 +6954,39 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -6439,6 +7004,21 @@ "node": ">=12" } }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6521,6 +7101,13 @@ "node": ">=12" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", diff --git a/package.json b/package.json index 950e404..caf6665 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "eslint": "^10.1.0", "eslint-plugin-vuetify": "^2.7.2", "happy-dom": "^20.0.10", + "jsdom": "^29.1.1", "npm-run-all2": "^8.0.4", "pinia": "^2.3.1", "sass": "^1.77.8", @@ -57,6 +58,7 @@ "unplugin-vue-router": "^0.19.0", "vite": "^7.3.2", "vite-plugin-vuetify": "^2.1.2", + "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.0.15", "vue-router": "^4.6.3", "vue-tsc": "^3.2.6" diff --git a/src/api/clients/api.client.ts b/src/api/clients/api.client.ts new file mode 100644 index 0000000..e19c91c --- /dev/null +++ b/src/api/clients/api.client.ts @@ -0,0 +1,9 @@ +import axios from "axios"; + +export const apiClient = axios.create({ + baseURL: import.meta.env.VITE_SPOTIFY_ENDPOINT, + timeout: 10_000, + headers: { + "Content-Type": "application/x-www-form-urlencoded" + } +}); diff --git a/src/api/clients.ts b/src/api/clients/auth.client.ts similarity index 53% rename from src/api/clients.ts rename to src/api/clients/auth.client.ts index 8157acc..c5b9ba5 100644 --- a/src/api/clients.ts +++ b/src/api/clients/auth.client.ts @@ -7,11 +7,3 @@ export const authApiClient = axios.create({ "Content-Type": "application/x-www-form-urlencoded" } }); - -export const apiClient = axios.create({ - baseURL: import.meta.env.VITE_SPOTIFY_ENDPOINT, - timeout: 10_000, - headers: { - "Content-Type": "application/x-www-form-urlencoded" - } -}); diff --git a/src/api/clients/index.ts b/src/api/clients/index.ts new file mode 100644 index 0000000..0943d75 --- /dev/null +++ b/src/api/clients/index.ts @@ -0,0 +1,2 @@ +export { apiClient } from "./api.client"; +export { authApiClient } from "./auth.client"; diff --git a/src/api/index.ts b/src/api/index.ts index 2613971..2bb6cff 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,2 +1,2 @@ -import "./interceptors"; -import "./clients"; +export { setupInterceptors } from "./setup.interceptors"; +export * from "./clients"; diff --git a/src/api/interceptors/auth.interceptor.ts b/src/api/interceptors/auth.interceptor.ts new file mode 100644 index 0000000..0cec5c3 --- /dev/null +++ b/src/api/interceptors/auth.interceptor.ts @@ -0,0 +1,16 @@ +import type { InternalAxiosRequestConfig } from "axios"; + +type AuthStoreLike = { + accessToken: string | undefined; +}; + +export const createAuthRequestInterceptor = + (authStore: AuthStoreLike) => + (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { + if (authStore.accessToken) { + config.headers = config.headers ?? {}; + config.headers.Authorization = `Bearer ${authStore.accessToken}`; + } + + return config; + }; diff --git a/src/api/interceptors/auth.ts b/src/api/interceptors/auth.ts deleted file mode 100644 index 70dad00..0000000 --- a/src/api/interceptors/auth.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { apiClient } from "../clients"; -import { useAuthStore } from "@/stores/auth"; - -apiClient.interceptors.request.use( - (config) => { - const authStore = useAuthStore(); - - if (authStore.accessToken) { - config.headers.Authorization = `Bearer ${authStore.accessToken}`; - } - - return config; - }, - (error) => Promise.reject(error) -); diff --git a/src/api/interceptors/index.ts b/src/api/interceptors/index.ts index 312e19c..6bebf41 100644 --- a/src/api/interceptors/index.ts +++ b/src/api/interceptors/index.ts @@ -1,3 +1,3 @@ -import "./refresh-token" -import "./rate-limit" -import "./auth" \ No newline at end of file +export * from "./auth.interceptor"; +export * from "./refresh-token.interceptor"; +export * from "./rate-limit.interceptor"; diff --git a/src/api/interceptors/rate-limit.interceptor.ts b/src/api/interceptors/rate-limit.interceptor.ts new file mode 100644 index 0000000..75b4a95 --- /dev/null +++ b/src/api/interceptors/rate-limit.interceptor.ts @@ -0,0 +1,38 @@ +import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios"; + +export const createRateLimitInterceptor = + (client: AxiosInstance) => + async (error: AxiosError) => { + const config = error.config as InternalAxiosRequestConfig | undefined; + + if (!config || !error.response) { + return Promise.reject(error); + } + + if (error.response.status !== 429) { + return Promise.reject(error); + } + + const method = config.method?.toLowerCase(); + + const safeMethods = ["get", "head", "put", "delete"]; + + if (!method || !safeMethods.includes(method)) { + return Promise.reject(error); + } + + const retryAfterSecs = parseInt( + error.response.headers["retry-after"], + 10 + ); + + const delayMs = + (Number.isFinite(retryAfterSecs) ? retryAfterSecs : 1) * 1000; + + const wait = (ms: number) => + new Promise((res) => setTimeout(res, ms)); + + await wait(Math.min(delayMs, 120_000)); + + return client.request(config); + }; diff --git a/src/api/interceptors/rate-limit.ts b/src/api/interceptors/rate-limit.ts deleted file mode 100644 index 906f113..0000000 --- a/src/api/interceptors/rate-limit.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { apiClient } from "../clients"; - -apiClient.interceptors.response.use( - async (response) => { - return response - }, - async (error) => { - const config = error.config || {}; - - if (error.response?.status === 429) { - const safeMethods = ['get', 'head', 'put', 'delete']; - if (!config.method || !safeMethods.includes(config.method.toLowerCase())) { - return Promise.reject(error); - } - - const wait = (ms: number) => new Promise((res) => setTimeout(res, ms)); - - const retryAfterSecs = parseInt(error.response.headers['retry-after'], 10) || 1; - const delayMs = Math.min(retryAfterSecs, 120) * 1000; - - await wait(delayMs); - - return apiClient.request(config); - } - } -); diff --git a/src/api/interceptors/refresh-token.interceptor.ts b/src/api/interceptors/refresh-token.interceptor.ts new file mode 100644 index 0000000..ddab8df --- /dev/null +++ b/src/api/interceptors/refresh-token.interceptor.ts @@ -0,0 +1,45 @@ +import type { AxiosError, AxiosInstance } from "axios"; + +type AuthStoreLike = { + accessToken: string | undefined; + refreshToken: string | undefined; + accessTokenExpired: boolean; + refreshAccessToken: () => Promise; +}; + +type RouterLike = { + push: (...args: any[]) => any; +}; + +export const createRefreshTokenInterceptor = + (client: AxiosInstance, authStore: AuthStoreLike, router: RouterLike) => + async (error: AxiosError) => { + const status = error.response?.status; + + if (status !== 401 && status !== 403) { + return Promise.reject(error); + } + + if (!authStore.refreshToken && !authStore.accessToken) { + await router.push({ name: "/" }); + return Promise.reject(error); + } + + if (authStore.accessTokenExpired && authStore.refreshToken) { + await authStore.refreshAccessToken(); + + const config = error.config; + + if (!config) { + return Promise.reject(error); + } + + config.headers = config.headers ?? {}; + config.headers.Authorization = `Bearer ${authStore.accessToken}`; + + return client.request(config); + } + + await router.push({ name: "/" }); + return Promise.reject(error); + }; diff --git a/src/api/interceptors/refresh-token.ts b/src/api/interceptors/refresh-token.ts deleted file mode 100644 index 6f9ad6e..0000000 --- a/src/api/interceptors/refresh-token.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { apiClient } from "../clients"; -import { useAuthStore } from "@/stores/auth"; -import router from "@/router"; - -apiClient.interceptors.response.use( - async (response) => { - return response - }, - async (error) => { - const authStore = useAuthStore(); - - if (error.response?.status === 401 || error.response?.status === 403) { - if (!authStore.refreshToken && !authStore.accessToken) { - router.push({ name: '/' }) - return Promise.reject(error); - } - if (authStore.accessTokenExpired && authStore.refreshToken) { - await authStore.refreshAccessToken(); - - error.config.headers.Authorization = `Bearer ${authStore.accessToken}`; - - return apiClient.request(error.config); - } - router.push({ name: '/' }) - return Promise.reject(error); - } - } -); diff --git a/src/api/setup.interceptors.ts b/src/api/setup.interceptors.ts new file mode 100644 index 0000000..7a0cf38 --- /dev/null +++ b/src/api/setup.interceptors.ts @@ -0,0 +1,12 @@ +import { useAuthStore } from "@/stores/auth"; +import { apiClient } from "./clients/api.client"; +import { createAuthRequestInterceptor, createRateLimitInterceptor, createRefreshTokenInterceptor, } from "./interceptors"; +import router from "@/router"; + +export function setupInterceptors(): void { + const authStore = useAuthStore(); + + apiClient.interceptors.request.use(createAuthRequestInterceptor(authStore)); + apiClient.interceptors.response.use((res) => res, createRefreshTokenInterceptor(apiClient, authStore, router)) + apiClient.interceptors.response.use((res) => res, createRateLimitInterceptor(apiClient)); +} diff --git a/src/components.d.ts b/src/components.d.ts index 0386c36..ff0cf3b 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -1,4 +1,4 @@ -/* eslint-disable */ + // @ts-nocheck // biome-ignore lint: disable // oxlint-disable diff --git a/src/main.ts b/src/main.ts index 8846124..aab64e8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,10 +31,12 @@ import Cassette from "./components/cassette/Cassette.vue"; // Composables import { createApp } from 'vue' +import { setupInterceptors } from "./api"; const app = createApp(App) registerPlugins(app) +setupInterceptors(); app.component('cassette-side', CassetteSide) app.component('playlist-list', PlaylistsList) diff --git a/src/stores/album.ts b/src/stores/album.ts index 5aa4dbb..c52c764 100644 --- a/src/stores/album.ts +++ b/src/stores/album.ts @@ -4,7 +4,7 @@ import type { GetAlbumResponse, GetAlbumTracksResponse, SearchResponse } from '@ import { ParseAlbumTrackDTO } from '@/parsers/trackDtoParser' import { GetSmallestImage } from '@/utils/images/imageUtils' import { useCassettesStore } from './cassette' -import { apiClient } from '@/api/clients' +import { apiClient } from '@/api/' import { useProfileStore } from './profile' import type { AlbumDTO } from '@/types/spotify/dto' import { ParseAlbumDTO } from '@/parsers/albumDtoParser' diff --git a/src/stores/auth.spec.ts b/src/stores/auth.spec.ts index 5cb15fb..0e4ba0d 100644 --- a/src/stores/auth.spec.ts +++ b/src/stores/auth.spec.ts @@ -1,13 +1,29 @@ -import { authApiClient } from '@/api/clients'; -import type { TokenResponse } from '@/types/spotify/responses'; -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { useAuthStore } from './auth'; -import { createPinia, setActivePinia } from 'pinia'; +import { authApiClient } from '@/api' +import type { TokenResponse } from '@/types/spotify/responses' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { useAuthStore } from './auth' +import { createPinia, setActivePinia } from 'pinia' +import { ref } from 'vue' -vi.mock('@/api/clients'); vi.mock('../router', () => ({ - default: { push: vi.fn() } -})); + default: { push: vi.fn() }, +})) + +vi.mock('@/api', () => ({ + authApiClient: { + post: vi.fn(), + }, +})) + +vi.mock('@vueuse/core', () => { + return { + useStorage: (key: string, initial: any, storage?: Storage) => { + return ref(initial) + }, + } +}) + +const mockedPost = vi.mocked(authApiClient.post) describe('Auth store', () => { const tokenResponseMock: TokenResponse = { @@ -15,139 +31,145 @@ describe('Auth store', () => { token_type: 'access_token', scope: 'user-read-private user-read-email', expires_in: 3600, - refresh_token: '9876543210' - }; + refresh_token: '9876543210', + } - const mockTime = new Date('2025-12-04T00:00:00Z'); - const postSpy = vi.spyOn(authApiClient, 'post'); + const mockTime = new Date('2025-12-04T00:00:00Z') beforeEach(() => { - setActivePinia(createPinia()); - - vi.useFakeTimers(); - vi.setSystemTime(mockTime); + setActivePinia(createPinia()) - vi.mocked(authApiClient.post).mockResolvedValue({ data: tokenResponseMock }); + vi.useFakeTimers() + vi.setSystemTime(mockTime) - const authStore = useAuthStore(); - authStore._generateCodeVerifier = vi.fn().mockReturnValue('test-code-verifier'); - authStore._hashCodeVerifier = vi.fn().mockResolvedValue(new ArrayBuffer(32)); - authStore._generateCodeChallenge = vi.fn().mockReturnValue('test-code-challenge'); - }); + mockedPost.mockResolvedValue({ data: tokenResponseMock } as any) + }) afterEach(() => { - vi.clearAllMocks(); - vi.useRealTimers(); - }); + vi.clearAllMocks() + vi.useRealTimers() + + }) describe('requestAccessToken', () => { - it('successfully requests access token and calls API with correct args', async () => { - const authStore = useAuthStore(); - const code = 'secretcode'; + it('requests access token and updates store correctly', async () => { + const authStore = useAuthStore() + const code = 'secretcode' - expect(authStore.accessToken).not.toBeDefined(); - expect(authStore.refreshToken).not.toBeDefined(); - expect(authStore.expiresAt).not.toBeDefined(); + expect(authStore.accessToken).toBeUndefined() + expect(authStore.refreshToken).toBeUndefined() + expect(authStore.expiresAt).toBeUndefined() - authStore.codeVerifier = 'test-code-verifier'; + authStore.codeVerifier = 'test-code-verifier' - await authStore.requestAccessToken(code); + await authStore.requestAccessToken(code) - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy).toHaveBeenCalledWith( + expect(mockedPost).toHaveBeenCalledTimes(1) + + expect(mockedPost).toHaveBeenCalledWith( '/api/token', - 'grant_type=authorization_code&code=secretcode&redirect_uri=http%3A%2F%2F127.0.0.1%3A5173%2FTapeify%2Fcallback&code_verifier=test-code-verifier&client_id=0123456789', + expect.stringContaining('grant_type=authorization_code'), { headers: { - Authorization: `Basic ${btoa('0123456789:9876543210')}` - } + 'Content-Type': 'application/x-www-form-urlencoded', + }, } - ); - - expect(authStore.accessToken).toEqual(tokenResponseMock.access_token); - expect(authStore.refreshToken).toEqual(tokenResponseMock.refresh_token); - const expectedTime = mockTime.getTime() + tokenResponseMock.expires_in * 1000; - expect(authStore.expiresAt).toEqual(expectedTime); - }); - }); + ) + + expect(authStore.accessToken).toBe(tokenResponseMock.access_token) + expect(authStore.refreshToken).toBe(tokenResponseMock.refresh_token) + + const expectedTime = + mockTime.getTime() + tokenResponseMock.expires_in * 1000 + + expect(authStore.expiresAt).toBe(expectedTime) + }) + }) + describe('refreshAccessToken', () => { - it('successfully refresh access token and calls API with correct args', async () => { - const authStore = useAuthStore(); + it('refreshes access token correctly', async () => { + const authStore = useAuthStore() - authStore.refreshToken = '9876543210'; + authStore.refreshToken = '9876543210' - expect(authStore.accessToken).not.toBeDefined(); - expect(authStore.refreshToken).toBeDefined(); - expect(authStore.expiresAt).not.toBeDefined(); + expect(authStore.accessToken).toBeUndefined() + expect(authStore.refreshToken).toBeDefined() + expect(authStore.expiresAt).toBeUndefined() - await authStore.refreshAccessToken(); + await authStore.refreshAccessToken() - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy).toHaveBeenCalledWith( + expect(mockedPost).toHaveBeenCalledTimes(1) + + expect(mockedPost).toHaveBeenCalledWith( '/api/token', - 'grant_type=refresh_token&refresh_token=9876543210&client_id=0123456789', + expect.stringContaining('grant_type=refresh_token'), { headers: { - Authorization: `Basic ${btoa('0123456789:9876543210')}` - } + Authorization: `Basic ${btoa( + `${import.meta.env.VITE_CLIENT_ID}:${import.meta.env.VITE_CLIENT_SECRET}` + )}`, + }, } - ); - - expect(authStore.accessToken).toEqual(tokenResponseMock.access_token); - expect(authStore.refreshToken).toEqual(tokenResponseMock.refresh_token); - const expectedTime = mockTime.getTime() + tokenResponseMock.expires_in * 1000; - expect(authStore.expiresAt).toEqual(expectedTime); - }); - }); + ) + + expect(authStore.accessToken).toBe(tokenResponseMock.access_token) + expect(authStore.refreshToken).toBe(tokenResponseMock.refresh_token) + + const expectedTime = + mockTime.getTime() + tokenResponseMock.expires_in * 1000 + + expect(authStore.expiresAt).toBe(expectedTime) + }) + }) + describe('accessTokenExpired', () => { - it('undefined expires at state', async () => { - const authStore = useAuthStore(); - authStore.expiresAt = undefined; - - expect(authStore.expiresAt).not.toBeDefined(); - expect(authStore.accessTokenExpired).toBe(true); - }); - it('is expired', async () => { - const authStore = useAuthStore(); - authStore.expiresAt = mockTime.getTime() - 1000; - - expect(authStore.expiresAt).toBeDefined(); - expect(authStore.accessTokenExpired).toBe(true); - }); - it('not expired', async () => { - const authStore = useAuthStore(); - authStore.expiresAt = mockTime.getTime() + 1000; - - expect(authStore.expiresAt).toBeDefined(); - expect(authStore.accessTokenExpired).toBe(false); - }); - }); -describe('userAuthorizationUrl', async () => { - it('builds a correct Spotify authorize URL when expiresAt is undefined', async() => { - const authStore = useAuthStore(); - - const url = await authStore.generateUserAuthorizationUrl(); - - expect(url.origin + url.pathname).toBe('https://accounts.spotify.com/authorize'); - - const params = url.searchParams; - expect(params.get('response_type')).toBe('code'); - expect(params.get('client_id')).toBe('0123456789'); - expect(params.get('redirect_uri')).toBe('http://127.0.0.1:5173/Tapeify/callback'); - expect(params.get('code_challenge_method')).toBe('S256'); - expect(params.get('code_challenge')).toBe('test-code-challenge'); - - const scope = params.get('scope') || ''; - const scopes = scope.split(/\s+/).filter(Boolean); - expect(scopes).toEqual(expect.arrayContaining([ - 'user-read-private', - 'user-read-email', - 'playlist-read-private', - 'playlist-modify-public', - 'playlist-modify-private' - ])); - - expect(params.has('expires_at')).toBe(false); - }); -}); -}); + it('returns true when expiresAt is undefined', () => { + const authStore = useAuthStore() + authStore.expiresAt = undefined + expect(authStore.accessTokenExpired).toBe(true) + }) + + it('returns true when expired', () => { + const authStore = useAuthStore() + authStore.expiresAt = mockTime.getTime() - 1000 + expect(authStore.accessTokenExpired).toBe(true) + }) + + it('returns false when not expired', () => { + const authStore = useAuthStore() + authStore.expiresAt = mockTime.getTime() + 1000 + expect(authStore.accessTokenExpired).toBe(false) + }) + }) + + describe('generateUserAuthorizationUrl', () => { + it('builds correct Spotify authorization URL', async () => { + const authStore = useAuthStore() + + // mock crypto-dependent functions + authStore._generateCodeVerifier = vi.fn().mockReturnValue('verifier') + authStore._hashCodeVerifier = vi + .fn() + .mockResolvedValue(new ArrayBuffer(32)) + authStore._generateCodeChallenge = vi.fn().mockReturnValue('challenge') + + const url = await authStore.generateUserAuthorizationUrl() + + expect(url.origin + url.pathname).toBe( + 'https://accounts.spotify.com/authorize' + ) + + const params = url.searchParams + + expect(params.get('response_type')).toBe('code') + expect(params.get('client_id')).toBe(import.meta.env.VITE_CLIENT_ID) + expect(params.get('redirect_uri')).toBe( + import.meta.env.VITE_REDIRECT_URI + ) + expect(params.get('code_challenge_method')).toBe('S256') + expect(params.get('code_challenge')).toBe('challenge') + + expect(params.has('expires_at')).toBe(false) + }) + }) +}) \ No newline at end of file diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 23de996..6215317 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -1,4 +1,4 @@ -import { authApiClient } from '@/api/clients'; +import { authApiClient } from '@/api'; import type { TokenResponse } from '@/types/spotify/responses' import { useStorage } from '@vueuse/core' import { defineStore } from 'pinia' @@ -8,8 +8,8 @@ export const useAuthStore = defineStore('auth', { state: () => ({ accessToken: useStorage('access_token', undefined), refreshToken: useStorage('refresh_token', undefined), - expiresAt: useStorage('expires_in', undefined), - codeVerifier: useStorage('code_verifier', undefined), + expiresAt: useStorage('expires_at', undefined), + codeVerifier: useStorage('code_verifier', undefined, sessionStorage), }), getters: { accessTokenExpired(): boolean { @@ -35,47 +35,40 @@ export const useAuthStore = defineStore('auth', { return url }, async requestAccessToken(code: string): Promise { - const client_id = import.meta.env.VITE_CLIENT_ID - - const body = qs.stringify({ - grant_type: "authorization_code", - code, - redirect_uri: import.meta.env.VITE_REDIRECT_URI, - code_verifier: this.codeVerifier, - client_id: client_id - }); - const response = await authApiClient.post( "/api/token", - body, + qs.stringify({ + grant_type: "authorization_code", + code, + redirect_uri: import.meta.env.VITE_REDIRECT_URI, + code_verifier: this.codeVerifier, + client_id: import.meta.env.VITE_CLIENT_ID, + }), { headers: { - "Authorization": `Basic ${btoa( - `${client_id}:${import.meta.env.VITE_CLIENT_SECRET}` - )}` - } + "Content-Type": "application/x-www-form-urlencoded", + }, } ); this.accessToken = response.data.access_token; this.refreshToken = response.data.refresh_token; this.expiresAt = Date.now() + response.data.expires_in * 1000 + this.codeVerifier = undefined + sessionStorage.removeItem('code_verifier') }, async refreshAccessToken(): Promise { - const client_id = import.meta.env.VITE_CLIENT_ID - const body = qs.stringify({ - grant_type: "refresh_token", - refresh_token: this.refreshToken, - client_id: client_id, - }); - const response = await authApiClient.post( "/api/token", - body, + qs.stringify({ + grant_type: "refresh_token", + refresh_token: this.refreshToken, + client_id: import.meta.env.VITE_CLIENT_ID, + }), { headers: { "Authorization": `Basic ${btoa( - `${client_id}:${import.meta.env.VITE_CLIENT_SECRET}` + `${import.meta.env.VITE_CLIENT_ID}:${import.meta.env.VITE_CLIENT_SECRET}` )}` } } diff --git a/src/stores/playlist.spec.ts b/src/stores/playlist.spec.ts index 944d32a..d213fd2 100644 --- a/src/stores/playlist.spec.ts +++ b/src/stores/playlist.spec.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; -import { apiClient } from '@/api/clients'; import type { GetPlaylistsResponse, GetPlaylistTracksResponse, UsersPlaylistsResponse } from '@/types/spotify/responses'; import { usePlaylistsStore } from './playlists'; +import { apiClient } from '@/api' const mockGetUserPlaylistsResponse: UsersPlaylistsResponse = { items: [], diff --git a/src/stores/playlists.ts b/src/stores/playlists.ts index 6a75def..a325beb 100644 --- a/src/stores/playlists.ts +++ b/src/stores/playlists.ts @@ -4,7 +4,7 @@ import type { CreatePlaylistResponse, GetPlaylistsResponse, GetPlaylistTracksRes import type { EpisodeDTO, PlaylistDTO, PlaylistTrackDTO } from '@/types/spotify/dto' import { ParsePlaylistTrackDTO } from '@/parsers/trackDtoParser' import { ParsePlaylistEpisodeDTO } from '@/parsers/episodeDtoParser' -import { apiClient } from '@/api/clients' +import { apiClient } from '@/api' import { ParsePlaylistDTO } from '@/parsers/playlistDtoParser' import type { Playlist, PlaylistSearchResult, Track } from '@/types/tapeify/models' import { useProfileStore } from './profile' diff --git a/src/stores/profile.ts b/src/stores/profile.ts index 615794e..842cae8 100644 --- a/src/stores/profile.ts +++ b/src/stores/profile.ts @@ -1,5 +1,5 @@ import type { GetProfileResponse } from '@/types/spotify/responses' -import { apiClient } from '@/api/clients' +import { apiClient } from '@/api' import { GetSmallestImage } from '@/utils/images/imageUtils' import { useStorage } from '@vueuse/core' import { defineStore } from 'pinia' diff --git a/src/stores/search.ts b/src/stores/search.ts index baaf1a7..036c33e 100644 --- a/src/stores/search.ts +++ b/src/stores/search.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import type { SearchResponse } from '@/types/spotify/responses' import { useProfileStore } from './profile' -import { apiClient } from '@/api/clients' +import { apiClient } from '@/api' import type { AlbumDTO, PlaylistDTO } from '@/types/spotify/dto' import { ParseAlbumDTO } from '@/parsers/albumDtoParser' import { ParsePlaylistDTO } from '@/parsers/playlistDtoParser' diff --git a/vite.config.mts b/vite.config.mts index f418850..c55ffaa 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -6,15 +6,17 @@ import Vue from '@vitejs/plugin-vue' import VueRouter from 'unplugin-vue-router/vite' import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify' import Layouts from 'vite-plugin-vue-layouts-next' +import tsconfigPaths from 'vite-tsconfig-paths' // Utilities -import { defineConfig } from 'vitest/config'; +import { defineConfig } from 'vitest/config' import { fileURLToPath, URL } from 'node:url' // https://vitejs.dev/config/ export default defineConfig({ base: '/Tapeify/', plugins: [ + tsconfigPaths(), VueRouter({ dts: 'src/typed-router.d.ts', }), diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..57a9ca1 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +// vitest.config.ts +import { defineConfig } from 'vitest/config' +import viteConfig from './vite.config.mjs' + +export default defineConfig({ + ...viteConfig, + test: { + environment: 'jsdom', + server: { + deps: { + inline: ['vuetify'], + }, + }, + }, +}) \ No newline at end of file