diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookCoverServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookCoverServiceTest.java index 7c1e41db1..02a321682 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookCoverServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/BookCoverServiceTest.java @@ -3,6 +3,7 @@ package com.adityachandel.booklore.service.metadata; import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.mapper.BookMetadataMapper; import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.dto.settings.AppSettings; import com.adityachandel.booklore.model.dto.settings.MetadataPersistenceSettings; import com.adityachandel.booklore.model.entity.AuthorEntity; import com.adityachandel.booklore.model.entity.BookEntity; @@ -26,11 +27,13 @@ import org.mockito.*; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.multipart.MultipartFile; -import java.time.Instant; -import java.util.*; import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.Set; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; class BookCoverServiceTest { @@ -243,7 +246,7 @@ class BookCoverServiceTest { assertThat(result).isNotNull(); } - private com.adityachandel.booklore.model.dto.settings.AppSettings mockAppSettings(boolean saveToOriginalFile, boolean convertCbrCb7ToCbz) { + private AppSettings mockAppSettings(boolean saveToOriginalFile, boolean convertCbrCb7ToCbz) { MetadataPersistenceSettings settings = new MetadataPersistenceSettings(); settings.setSaveToOriginalFile(saveToOriginalFile); settings.setConvertCbrCb7ToCbz(convertCbrCb7ToCbz); diff --git a/booklore-ui/angular.json b/booklore-ui/angular.json index f0336f036..7ad3647fc 100644 --- a/booklore-ui/angular.json +++ b/booklore-ui/angular.json @@ -106,25 +106,7 @@ "builder": "@angular/build:extract-i18n" }, "test": { - "builder": "@angular/build:karma", - "options": { - "polyfills": [ - "zone.js", - "zone.js/testing" - ], - "tsConfig": "tsconfig.spec.json", - "inlineStyleLanguage": "scss", - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], - "styles": [ - "src/styles.scss" - ], - "scripts": [] - } + "builder": "@angular/build:unit-test" }, "lint": { "builder": "@angular-eslint/builder:lint", diff --git a/booklore-ui/package-lock.json b/booklore-ui/package-lock.json index b4da98c36..15bd2683b 100644 --- a/booklore-ui/package-lock.json +++ b/booklore-ui/package-lock.json @@ -49,21 +49,26 @@ "@angular/compiler-cli": "^21.0.5", "@tailwindcss/typography": "^0.5.19", "@types/jasmine": "^5.1.13", + "@types/node": "^25.0.3", "@types/showdown": "^2.0.6", "angular-eslint": "^21.1.0", "autoprefixer": "^10.4.23", "eslint": "^9.39.2", "jasmine-core": "^5.13.0", - "karma": "^6.4.4", - "karma-chrome-launcher": "^3.2.0", - "karma-coverage": "^2.2.1", - "karma-jasmine": "^5.1.0", - "karma-jasmine-html-reporter": "^2.1.0", + "jsdom": "^27.4.0", "tailwindcss": "^3.4.17", "typescript": "~5.9.3", - "typescript-eslint": "^8.50.0" + "typescript-eslint": "^8.50.0", + "vitest": "^4.0.16" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.30", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", + "integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==", + "dev": true, + "license": "MIT" + }, "node_modules/@algolia/abtesting": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.6.1.tgz", @@ -800,6 +805,61 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1116,10 +1176,147 @@ "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.1.90" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz", + "integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -1809,6 +2006,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.8.0.tgz", + "integrity": "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@exodus/crypto": "^1.0.0-rc.4" + }, + "peerDependenciesMeta": { + "@exodus/crypto": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4322,14 +4537,15 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@stomp/rx-stomp": { "version": "2.3.0", @@ -4402,16 +4618,36 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4444,9 +4680,9 @@ } }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", "dependencies": { @@ -4706,6 +4942,137 @@ "vite": "^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.7.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", @@ -4977,6 +5344,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/autoprefixer": { "version": "10.4.23", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", @@ -5037,6 +5414,8 @@ "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": "^4.5.0 || >= 5.9" } @@ -5071,6 +5450,16 @@ "node": ">=14.0.0" } }, + "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/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5339,6 +5728,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5587,6 +5986,8 @@ "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", @@ -5603,6 +6004,8 @@ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -5613,6 +6016,8 @@ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.8" } @@ -5623,6 +6028,8 @@ "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", @@ -5641,7 +6048,9 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/connect/node_modules/on-finished": { "version": "2.3.0", @@ -5649,6 +6058,8 @@ "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -5662,6 +6073,8 @@ "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -5780,6 +6193,20 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css-what": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", @@ -5805,12 +6232,40 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.6.tgz", + "integrity": "sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/custom-event": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/d": { "version": "1.0.2", @@ -5825,6 +6280,20 @@ "node": ">=0.12" } }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -5841,6 +6310,8 @@ "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=4.0" } @@ -5863,6 +6334,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", @@ -5886,6 +6364,8 @@ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -5907,7 +6387,9 @@ "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/didyoumean": { "version": "1.2.2", @@ -5927,6 +6409,8 @@ "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "custom-event": "~1.0.0", "ent": "~2.2.0", @@ -6070,6 +6554,8 @@ "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", @@ -6091,6 +6577,8 @@ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=10.0.0" } @@ -6101,6 +6589,8 @@ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -6115,6 +6605,8 @@ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -6133,6 +6625,8 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -6143,6 +6637,8 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -6156,6 +6652,8 @@ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -6166,6 +6664,8 @@ "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -6188,6 +6688,8 @@ "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", @@ -6278,6 +6780,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -6663,6 +7172,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6698,7 +7217,9 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/eventsource": { "version": "3.0.7", @@ -6723,6 +7244,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -6804,7 +7335,9 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -7001,6 +7534,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=4.0" }, @@ -7050,6 +7585,8 @@ "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", @@ -7077,7 +7614,9 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -7181,6 +7720,8 @@ "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -7221,6 +7762,8 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7232,6 +7775,8 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -7301,6 +7846,8 @@ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -7346,12 +7893,18 @@ "node": "20 || >=22" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "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" + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } }, "node_modules/htmlparser2": { "version": "10.0.0", @@ -7420,6 +7973,8 @@ "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", @@ -7560,6 +8115,8 @@ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -7687,6 +8244,13 @@ "node": ">=0.12.0" } }, + "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/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -7700,6 +8264,8 @@ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", @@ -7738,6 +8304,8 @@ "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 8.0.0" }, @@ -7779,60 +8347,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jasmine-core": { "version": "5.13.0", "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.13.0.tgz", @@ -7870,6 +8384,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7940,6 +8494,8 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -7981,6 +8537,8 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -8014,139 +8572,14 @@ "node": ">= 10" } }, - "node_modules/karma-chrome-launcher": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", - "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "which": "^1.2.1" - } - }, - "node_modules/karma-chrome-launcher/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/karma-coverage": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", - "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.1", - "istanbul-reports": "^3.0.5", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/karma-coverage/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma-coverage/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/karma-coverage/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/karma-jasmine": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", - "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "jasmine-core": "^4.1.0" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "karma": "^6.0.0" - } - }, - "node_modules/karma-jasmine-html-reporter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.1.0.tgz", - "integrity": "sha512-sPQE1+nlsn6Hwb5t+HHwyy0A1FNCVKuL1192b+XNauMYWThz2kweiBVW1DqloRpVvZIJkIoHVB7XRpK78n1xbQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "jasmine-core": "^4.0.0 || ^5.0.0", - "karma": "^6.0.0", - "karma-jasmine": "^5.0.0" - } - }, - "node_modules/karma-jasmine/node_modules/jasmine-core": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", - "integrity": "sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==", - "dev": true, - "license": "MIT" - }, "node_modules/karma/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -8157,6 +8590,8 @@ "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -8182,6 +8617,8 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -8193,6 +8630,8 @@ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -8218,6 +8657,8 @@ "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -8230,6 +8671,8 @@ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8239,7 +8682,9 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/karma/node_modules/glob-parent": { "version": "5.1.2", @@ -8247,6 +8692,8 @@ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -8260,6 +8707,8 @@ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", @@ -8277,6 +8726,8 @@ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -8290,6 +8741,8 @@ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -8300,6 +8753,8 @@ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -8310,6 +8765,8 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -8320,6 +8777,8 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -8333,6 +8792,8 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -8345,7 +8806,9 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/karma/node_modules/picomatch": { "version": "2.3.1", @@ -8353,6 +8816,8 @@ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=8.6" }, @@ -8366,6 +8831,8 @@ "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dev": true, "license": "BSD-3-Clause", + "optional": true, + "peer": true, "dependencies": { "side-channel": "^1.0.6" }, @@ -8382,6 +8849,8 @@ "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -8398,6 +8867,8 @@ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -8411,6 +8882,8 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8421,6 +8894,8 @@ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.8" } @@ -8431,6 +8906,8 @@ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -8446,6 +8923,8 @@ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -8459,6 +8938,8 @@ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -8473,6 +8954,8 @@ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -8491,6 +8974,8 @@ "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -8510,6 +8995,8 @@ "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "engines": { "node": ">=10" } @@ -8789,6 +9276,8 @@ "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", "dev": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "date-format": "^4.0.14", "debug": "^4.3.4", @@ -8820,22 +9309,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/make-fetch-happen": { "version": "15.0.3", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz", @@ -8898,6 +9371,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -8961,6 +9441,8 @@ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "mime": "cli.js" }, @@ -9030,6 +9512,8 @@ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9193,6 +9677,8 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -9664,6 +10150,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -9976,6 +10473,8 @@ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10040,6 +10539,13 @@ "integrity": "sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10363,7 +10869,9 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/qjobs": { "version": "1.2.0", @@ -10371,6 +10879,8 @@ "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.9" } @@ -10523,6 +11033,8 @@ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10542,7 +11054,9 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/resolve": { "version": "1.22.11", @@ -10625,6 +11139,8 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -10771,6 +11287,8 @@ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -10811,6 +11329,19 @@ "@parcel/watcher": "^2.4.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/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -10999,6 +11530,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -11077,6 +11615,8 @@ "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", @@ -11096,6 +11636,8 @@ "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "debug": "~4.3.4", "ws": "~8.17.1" @@ -11107,6 +11649,8 @@ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -11125,6 +11669,8 @@ "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -11147,6 +11693,8 @@ "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" @@ -11161,6 +11709,8 @@ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -11179,6 +11729,8 @@ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -11193,6 +11745,8 @@ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -11211,6 +11765,8 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -11221,6 +11777,8 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -11234,6 +11792,8 @@ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -11357,6 +11917,13 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -11367,6 +11934,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -11386,6 +11960,8 @@ "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "date-format": "^4.0.14", "debug": "^4.3.4", @@ -11507,6 +12083,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/tailwindcss": { "version": "3.4.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", @@ -11683,6 +12266,23 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -11699,12 +12299,44 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=14.14" } @@ -11731,6 +12363,42 @@ "node": ">=0.6" } }, + "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/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/tr46/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -11863,6 +12531,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "bin": { "ua-parser-js": "script/cli.js" }, @@ -11919,6 +12589,8 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -11996,6 +12668,8 @@ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -12604,16 +13278,119 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/void-elements": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } }, + "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/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", @@ -12636,6 +13413,40 @@ "license": "MIT", "optional": true }, + "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/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -12652,6 +13463,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -12760,6 +13588,23 @@ } } }, + "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/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/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/booklore-ui/package.json b/booklore-ui/package.json index a5bc92046..165c55573 100644 --- a/booklore-ui/package.json +++ b/booklore-ui/package.json @@ -53,18 +53,16 @@ "@angular/compiler-cli": "^21.0.5", "@tailwindcss/typography": "^0.5.19", "@types/jasmine": "^5.1.13", + "@types/node": "^25.0.3", "@types/showdown": "^2.0.6", "angular-eslint": "^21.1.0", "autoprefixer": "^10.4.23", "eslint": "^9.39.2", "jasmine-core": "^5.13.0", - "karma": "^6.4.4", - "karma-chrome-launcher": "^3.2.0", - "karma-coverage": "^2.2.1", - "karma-jasmine": "^5.1.0", - "karma-jasmine-html-reporter": "^2.1.0", + "jsdom": "^27.4.0", "tailwindcss": "^3.4.17", "typescript": "~5.9.3", - "typescript-eslint": "^8.50.0" + "typescript-eslint": "^8.50.0", + "vitest": "^4.0.16" } } diff --git a/booklore-ui/src/app/features/book/service/book-menu.service.spec.ts b/booklore-ui/src/app/features/book/service/book-menu.service.spec.ts new file mode 100644 index 000000000..b44b2f490 --- /dev/null +++ b/booklore-ui/src/app/features/book/service/book-menu.service.spec.ts @@ -0,0 +1,466 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {EnvironmentInjector, runInInjectionContext} from '@angular/core'; +import {ConfirmationService, MessageService} from 'primeng/api'; +import {BookService} from './book.service'; +import {LoadingService} from '../../../core/services/loading.service'; +import {User} from '../../settings/user-management/user.service'; +import {ReadStatus} from '../model/book.model'; +import {ResetProgressTypes} from '../../../shared/constants/reset-progress-type'; +import {of, throwError} from 'rxjs'; +import {HttpErrorResponse} from '@angular/common/http'; +import {APIException} from '../../../shared/models/api-exception.model'; +import {BookMenuService} from './book-menu.service'; + +describe('BookMenuService', () => { + let service: BookMenuService; + let confirmationService: any; + let messageService: any; + let bookService: any; + let loadingService: any; + + const mockUser: User = { + id: 1, + username: 'testuser', + email: 'test@example.com', + permissions: { + canBulkAutoFetchMetadata: true, + canBulkCustomFetchMetadata: true, + canBulkEditMetadata: true, + canBulkRegenerateCover: true, + canBulkResetBookReadStatus: true, + canBulkResetBookloreReadProgress: true, + canBulkResetKoReaderReadProgress: true + } + } as User; + + const mockUserNoPermissions: User = { + id: 2, + username: 'limiteduser', + email: 'limited@example.com', + permissions: { + canBulkAutoFetchMetadata: false, + canBulkCustomFetchMetadata: false, + canBulkEditMetadata: false, + canBulkRegenerateCover: false, + canBulkResetBookReadStatus: false, + canBulkResetBookloreReadProgress: false, + canBulkResetKoReaderReadProgress: false + } + } as User; + + beforeEach(() => { + confirmationService = { + confirm: vi.fn() + }; + + messageService = { + add: vi.fn() + }; + + bookService = { + updateBookReadStatus: vi.fn(), + resetProgress: vi.fn() + }; + + loadingService = { + show: vi.fn().mockReturnValue('loader-id'), + hide: vi.fn() + }; + + TestBed.configureTestingModule({ + providers: [ + BookMenuService, + {provide: ConfirmationService, useValue: confirmationService}, + {provide: MessageService, useValue: messageService}, + {provide: BookService, useValue: bookService}, + {provide: LoadingService, useValue: loadingService} + ] + }); + + const injector = TestBed.inject(EnvironmentInjector); + + service = runInInjectionContext( + injector, + () => TestBed.inject(BookMenuService) + ); + }); + + describe('getMetadataMenuItems', () => { + it('should return all metadata menu items when user has all permissions', () => { + const autoFetch = vi.fn(); + const fetch = vi.fn(); + const bulkEdit = vi.fn(); + const multiEdit = vi.fn(); + const regenerate = vi.fn(); + + const items = service.getMetadataMenuItems( + autoFetch, + fetch, + bulkEdit, + multiEdit, + regenerate, + mockUser + ); + + expect(items).toHaveLength(5); + expect(items[0].label).toBe('Auto Fetch Metadata'); + expect(items[0].icon).toBe('pi pi-bolt'); + expect(items[1].label).toBe('Custom Fetch Metadata'); + expect(items[1].icon).toBe('pi pi-sync'); + expect(items[2].label).toBe('Bulk Metadata Editor'); + expect(items[2].icon).toBe('pi pi-table'); + expect(items[3].label).toBe('Multi-Book Metadata Editor'); + expect(items[3].icon).toBe('pi pi-clone'); + expect(items[4].label).toBe('Regenerate Covers'); + expect(items[4].icon).toBe('pi pi-image'); + }); + + it('should return empty array when user has no permissions', () => { + const items = service.getMetadataMenuItems( + vi.fn(), + vi.fn(), + vi.fn(), + vi.fn(), + vi.fn(), + mockUserNoPermissions + ); + + expect(items).toHaveLength(0); + }); + + it('should return empty array when user is null', () => { + const items = service.getMetadataMenuItems( + vi.fn(), + vi.fn(), + vi.fn(), + vi.fn(), + vi.fn(), + null + ); + + expect(items).toHaveLength(0); + }); + + it('should call command functions when menu items are clicked', () => { + const autoFetch = vi.fn(); + const fetch = vi.fn(); + const bulkEdit = vi.fn(); + const multiEdit = vi.fn(); + const regenerate = vi.fn(); + + const items = service.getMetadataMenuItems( + autoFetch, + fetch, + bulkEdit, + multiEdit, + regenerate, + mockUser + ); + + items[0].command!({} as any); + expect(autoFetch).toHaveBeenCalledOnce(); + + items[1].command!({} as any); + expect(fetch).toHaveBeenCalledOnce(); + + items[2].command!({} as any); + expect(bulkEdit).toHaveBeenCalledOnce(); + + items[3].command!({} as any); + expect(multiEdit).toHaveBeenCalledOnce(); + + items[4].command!({} as any); + expect(regenerate).toHaveBeenCalledOnce(); + }); + + it('should return only items for granted permissions', () => { + const partialUser: User = { + ...mockUser, + permissions: { + ...mockUser.permissions, + canBulkAutoFetchMetadata: true, + canBulkCustomFetchMetadata: false, + canBulkEditMetadata: true, + canBulkRegenerateCover: false + } + } as User; + + const items = service.getMetadataMenuItems( + vi.fn(), + vi.fn(), + vi.fn(), + vi.fn(), + vi.fn(), + partialUser + ); + + expect(items).toHaveLength(3); + expect(items[0].label).toBe('Auto Fetch Metadata'); + expect(items[1].label).toBe('Bulk Metadata Editor'); + expect(items[2].label).toBe('Multi-Book Metadata Editor'); + }); + }); + + describe('getBulkReadActionsMenu', () => { + it('should return all read action items when user has all permissions', () => { + const selectedBooks = new Set([1, 2, 3]); + const items = service.getBulkReadActionsMenu(selectedBooks, mockUser); + + expect(items).toHaveLength(3); + expect(items[0].label).toBe('Update Read Status'); + expect(items[0].icon).toBe('pi pi-book'); + expect(items[0].items).toBeDefined(); + expect(items[0].items!.length).toBeGreaterThan(0); + expect(items[1].label).toBe('Reset Booklore Progress'); + expect(items[1].icon).toBe('pi pi-undo'); + expect(items[2].label).toBe('Reset KOReader Progress'); + expect(items[2].icon).toBe('pi pi-undo'); + }); + + it('should return empty array when user has no permissions', () => { + const selectedBooks = new Set([1, 2, 3]); + const items = service.getBulkReadActionsMenu(selectedBooks, mockUserNoPermissions); + + expect(items).toHaveLength(0); + }); + + it('should return empty array when user is null', () => { + const selectedBooks = new Set([1, 2, 3]); + const items = service.getBulkReadActionsMenu(selectedBooks, null); + + expect(items).toHaveLength(0); + }); + + it('should show confirmation dialog when updating read status', () => { + const selectedBooks = new Set([1, 2, 3]); + const items = service.getBulkReadActionsMenu(selectedBooks, mockUser); + + const readStatusItems = items[0].items!; + readStatusItems[0].command!({} as any); + + expect(confirmationService.confirm).toHaveBeenCalledOnce(); + const confirmCall = vi.mocked(confirmationService.confirm).mock.calls[0][0]; + expect(confirmCall.message).toContain('3 book(s)'); + expect(confirmCall.header).toBe('Confirm Read Status Update'); + }); + + it('should update read status on confirmation accept', () => { + const selectedBooks = new Set([1, 2, 3]); + vi.mocked(bookService.updateBookReadStatus).mockReturnValue(of([])); + + const items = service.getBulkReadActionsMenu(selectedBooks, mockUser); + const readStatusItems = items[0].items!; + + const readItem = readStatusItems.find(item => item.label === 'Read'); + expect(readItem).toBeDefined(); + + readItem!.command!({} as any); + + const confirmCall = vi.mocked(confirmationService.confirm).mock.calls[0][0]; + confirmCall.accept!(); + + expect(loadingService.show).toHaveBeenCalledWith('Updating read status for 3 book(s)...'); + expect(bookService.updateBookReadStatus).toHaveBeenCalledWith([1, 2, 3], ReadStatus.READ); + expect(loadingService.hide).toHaveBeenCalledWith('loader-id'); + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'success', + summary: 'Read Status Updated', + detail: 'Marked as "Read"', + life: 2000 + }); + }); + + it('should show error message when read status update fails', () => { + const selectedBooks = new Set([1, 2]); + const errorResponse = new HttpErrorResponse({ + error: {message: 'Update failed'} as APIException, + status: 500 + }); + vi.mocked(bookService.updateBookReadStatus).mockReturnValue(throwError(() => errorResponse)); + + const items = service.getBulkReadActionsMenu(selectedBooks, mockUser); + const readStatusItems = items[0].items!; + + readStatusItems[0].command!({} as any); + + const confirmCall = vi.mocked(confirmationService.confirm).mock.calls[0][0]; + confirmCall.accept!(); + + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'error', + summary: 'Update Failed', + detail: 'Update failed', + life: 3000 + }); + }); + + it('should show confirmation dialog when resetting Booklore progress', () => { + const selectedBooks = new Set([1, 2, 3]); + const items = service.getBulkReadActionsMenu(selectedBooks, mockUser); + + items[1].command!({} as any); + + expect(confirmationService.confirm).toHaveBeenCalledOnce(); + const confirmCall = vi.mocked(confirmationService.confirm).mock.calls[0][0]; + expect(confirmCall.message).toContain('3 book(s)'); + expect(confirmCall.header).toBe('Confirm Reset'); + }); + + it('should reset Booklore progress on confirmation accept', () => { + const selectedBooks = new Set([5, 6]); + vi.mocked(bookService.resetProgress).mockReturnValue(of([])); + + const items = service.getBulkReadActionsMenu(selectedBooks, mockUser); + items[1].command!({} as any); + + const confirmCall = vi.mocked(confirmationService.confirm).mock.calls[0][0]; + confirmCall.accept!(); + + expect(loadingService.show).toHaveBeenCalledWith('Resetting Booklore progress for 2 book(s)...'); + expect(bookService.resetProgress).toHaveBeenCalledWith([5, 6], ResetProgressTypes.BOOKLORE); + expect(loadingService.hide).toHaveBeenCalledWith('loader-id'); + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'success', + summary: 'Progress Reset', + detail: 'Booklore reading progress has been reset.', + life: 1500 + }); + }); + + it('should show error message when Booklore progress reset fails', () => { + const selectedBooks = new Set([1]); + const errorResponse = new HttpErrorResponse({ + error: {message: 'Reset failed'} as APIException, + status: 500 + }); + vi.mocked(bookService.resetProgress).mockReturnValue(throwError(() => errorResponse)); + + const items = service.getBulkReadActionsMenu(selectedBooks, mockUser); + items[1].command!({} as any); + + const confirmCall = vi.mocked(confirmationService.confirm).mock.calls[0][0]; + confirmCall.accept!(); + + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'error', + summary: 'Failed', + detail: 'Reset failed', + life: 3000 + }); + }); + + it('should show confirmation dialog when resetting KOReader progress', () => { + const selectedBooks = new Set([7, 8]); + const items = service.getBulkReadActionsMenu(selectedBooks, mockUser); + + items[2].command!({} as any); + + expect(confirmationService.confirm).toHaveBeenCalledOnce(); + const confirmCall = vi.mocked(confirmationService.confirm).mock.calls[0][0]; + expect(confirmCall.message).toContain('2 book(s)'); + expect(confirmCall.message).toContain('KOReader'); + expect(confirmCall.header).toBe('Confirm Reset'); + }); + + it('should reset KOReader progress on confirmation accept', () => { + const selectedBooks = new Set([9]); + vi.mocked(bookService.resetProgress).mockReturnValue(of([])); + + const items = service.getBulkReadActionsMenu(selectedBooks, mockUser); + items[2].command!({} as any); + + const confirmCall = vi.mocked(confirmationService.confirm).mock.calls[0][0]; + confirmCall.accept!(); + + expect(loadingService.show).toHaveBeenCalledWith('Resetting KOReader progress for 1 book(s)...'); + expect(bookService.resetProgress).toHaveBeenCalledWith([9], ResetProgressTypes.KOREADER); + expect(loadingService.hide).toHaveBeenCalledWith('loader-id'); + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'success', + summary: 'Progress Reset', + detail: 'KOReader reading progress has been reset.', + life: 1500 + }); + }); + + it('should show error message when KOReader progress reset fails', () => { + const selectedBooks = new Set([10]); + const errorResponse = new HttpErrorResponse({ + error: {message: 'KOReader reset failed'} as APIException, + status: 500 + }); + vi.mocked(bookService.resetProgress).mockReturnValue(throwError(() => errorResponse)); + + const items = service.getBulkReadActionsMenu(selectedBooks, mockUser); + items[2].command!({} as any); + + const confirmCall = vi.mocked(confirmationService.confirm).mock.calls[0][0]; + confirmCall.accept!(); + + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'error', + summary: 'Failed', + detail: 'KOReader reset failed', + life: 3000 + }); + }); + + it('should handle API error without message gracefully', () => { + const selectedBooks = new Set([1]); + const errorResponse = new HttpErrorResponse({ + error: {} as APIException, + status: 500 + }); + vi.mocked(bookService.updateBookReadStatus).mockReturnValue(throwError(() => errorResponse)); + + const items = service.getBulkReadActionsMenu(selectedBooks, mockUser); + const readStatusItems = items[0].items!; + + readStatusItems[0].command!({} as any); + + const confirmCall = vi.mocked(confirmationService.confirm).mock.calls[0][0]; + confirmCall.accept!(); + + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'error', + summary: 'Update Failed', + detail: 'Could not update read status.', + life: 3000 + }); + }); + + it('should return only items for granted read permissions', () => { + const partialUser: User = { + ...mockUser, + permissions: { + ...mockUser.permissions, + canBulkResetBookReadStatus: true, + canBulkResetBookloreReadProgress: false, + canBulkResetKoReaderReadProgress: true + } + } as User; + + const selectedBooks = new Set([1, 2]); + const items = service.getBulkReadActionsMenu(selectedBooks, partialUser); + + expect(items).toHaveLength(2); + expect(items[0].label).toBe('Update Read Status'); + expect(items[1].label).toBe('Reset KOReader Progress'); + }); + + it('should not call bookService when confirmation is rejected', () => { + const selectedBooks = new Set([1, 2]); + const items = service.getBulkReadActionsMenu(selectedBooks, mockUser); + + items[1].command!({} as any); + + const confirmCall = vi.mocked(confirmationService.confirm).mock.calls[0][0]; + confirmCall.reject?.(); + + expect(bookService.resetProgress).not.toHaveBeenCalled(); + expect(loadingService.show).not.toHaveBeenCalled(); + }); + }); +}); + diff --git a/booklore-ui/src/app/features/book/service/book-navigation.service.spec.ts b/booklore-ui/src/app/features/book/service/book-navigation.service.spec.ts new file mode 100644 index 000000000..96945fbc3 --- /dev/null +++ b/booklore-ui/src/app/features/book/service/book-navigation.service.spec.ts @@ -0,0 +1,106 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {firstValueFrom} from 'rxjs'; +import {BookNavigationService} from './book-navigation.service'; + +describe('BookNavigationService', () => { + let service: BookNavigationService; + const bookIds = [10, 20, 30, 40]; + + beforeEach(() => { + service = new BookNavigationService(); + }); + + it('should set and get available book ids', () => { + service.setAvailableBookIds(bookIds); + expect(service.getAvailableBookIds()).toEqual(bookIds); + }); + + it('should set navigation context and emit state', async () => { + service.setNavigationContext(bookIds, 30); + + const state = await firstValueFrom(service.getNavigationState()); + + expect(state).not.toBeNull(); + expect(state!.bookIds).toEqual(bookIds); + expect(state!.currentIndex).toBe(2); + }); + + it('should emit null if currentBookId not in bookIds', async () => { + service.setNavigationContext(bookIds, 99); + + const state = await firstValueFrom(service.getNavigationState()); + + expect(state).toBeNull(); + }); + + it('should determine canNavigatePrevious correctly', () => { + service.setNavigationContext(bookIds, 10); + expect(service.canNavigatePrevious()).toBe(false); + + service.setNavigationContext(bookIds, 30); + expect(service.canNavigatePrevious()).toBe(true); + }); + + it('should determine canNavigateNext correctly', () => { + service.setNavigationContext(bookIds, 40); + expect(service.canNavigateNext()).toBe(false); + + service.setNavigationContext(bookIds, 20); + expect(service.canNavigateNext()).toBe(true); + }); + + it('should get previous book id', () => { + service.setNavigationContext(bookIds, 30); + expect(service.getPreviousBookId()).toBe(20); + + service.setNavigationContext(bookIds, 10); + expect(service.getPreviousBookId()).toBeNull(); + }); + + it('should get next book id', () => { + service.setNavigationContext(bookIds, 20); + expect(service.getNextBookId()).toBe(30); + + service.setNavigationContext(bookIds, 40); + expect(service.getNextBookId()).toBeNull(); + }); + + it('should update current book and emit new index', async () => { + service.setNavigationContext(bookIds, 10); + service.updateCurrentBook(30); + + const state = await firstValueFrom(service.getNavigationState()); + + expect(state!.currentIndex).toBe(2); + expect(state!.bookIds[state!.currentIndex]).toBe(30); + }); + + it('should not update current book if id not in list', async () => { + service.setNavigationContext(bookIds, 10); + service.updateCurrentBook(99); + + const state = await firstValueFrom(service.getNavigationState()); + + expect(state!.currentIndex).toBe(0); + expect(state!.bookIds[state!.currentIndex]).toBe(10); + }); + + it('should return current position', () => { + service.setNavigationContext(bookIds, 30); + expect(service.getCurrentPosition()).toEqual({current: 3, total: 4}); + }); + + it('should return null for current position if no state', () => { + expect(service.getCurrentPosition()).toBeNull(); + }); + + it('should handle navigation methods gracefully if state is null', () => { + expect(service.canNavigatePrevious()).toBe(false); + expect(service.canNavigateNext()).toBe(false); + expect(service.getPreviousBookId()).toBeNull(); + expect(service.getNextBookId()).toBeNull(); + + service.updateCurrentBook(10); + expect(service.getCurrentPosition()).toBeNull(); + }); +}); diff --git a/booklore-ui/src/app/features/book/service/book-patch.service.spec.ts b/booklore-ui/src/app/features/book/service/book-patch.service.spec.ts new file mode 100644 index 000000000..a64ca98af --- /dev/null +++ b/booklore-ui/src/app/features/book/service/book-patch.service.spec.ts @@ -0,0 +1,173 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {EnvironmentInjector, runInInjectionContext} from '@angular/core'; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {of, throwError} from 'rxjs'; + +import {BookPatchService} from './book-patch.service'; +import {BookStateService} from './book-state.service'; +import {Book, ReadStatus} from '../model/book.model'; + +describe('BookPatchService', () => { + let service: BookPatchService; + let httpMock: any; + let bookStateServiceMock: any; + + const mockBooks: Book[] = [ + {id: 1, bookType: 'PDF', libraryId: 1, libraryName: 'Lib', dateFinished: undefined} as Book, + {id: 2, bookType: 'EPUB', libraryId: 1, libraryName: 'Lib', dateFinished: undefined} as Book, + ]; + + beforeEach(() => { + httpMock = { + post: vi.fn(), + put: vi.fn(), + }; + bookStateServiceMock = { + getCurrentBookState: vi.fn(), + updateBookState: vi.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + BookPatchService, + {provide: HttpClient, useValue: httpMock}, + {provide: BookStateService, useValue: bookStateServiceMock}, + ] + }); + + const injector = TestBed.inject(EnvironmentInjector); + service = runInInjectionContext( + injector, + () => TestBed.inject(BookPatchService) + ); + }); + + it('should update book shelves and update state', () => { + const updatedBooks = [{...mockBooks[0], id: 1, libraryName: 'Updated'}]; + httpMock.post.mockReturnValue(of(updatedBooks)); + bookStateServiceMock.getCurrentBookState.mockReturnValue({books: [...mockBooks]}); + service.updateBookShelves(new Set([1]), new Set([2]), new Set([3])).subscribe(result => { + expect(result).toEqual(updatedBooks); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalledWith(expect.objectContaining({ + books: expect.arrayContaining([expect.objectContaining({id: 1, libraryName: 'Updated'})]) + })); + }); + }); + + it('should save PDF progress', () => { + httpMock.post.mockReturnValue(of(void 0)); + service.savePdfProgress(1, 10, 0.5).subscribe(result => { + expect(result).toBeUndefined(); + expect(httpMock.post).toHaveBeenCalledWith(expect.stringContaining('/progress'), { + bookId: 1, + pdfProgress: {page: 10, percentage: 0.5} + }); + }); + }); + + it('should save EPUB progress', () => { + httpMock.post.mockReturnValue(of(void 0)); + service.saveEpubProgress(2, 'cfi123', 0.8).subscribe(result => { + expect(result).toBeUndefined(); + expect(httpMock.post).toHaveBeenCalledWith(expect.stringContaining('/progress'), { + bookId: 2, + epubProgress: {cfi: 'cfi123', percentage: 0.8} + }); + }); + }); + + it('should save CBX progress', () => { + httpMock.post.mockReturnValue(of(void 0)); + service.saveCbxProgress(3, 5, 0.2).subscribe(result => { + expect(result).toBeUndefined(); + expect(httpMock.post).toHaveBeenCalledWith(expect.stringContaining('/progress'), { + bookId: 3, + cbxProgress: {page: 5, percentage: 0.2} + }); + }); + }); + + it('should update date finished and update state', () => { + httpMock.post.mockReturnValue(of(void 0)); + bookStateServiceMock.getCurrentBookState.mockReturnValue({books: [...mockBooks]}); + service.updateDateFinished(1, '2023-01-01').subscribe(() => { + expect(bookStateServiceMock.updateBookState).toHaveBeenCalledWith(expect.objectContaining({ + books: expect.arrayContaining([expect.objectContaining({id: 1, dateFinished: '2023-01-01'})]) + })); + }); + }); + + it('should reset progress and update state', () => { + const responses = [{bookId: 1, readStatus: ReadStatus.UNREAD, readStatusModifiedTime: 'now', dateFinished: undefined}]; + httpMock.post.mockReturnValue(of(responses)); + bookStateServiceMock.getCurrentBookState.mockReturnValue({books: [...mockBooks]}); + service.resetProgress([1], 'BOOKLORE').subscribe(result => { + expect(result).toEqual(responses); + expect(httpMock.post).toHaveBeenCalledWith( + expect.stringContaining('/reset-progress'), + [1], + {params: expect.any(HttpParams)} + ); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalled(); + }); + }); + + it('should update book read status and update state', () => { + const responses = [{bookId: 2, readStatus: ReadStatus.READING, readStatusModifiedTime: 'now', dateFinished: '2023-01-01'}]; + httpMock.post.mockReturnValue(of(responses)); + bookStateServiceMock.getCurrentBookState.mockReturnValue({books: [...mockBooks]}); + service.updateBookReadStatus([2], ReadStatus.READING).subscribe(result => { + expect(result).toEqual(responses); + expect(httpMock.post).toHaveBeenCalledWith( + expect.stringContaining('/status'), + {bookIds: [2], status: ReadStatus.READING} + ); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalled(); + }); + }); + + it('should reset personal rating and update state', () => { + const responses = [{bookId: 1, personalRating: null}]; + httpMock.post.mockReturnValue(of(responses)); + bookStateServiceMock.getCurrentBookState.mockReturnValue({books: [...mockBooks]}); + service.resetPersonalRating([1]).subscribe(result => { + expect(result).toEqual(responses); + expect(httpMock.post).toHaveBeenCalledWith(expect.stringContaining('/reset-personal-rating'), [1]); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalled(); + }); + }); + + it('should update personal rating and update state', () => { + const responses = [{bookId: 2, personalRating: 5}]; + httpMock.put.mockReturnValue(of(responses)); + bookStateServiceMock.getCurrentBookState.mockReturnValue({books: [...mockBooks]}); + service.updatePersonalRating([2], 5).subscribe(result => { + expect(result).toEqual(responses); + expect(httpMock.put).toHaveBeenCalledWith(expect.stringContaining('/personal-rating'), {ids: [2], rating: 5}); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalled(); + }); + }); + + it('should update last read time in state', () => { + const now = new Date().toISOString(); + vi.useFakeTimers().setSystemTime(new Date(now)); + bookStateServiceMock.getCurrentBookState.mockReturnValue({books: [...mockBooks]}); + service.updateLastReadTime(1); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalledWith(expect.objectContaining({ + books: expect.arrayContaining([expect.objectContaining({id: 1, lastReadTime: now})]) + })); + vi.useRealTimers(); + }); + + it('should handle errors from http.post gracefully', () => { + httpMock.post.mockReturnValue(throwError(() => new Error('fail'))); + service.savePdfProgress(1, 1, 1).subscribe({ + error: (err: any) => { + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe('fail'); + } + }); + }); +}); + diff --git a/booklore-ui/src/app/features/book/service/book-state.service.ts b/booklore-ui/src/app/features/book/service/book-state.service.ts index 114849374..dcf134f7a 100644 --- a/booklore-ui/src/app/features/book/service/book-state.service.ts +++ b/booklore-ui/src/app/features/book/service/book-state.service.ts @@ -1,5 +1,5 @@ import {Injectable} from '@angular/core'; -import {BehaviorSubject, Observable} from 'rxjs'; +import {BehaviorSubject} from 'rxjs'; import {BookState} from '../model/state/book-state.model'; @Injectable({ @@ -22,13 +22,6 @@ export class BookStateService { this.bookStateSubject.next(state); } - updatePartialBookState(partialState: Partial): void { - this.bookStateSubject.next({ - ...this.bookStateSubject.value, - ...partialState - }); - } - resetBookState(): void { this.bookStateSubject.next({ books: null, diff --git a/booklore-ui/src/app/features/book/service/book.service.spec.ts b/booklore-ui/src/app/features/book/service/book.service.spec.ts new file mode 100644 index 000000000..7c83de699 --- /dev/null +++ b/booklore-ui/src/app/features/book/service/book.service.spec.ts @@ -0,0 +1,537 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {EnvironmentInjector, runInInjectionContext} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {firstValueFrom, of, throwError} from 'rxjs'; +import {BookService} from './book.service'; +import {BookStateService} from './book-state.service'; +import {BookSocketService} from './book-socket.service'; +import {BookPatchService} from './book-patch.service'; +import {MessageService} from 'primeng/api'; +import {AuthService} from '../../../shared/service/auth.service'; +import {FileDownloadService} from '../../../shared/service/file-download.service'; +import {Router} from '@angular/router'; +import {AdditionalFileType, Book, BookMetadata, ReadStatus} from '../model/book.model'; +import {FetchMetadataRequest} from '../../metadata/model/request/fetch-metadata-request.model'; + +describe('BookService', () => { + let service: BookService; + let httpMock: any; + let messageServiceMock: any; + let authServiceMock: any; + let fileDownloadServiceMock: any; + let routerMock: any; + let bookStateServiceMock: any; + let bookSocketServiceMock: any; + let bookPatchServiceMock: any; + + beforeEach(() => { + httpMock = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn() + }; + messageServiceMock = {add: vi.fn()}; + authServiceMock = {token$: of('token')}; + fileDownloadServiceMock = {downloadFile: vi.fn()}; + routerMock = {navigate: vi.fn()}; + bookStateServiceMock = { + bookState$: of({books: [{id: 1, bookType: 'PDF', shelves: [], metadata: {seriesName: 'S'}}], loaded: true, error: null}), + getCurrentBookState: vi.fn(), + updateBookState: vi.fn(), + resetBookState: vi.fn() + }; + bookSocketServiceMock = { + handleNewlyCreatedBook: vi.fn(), + handleRemovedBookIds: vi.fn(), + handleBookUpdate: vi.fn(), + handleMultipleBookUpdates: vi.fn(), + handleBookMetadataUpdate: vi.fn(), + handleMultipleBookCoverPatches: vi.fn() + }; + bookPatchServiceMock = { + updateBookShelves: vi.fn(), + updateLastReadTime: vi.fn(), + savePdfProgress: vi.fn(), + saveEpubProgress: vi.fn(), + saveCbxProgress: vi.fn(), + updateDateFinished: vi.fn(), + resetProgress: vi.fn(), + updateBookReadStatus: vi.fn(), + resetPersonalRating: vi.fn(), + updatePersonalRating: vi.fn() + }; + + bookStateServiceMock.getCurrentBookState.mockReturnValue({ + books: [ + {id: 1, bookType: 'PDF', shelves: [{id: 10}], metadata: {seriesName: 'S'}, alternativeFormats: [], supplementaryFiles: [], fileName: 'file.pdf'}, + {id: 2, bookType: 'EPUB', shelves: [], metadata: {}, alternativeFormats: [], supplementaryFiles: [], fileName: 'file2.epub'} + ], + loaded: true, + error: null + }); + + TestBed.configureTestingModule({ + providers: [ + BookService, + {provide: HttpClient, useValue: httpMock}, + {provide: MessageService, useValue: messageServiceMock}, + {provide: AuthService, useValue: authServiceMock}, + {provide: FileDownloadService, useValue: fileDownloadServiceMock}, + {provide: Router, useValue: routerMock}, + {provide: BookStateService, useValue: bookStateServiceMock}, + {provide: BookSocketService, useValue: bookSocketServiceMock}, + {provide: BookPatchService, useValue: bookPatchServiceMock} + ] + }); + + const injector = TestBed.inject(EnvironmentInjector); + service = runInInjectionContext(injector, () => TestBed.inject(BookService)); + }); + + describe('State Management', () => { + it('should get current book state', () => { + expect(service.getCurrentBookState()).toEqual(bookStateServiceMock.getCurrentBookState()); + }); + + it('should fetch books and update state', async () => { + const books = [{id: 1}]; + httpMock.get.mockReturnValue(of(books)); + await firstValueFrom(service['fetchBooks']()); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalledWith({ + books, + loaded: true, + error: null + }); + }); + + it('should handle fetchBooks error', async () => { + httpMock.get.mockReturnValue(throwError(() => ({message: 'fail'}))); + bookStateServiceMock.getCurrentBookState.mockReturnValue({books: [], loaded: false, error: null}); + await expect(firstValueFrom(service['fetchBooks']())).rejects.toBeTruthy(); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalledWith({ + books: [], + loaded: true, + error: 'fail' + }); + }); + + it('should refreshBooks and update state', () => { + httpMock.get.mockReturnValue(of([{id: 1}])); + service.refreshBooks(); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalledWith({ + books: [{id: 1}], + loaded: true, + error: null + }); + }); + + it('should handle refreshBooks error', () => { + httpMock.get.mockReturnValue(throwError(() => ({message: 'fail'}))); + service.refreshBooks(); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalledWith({ + books: null, + loaded: true, + error: 'fail' + }); + }); + + it('should remove books by library id', () => { + bookStateServiceMock.getCurrentBookState.mockReturnValue({ + books: [{id: 1, libraryId: 2}, {id: 2, libraryId: 3}], + loaded: true, + error: null + }); + service.removeBooksByLibraryId(2); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalledWith({ + books: [{id: 2, libraryId: 3}], + loaded: true, + error: null + }); + }); + + it('should remove books from shelf', () => { + bookStateServiceMock.getCurrentBookState.mockReturnValue({ + books: [{id: 1, shelves: [{id: 10}, {id: 20}]}, {id: 2, shelves: [{id: 20}]}], + loaded: true, + error: null + }); + service.removeBooksFromShelf(20); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalled(); + }); + }); + + describe('Book Retrieval', () => { + it('should get book by id from state', () => { + expect(service.getBookByIdFromState(1)).toEqual(expect.objectContaining({id: 1})); + expect(service.getBookByIdFromState(999)).toBeUndefined(); + }); + + it('should get books by ids from state', () => { + expect(service.getBooksByIdsFromState([1, 2])).toHaveLength(2); + expect(service.getBooksByIdsFromState([])).toEqual([]); + }); + + it('should get book by id from API', async () => { + httpMock.get.mockReturnValue(of({id: 1})); + const result = await firstValueFrom(service.getBookByIdFromAPI(1, true)); + expect(result).toEqual({id: 1}); + }); + + it('should get books in series', async () => { + const result = await firstValueFrom(service.getBooksInSeries(1)); + expect(result).toEqual([expect.objectContaining({id: 1})]); + }); + + it('should get book recommendations', async () => { + httpMock.get.mockReturnValue(of([{id: 1}])); + const result = await firstValueFrom(service.getBookRecommendations(1)); + expect(result).toEqual([{id: 1}]); + }); + }); + + describe('Book Operations', () => { + it('should delete books and update state', async () => { + httpMock.delete.mockReturnValue(of({failedFileDeletions: []})); + bookStateServiceMock.getCurrentBookState.mockReturnValue({ + books: [{id: 1}, {id: 2}], + loaded: true, + error: null + }); + await firstValueFrom(service.deleteBooks(new Set([1]))); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalled(); + expect(messageServiceMock.add).toHaveBeenCalledWith(expect.objectContaining({severity: 'success'})); + }); + + it('should show warning if some files could not be deleted', async () => { + httpMock.delete.mockReturnValue(of({failedFileDeletions: ['file.pdf']})); + bookStateServiceMock.getCurrentBookState.mockReturnValue({ + books: [{id: 1, fileName: 'file.pdf'}], + loaded: true, + error: null + }); + await firstValueFrom(service.deleteBooks(new Set([1]))); + expect(messageServiceMock.add).toHaveBeenCalledWith(expect.objectContaining({severity: 'warn'})); + }); + + it('should handle deleteBooks error', async () => { + httpMock.delete.mockReturnValue(throwError(() => ({error: {message: 'fail'}}))); + await expect(firstValueFrom(service.deleteBooks(new Set([1])))).rejects.toBeTruthy(); + expect(messageServiceMock.add).toHaveBeenCalledWith(expect.objectContaining({severity: 'error'})); + }); + + it('should update book shelves', async () => { + bookPatchServiceMock.updateBookShelves.mockReturnValue(of([{id: 1}])); + const result = await firstValueFrom(service.updateBookShelves(new Set([1]), new Set([2]), new Set([3]))); + expect(result).toEqual([{id: 1}]); + }); + + it('should handle updateBookShelves error', async () => { + bookPatchServiceMock.updateBookShelves.mockReturnValue(throwError(() => ({message: 'fail'}))); + await expect(firstValueFrom(service.updateBookShelves(new Set([1]), new Set([2]), new Set([3])))).rejects.toBeTruthy(); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalled(); + }); + }); + + describe('Reading & Viewer Settings', () => { + it('should navigate to correct reader and update last read time', () => { + service.readBook(1, 'ngx'); + expect(routerMock.navigate).toHaveBeenCalledWith(['/pdf-reader/book/1']); + expect(bookPatchServiceMock.updateLastReadTime).toHaveBeenCalledWith(1); + }); + + it('should not navigate if book not found', () => { + bookStateServiceMock.getCurrentBookState.mockReturnValue({books: [], loaded: true, error: null}); + service.readBook(999, 'ngx'); + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + + it('should get book setting', async () => { + httpMock.get.mockReturnValue(of({fontSize: 12})); + const result = await firstValueFrom(service.getBookSetting(1)); + expect(result).toEqual({fontSize: 12}); + }); + + it('should update viewer setting', async () => { + httpMock.put.mockReturnValue(of(void 0)); + const result = await firstValueFrom(service.updateViewerSetting({fontSize: 12}, 1)); + expect(result).toBeUndefined(); + }); + }); + + describe('File Operations', () => { + it('should get file content', async () => { + httpMock.get.mockReturnValue(of(new Blob(['abc']))); + const result = await firstValueFrom(service.getFileContent(1)); + expect(result).toBeInstanceOf(Blob); + }); + + it('should download file', () => { + const book = {id: 1, fileName: 'file.pdf'}; + service.downloadFile(book as Book); + expect(fileDownloadServiceMock.downloadFile).toHaveBeenCalled(); + }); + + it('should delete additional file and update state', async () => { + httpMock.delete.mockReturnValue(of(void 0)); + bookStateServiceMock.getCurrentBookState.mockReturnValue({ + books: [{id: 1, alternativeFormats: [{id: 2}], supplementaryFiles: [{id: 3}]}], + loaded: true, + error: null + }); + await firstValueFrom(service.deleteAdditionalFile(1, 2)); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalled(); + expect(messageServiceMock.add).toHaveBeenCalledWith(expect.objectContaining({severity: 'success'})); + }); + + it('should handle deleteAdditionalFile error', async () => { + httpMock.delete.mockReturnValue(throwError(() => ({error: {message: 'fail'}}))); + await expect(firstValueFrom(service.deleteAdditionalFile(1, 2))).rejects.toBeTruthy(); + expect(messageServiceMock.add).toHaveBeenCalledWith(expect.objectContaining({severity: 'error'})); + }); + + it('should upload additional file and update state', async () => { + httpMock.post.mockReturnValue(of({id: 2})); + bookStateServiceMock.getCurrentBookState.mockReturnValue({ + books: [{id: 1, alternativeFormats: [], supplementaryFiles: []}], + loaded: true, + error: null + }); + const file = new File(['abc'], 'file.txt'); + await firstValueFrom(service.uploadAdditionalFile(1, file, AdditionalFileType.ALTERNATIVE_FORMAT)); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalled(); + expect(messageServiceMock.add).toHaveBeenCalledWith(expect.objectContaining({severity: 'success'})); + }); + + it('should handle uploadAdditionalFile error', async () => { + httpMock.post.mockReturnValue(throwError(() => ({error: {message: 'fail'}}))); + const file = new File(['abc'], 'file.txt'); + await expect(firstValueFrom(service.uploadAdditionalFile(1, file, AdditionalFileType.ALTERNATIVE_FORMAT))).rejects.toBeTruthy(); + expect(messageServiceMock.add).toHaveBeenCalledWith(expect.objectContaining({severity: 'error'})); + }); + + it('should download additional file', () => { + const book = {id: 1, alternativeFormats: [{id: 2, fileName: 'f.txt'}]}; + service.downloadAdditionalFile(book as Book, 2); + expect(fileDownloadServiceMock.downloadFile).toHaveBeenCalled(); + }); + }); + + describe('Progress & Status Tracking', () => { + it('should update last read time', () => { + service.updateLastReadTime(1); + expect(bookPatchServiceMock.updateLastReadTime).toHaveBeenCalledWith(1); + }); + + it('should save pdf progress', async () => { + bookPatchServiceMock.savePdfProgress.mockReturnValue(of(void 0)); + const result = await firstValueFrom(service.savePdfProgress(1, 2, 0.5)); + expect(result).toBeUndefined(); + }); + + it('should save epub progress', async () => { + bookPatchServiceMock.saveEpubProgress.mockReturnValue(of(void 0)); + const result = await firstValueFrom(service.saveEpubProgress(1, 'cfi', 0.5)); + expect(result).toBeUndefined(); + }); + + it('should save cbx progress', async () => { + bookPatchServiceMock.saveCbxProgress.mockReturnValue(of(void 0)); + const result = await firstValueFrom(service.saveCbxProgress(1, 2, 0.5)); + expect(result).toBeUndefined(); + }); + + it('should update date finished', async () => { + bookPatchServiceMock.updateDateFinished.mockReturnValue(of(void 0)); + const result = await firstValueFrom(service.updateDateFinished(1, '2020-01-01')); + expect(result).toBeUndefined(); + }); + + it('should reset progress', async () => { + bookPatchServiceMock.resetProgress.mockReturnValue(of([{bookId: 1, readStatus: ReadStatus.READ, readStatusModifiedTime: ''}])); + const result = await firstValueFrom(service.resetProgress([1], 'BOOKLORE')); + expect(result).toEqual([{bookId: 1, readStatus: ReadStatus.READ, readStatusModifiedTime: ''}]); + }); + + it('should update book read status', async () => { + bookPatchServiceMock.updateBookReadStatus.mockReturnValue(of([{bookId: 1, readStatus: ReadStatus.READ, readStatusModifiedTime: ''}])); + const result = await firstValueFrom(service.updateBookReadStatus([1], ReadStatus.READ)); + expect(result).toEqual([{bookId: 1, readStatus: ReadStatus.READ, readStatusModifiedTime: ''}]); + }); + }); + + describe('Personal Rating', () => { + it('should reset personal rating', async () => { + bookPatchServiceMock.resetPersonalRating.mockReturnValue(of([{bookId: 1}])); + const result = await firstValueFrom(service.resetPersonalRating([1])); + expect(result).toEqual([{bookId: 1}]); + }); + + it('should update personal rating', async () => { + bookPatchServiceMock.updatePersonalRating.mockReturnValue(of([{bookId: 1, personalRating: 5}])); + const result = await firstValueFrom(service.updatePersonalRating([1], 5)); + expect(result).toEqual([{bookId: 1, personalRating: 5}]); + }); + }); + + describe('Metadata Operations', () => { + it('should fetch book metadata', async () => { + httpMock.post.mockReturnValue(of([{bookId: 1}])); + const req: FetchMetadataRequest = { + bookId: 1, + providers: [], + title: '', + author: '', + isbn: '' + }; + const result = await firstValueFrom(service.fetchBookMetadata(1, req)); + expect(result).toEqual([{bookId: 1}]); + }); + + it('should update book metadata', async () => { + httpMock.put.mockReturnValue(of({bookId: 1})); + service.handleBookMetadataUpdate = vi.fn(); + const result = await firstValueFrom(service.updateBookMetadata(1, {} as any, true)); + expect(result).toEqual({bookId: 1}); + expect(service.handleBookMetadataUpdate).toHaveBeenCalledWith(1, {bookId: 1}); + }); + + it('should update books metadata', async () => { + httpMock.put.mockReturnValue(of(void 0)); + const result = await firstValueFrom(service.updateBooksMetadata({} as any)); + expect(result).toBeUndefined(); + }); + + it('should toggle all lock', async () => { + httpMock.put.mockReturnValue(of([{bookId: 1}])); + bookStateServiceMock.getCurrentBookState.mockReturnValue({ + books: [{id: 1, metadata: {bookId: 1}}], + loaded: true, + error: null + }); + const result = await firstValueFrom(service.toggleAllLock(new Set([1]), 'lock')); + expect(result).toBeUndefined(); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalled(); + }); + + it('should handle toggle all lock error', async () => { + httpMock.put.mockReturnValue(throwError(() => ({message: 'fail'}))); + await expect(firstValueFrom(service.toggleAllLock(new Set([1]), 'lock'))).rejects.toBeTruthy(); + }); + + it('should toggle field locks', async () => { + httpMock.put.mockReturnValue(of(void 0)); + bookStateServiceMock.getCurrentBookState.mockReturnValue({ + books: [{id: 1, metadata: {}}], + loaded: true, + error: null + }); + const result = await firstValueFrom(service.toggleFieldLocks([1], {title: 'LOCK'})); + expect(result).toBeUndefined(); + expect(bookStateServiceMock.updateBookState).toHaveBeenCalled(); + }); + + it('should handle toggle field locks error', async () => { + httpMock.put.mockReturnValue(throwError(() => ({message: 'fail'}))); + await expect(firstValueFrom(service.toggleFieldLocks([1], {title: 'LOCK'}))).rejects.toBeTruthy(); + expect(messageServiceMock.add).toHaveBeenCalledWith(expect.objectContaining({severity: 'error'})); + }); + + it('should consolidate metadata', async () => { + httpMock.post.mockReturnValue(of({})); + service.refreshBooks = vi.fn(); + await firstValueFrom(service.consolidateMetadata('authors', ['a'], ['b'])); + expect(service.refreshBooks).toHaveBeenCalled(); + }); + + it('should delete metadata', async () => { + httpMock.post.mockReturnValue(of({})); + service.refreshBooks = vi.fn(); + await firstValueFrom(service.deleteMetadata('authors', ['a'])); + expect(service.refreshBooks).toHaveBeenCalled(); + }); + }); + + describe('Cover Operations', () => { + it('should get upload cover url', () => { + expect(service.getUploadCoverUrl(1)).toContain('/1/metadata/cover/upload'); + }); + + it('should upload cover from url and handleBookMetadataUpdate', async () => { + httpMock.post.mockReturnValue(of({bookId: 1})); + service.handleBookMetadataUpdate = vi.fn(); + const result = await firstValueFrom(service.uploadCoverFromUrl(1, 'url')); + expect(result).toEqual({bookId: 1}); + expect(service.handleBookMetadataUpdate).toHaveBeenCalledWith(1, {bookId: 1}); + }); + + it('should regenerate covers', async () => { + httpMock.post.mockReturnValue(of(void 0)); + const result = await firstValueFrom(service.regenerateCovers()); + expect(result).toBeUndefined(); + }); + + it('should regenerate cover', async () => { + httpMock.post.mockReturnValue(of(void 0)); + const result = await firstValueFrom(service.regenerateCover(1)); + expect(result).toBeUndefined(); + }); + + it('should generate custom cover', async () => { + httpMock.post.mockReturnValue(of(void 0)); + const result = await firstValueFrom(service.generateCustomCover(1)); + expect(result).toBeUndefined(); + }); + + it('should regenerate covers for books', async () => { + httpMock.post.mockReturnValue(of(void 0)); + const result = await firstValueFrom(service.regenerateCoversForBooks([1, 2])); + expect(result).toBeUndefined(); + }); + + it('should bulk upload cover', async () => { + httpMock.post.mockReturnValue(of(void 0)); + const file = new File(['abc'], 'cover.jpg'); + const result = await firstValueFrom(service.bulkUploadCover([1, 2], file)); + expect(result).toBeUndefined(); + }); + }); + + describe('Websocket Handlers', () => { + it('should handle newly created book', () => { + const book = {id: 1}; + service.handleNewlyCreatedBook(book as Book); + expect(bookSocketServiceMock.handleNewlyCreatedBook).toHaveBeenCalledWith(book); + }); + + it('should handle removed book ids', () => { + service.handleRemovedBookIds([1, 2]); + expect(bookSocketServiceMock.handleRemovedBookIds).toHaveBeenCalledWith([1, 2]); + }); + + it('should handle book update', () => { + const book = {id: 1}; + service.handleBookUpdate(book as Book); + expect(bookSocketServiceMock.handleBookUpdate).toHaveBeenCalledWith(book); + }); + + it('should handle multiple book updates', () => { + const books = [{id: 1}, {id: 2}]; + service.handleMultipleBookUpdates(books as Book[]); + expect(bookSocketServiceMock.handleMultipleBookUpdates).toHaveBeenCalledWith(books); + }); + + it('should handle book metadata update', () => { + service.handleBookMetadataUpdate(1, {bookId: 1} as BookMetadata); + expect(bookSocketServiceMock.handleBookMetadataUpdate).toHaveBeenCalledWith(1, {bookId: 1}); + }); + + it('should handle multiple book cover patches', () => { + const patches = [{id: 1, coverUpdatedOn: 'now'}]; + service.handleMultipleBookCoverPatches(patches); + expect(bookSocketServiceMock.handleMultipleBookCoverPatches).toHaveBeenCalledWith(patches); + }); + }); +}); + diff --git a/booklore-ui/src/app/features/book/service/cbx-reader.service.spec.ts b/booklore-ui/src/app/features/book/service/cbx-reader.service.spec.ts new file mode 100644 index 000000000..10654f131 --- /dev/null +++ b/booklore-ui/src/app/features/book/service/cbx-reader.service.spec.ts @@ -0,0 +1,96 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {EnvironmentInjector, runInInjectionContext} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {of, throwError} from 'rxjs'; + +import {CbxReaderService} from './cbx-reader.service'; +import {AuthService} from '../../../shared/service/auth.service'; + +describe('CbxReaderService', () => { + let service: CbxReaderService; + let httpClientMock: any; + let authServiceMock: any; + + beforeEach(() => { + httpClientMock = { + get: vi.fn() + }; + + authServiceMock = { + getOidcAccessToken: vi.fn(), + getInternalAccessToken: vi.fn() + }; + + TestBed.configureTestingModule({ + providers: [ + CbxReaderService, + {provide: HttpClient, useValue: httpClientMock}, + {provide: AuthService, useValue: authServiceMock} + ] + }); + + const injector = TestBed.inject(EnvironmentInjector); + + service = runInInjectionContext( + injector, + () => TestBed.inject(CbxReaderService) + ); + }); + + it('should get available pages with token', () => { + authServiceMock.getOidcAccessToken.mockReturnValue('abc123'); + authServiceMock.getInternalAccessToken.mockReturnValue(null); + httpClientMock.get.mockReturnValue(of([1, 2, 3])); + service.getAvailablePages(42).subscribe(pages => { + expect(pages).toEqual([1, 2, 3]); + expect(httpClientMock.get).toHaveBeenCalledWith(expect.stringContaining('token=abc123')); + }); + }); + + it('should get available pages without token', () => { + authServiceMock.getOidcAccessToken.mockReturnValue(null); + authServiceMock.getInternalAccessToken.mockReturnValue(null); + httpClientMock.get.mockReturnValue(of([4, 5])); + service.getAvailablePages(99).subscribe(pages => { + expect(pages).toEqual([4, 5]); + expect(httpClientMock.get).toHaveBeenCalledWith(expect.not.stringContaining('token=')); + }); + }); + + it('should handle error when getting available pages', () => { + authServiceMock.getOidcAccessToken.mockReturnValue('tok'); + httpClientMock.get.mockReturnValue(throwError(() => new Error('fail'))); + service.getAvailablePages(1).subscribe({ + error: (err: any) => { + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe('fail'); + } + }); + }); + + it('should return correct page image url with oidc token', () => { + authServiceMock.getOidcAccessToken.mockReturnValue('tok'); + authServiceMock.getInternalAccessToken.mockReturnValue(null); + const url = service.getPageImageUrl(5, 7); + expect(url).toContain('/api/v1/media/book/5/cbx/pages/7'); + expect(url).toContain('token=tok'); + }); + + it('should return correct page image url with internal token', () => { + authServiceMock.getOidcAccessToken.mockReturnValue(null); + authServiceMock.getInternalAccessToken.mockReturnValue('inttok'); + const url = service.getPageImageUrl(8, 2); + expect(url).toContain('/api/v1/media/book/8/cbx/pages/2'); + expect(url).toContain('token=inttok'); + }); + + it('should return correct page image url without token', () => { + authServiceMock.getOidcAccessToken.mockReturnValue(null); + authServiceMock.getInternalAccessToken.mockReturnValue(null); + const url = service.getPageImageUrl(3, 1); + expect(url).toContain('/api/v1/media/book/3/cbx/pages/1'); + expect(url).not.toContain('token='); + }); +}); + diff --git a/booklore-ui/src/app/features/book/service/library-shelf-menu.service.spec.ts b/booklore-ui/src/app/features/book/service/library-shelf-menu.service.spec.ts new file mode 100644 index 000000000..449efcbd6 --- /dev/null +++ b/booklore-ui/src/app/features/book/service/library-shelf-menu.service.spec.ts @@ -0,0 +1,350 @@ +import {TestBed} from '@angular/core/testing'; +import {EnvironmentInjector, runInInjectionContext} from '@angular/core'; +import {ConfirmationService, MessageService} from 'primeng/api'; +import {Router} from '@angular/router'; +import {LibraryService} from './library.service'; +import {ShelfService} from './shelf.service'; +import {TaskHelperService} from '../../settings/task-management/task-helper.service'; +import {DialogLauncherService} from '../../../shared/services/dialog-launcher.service'; +import {MagicShelf, MagicShelfService} from '../../magic-shelf/service/magic-shelf.service'; +import {UserService} from "../../settings/user-management/user.service"; +import {LoadingService} from '../../../core/services/loading.service'; +import {LibraryShelfMenuService} from './library-shelf-menu.service'; +import {Library} from '../model/library.model'; +import {Shelf} from '../model/shelf.model'; +import {MetadataRefreshType} from '../../metadata/model/request/metadata-refresh-type.enum'; +import {of, throwError} from 'rxjs'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +describe('LibraryShelfMenuService', () => { + let service: LibraryShelfMenuService; + let confirmationService: any; + let messageService: any; + let libraryService: any; + let shelfService: any; + let taskHelperService: any; + let router: any; + let dialogLauncherService: any; + let magicShelfService: any; + let userService: any; + let loadingService: any; + + const mockLibrary: Library = {id: 1, name: 'Lib1'} as Library; + const mockShelf: Shelf = {id: 2, name: 'Shelf1'} as Shelf; + const mockMagicShelf: MagicShelf = {id: 3, name: 'Magic1', isPublic: false} as MagicShelf; + + beforeEach(() => { + confirmationService = {confirm: vi.fn()}; + messageService = {add: vi.fn()}; + libraryService = { + refreshLibrary: vi.fn(), + deleteLibrary: vi.fn() + }; + shelfService = { + deleteShelf: vi.fn() + }; + taskHelperService = { + refreshMetadataTask: vi.fn() + }; + router = {navigate: vi.fn()}; + dialogLauncherService = { + openLibraryEditDialog: vi.fn(), + openLibraryMetadataFetchDialog: vi.fn(), + openShelfEditDialog: vi.fn(), + openMagicShelfEditDialog: vi.fn() + }; + magicShelfService = { + deleteShelf: vi.fn() + }; + userService = { + getCurrentUser: vi.fn() + }; + loadingService = { + show: vi.fn().mockReturnValue('loader-id'), + hide: vi.fn() + }; + + TestBed.configureTestingModule({ + providers: [ + LibraryShelfMenuService, + {provide: ConfirmationService, useValue: confirmationService}, + {provide: MessageService, useValue: messageService}, + {provide: LibraryService, useValue: libraryService}, + {provide: ShelfService, useValue: shelfService}, + {provide: TaskHelperService, useValue: taskHelperService}, + {provide: Router, useValue: router}, + {provide: DialogLauncherService, useValue: dialogLauncherService}, + {provide: MagicShelfService, useValue: magicShelfService}, + {provide: UserService, useValue: userService}, + {provide: LoadingService, useValue: loadingService} + ] + }); + + const injector = TestBed.inject(EnvironmentInjector); + service = runInInjectionContext(injector, () => TestBed.inject(LibraryShelfMenuService)); + }); + + describe('initializeLibraryMenuItems', () => { + it('should provide all menu items for a library', () => { + const items = service.initializeLibraryMenuItems(mockLibrary); + expect(items.length).toBe(1); + const options = items[0].items!; + expect(options.some(i => i.label === 'Edit Library')).toBe(true); + expect(options.some(i => i.label === 'Re-scan Library')).toBe(true); + expect(options.some(i => i.label === 'Custom Fetch Metadata')).toBe(true); + expect(options.some(i => i.label === 'Auto Fetch Metadata')).toBe(true); + expect(options.some(i => i.label === 'Delete Library')).toBe(true); + }); + + it('should call openLibraryEditDialog when Edit Library is clicked', () => { + const items = service.initializeLibraryMenuItems(mockLibrary); + const edit = items[0].items!.find(i => i.label === 'Edit Library'); + edit!.command!({}); + expect(dialogLauncherService.openLibraryEditDialog).toHaveBeenCalledWith(mockLibrary.id); + }); + + it('should call openLibraryMetadataFetchDialog when Custom Fetch Metadata is clicked', () => { + const items = service.initializeLibraryMenuItems(mockLibrary); + const fetch = items[0].items!.find(i => i.label === 'Custom Fetch Metadata'); + fetch!.command!({}); + expect(dialogLauncherService.openLibraryMetadataFetchDialog).toHaveBeenCalledWith(mockLibrary.id); + }); + + it('should call refreshMetadataTask when Auto Fetch Metadata is clicked', () => { + taskHelperService.refreshMetadataTask.mockReturnValue(of({})); + const items = service.initializeLibraryMenuItems(mockLibrary); + const auto = items[0].items!.find(i => i.label === 'Auto Fetch Metadata'); + auto!.command!({}); + expect(taskHelperService.refreshMetadataTask).toHaveBeenCalledWith({ + refreshType: MetadataRefreshType.LIBRARY, + libraryId: mockLibrary.id + }); + }); + + it('should show confirmation dialog for Re-scan Library', () => { + libraryService.refreshLibrary.mockReturnValue(of({})); + const items = service.initializeLibraryMenuItems(mockLibrary); + const rescan = items[0].items!.find(i => i.label === 'Re-scan Library'); + rescan!.command!({}); + expect(confirmationService.confirm).toHaveBeenCalledTimes(1); + const confirmCall = confirmationService.confirm.mock.calls[0][0]; + expect(confirmCall.message).toContain(mockLibrary.name); + expect(confirmCall.header).toBe('Confirmation'); + }); + + it('should refresh library and show success message on accept', () => { + libraryService.refreshLibrary.mockReturnValue(of({})); + const items = service.initializeLibraryMenuItems(mockLibrary); + const rescan = items[0].items!.find(i => i.label === 'Re-scan Library'); + rescan!.command!({}); + const confirmCall = confirmationService.confirm.mock.calls[0][0]; + confirmCall.accept(); + expect(libraryService.refreshLibrary).toHaveBeenCalledWith(mockLibrary.id); + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'info', + summary: 'Success', + detail: 'Library refresh scheduled' + }); + }); + + it('should show error message if refreshLibrary fails', () => { + libraryService.refreshLibrary.mockReturnValue(throwError(() => ({}))); + const items = service.initializeLibraryMenuItems(mockLibrary); + const rescan = items[0].items!.find(i => i.label === 'Re-scan Library'); + rescan!.command!({}); + const confirmCall = confirmationService.confirm.mock.calls[0][0]; + confirmCall.accept(); + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'error', + summary: 'Failed', + detail: 'Failed to refresh library' + }); + }); + + it('should show confirmation dialog for Delete Library', () => { + libraryService.deleteLibrary.mockReturnValue(of({})); + const items = service.initializeLibraryMenuItems(mockLibrary); + const del = items[0].items!.find(i => i.label === 'Delete Library'); + del!.command!({}); + expect(confirmationService.confirm).toHaveBeenCalledTimes(1); + const confirmCall = confirmationService.confirm.mock.calls[0][0]; + expect(confirmCall.message).toContain(mockLibrary.name); + expect(confirmCall.header).toBe('Confirmation'); + }); + + it('should delete library, navigate, and show success message on accept', () => { + libraryService.deleteLibrary.mockReturnValue(of({})); + const items = service.initializeLibraryMenuItems(mockLibrary); + const del = items[0].items!.find(i => i.label === 'Delete Library'); + del!.command!({}); + const confirmCall = confirmationService.confirm.mock.calls[0][0]; + confirmCall.accept(); + expect(loadingService.show).toHaveBeenCalledWith(expect.stringContaining(mockLibrary.name)); + expect(libraryService.deleteLibrary).toHaveBeenCalledWith(mockLibrary.id); + expect(router.navigate).toHaveBeenCalledWith(['/']); + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'info', + summary: 'Success', + detail: 'Library was deleted' + }); + expect(loadingService.hide).toHaveBeenCalledWith('loader-id'); + }); + + it('should show error message if deleteLibrary fails', () => { + libraryService.deleteLibrary.mockReturnValue(throwError(() => ({}))); + const items = service.initializeLibraryMenuItems(mockLibrary); + const del = items[0].items!.find(i => i.label === 'Delete Library'); + del!.command!({}); + const confirmCall = confirmationService.confirm.mock.calls[0][0]; + confirmCall.accept(); + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'error', + summary: 'Failed', + detail: 'Failed to delete library' + }); + expect(loadingService.hide).toHaveBeenCalledWith('loader-id'); + }); + }); + + describe('initializeShelfMenuItems', () => { + it('should provide all menu items for a shelf', () => { + const items = service.initializeShelfMenuItems(mockShelf); + expect(items.length).toBe(1); + const options = items[0].items!; + expect(options.some(i => i.label === 'Edit Shelf')).toBe(true); + expect(options.some(i => i.label === 'Delete Shelf')).toBe(true); + }); + + it('should call openShelfEditDialog when Edit Shelf is clicked', () => { + const items = service.initializeShelfMenuItems(mockShelf); + const edit = items[0].items!.find(i => i.label === 'Edit Shelf'); + edit!.command!({}); + expect(dialogLauncherService.openShelfEditDialog).toHaveBeenCalledWith(mockShelf.id); + }); + + it('should show confirmation dialog for Delete Shelf', () => { + shelfService.deleteShelf.mockReturnValue(of({})); + const items = service.initializeShelfMenuItems(mockShelf); + const del = items[0].items!.find(i => i.label === 'Delete Shelf'); + del!.command!({}); + expect(confirmationService.confirm).toHaveBeenCalledTimes(1); + const confirmCall = confirmationService.confirm.mock.calls[0][0]; + expect(confirmCall.message).toContain(mockShelf.name); + expect(confirmCall.header).toBe('Confirmation'); + }); + + it('should delete shelf, navigate, and show success message on accept', () => { + shelfService.deleteShelf.mockReturnValue(of({})); + const items = service.initializeShelfMenuItems(mockShelf); + const del = items[0].items!.find(i => i.label === 'Delete Shelf'); + del!.command!({}); + const confirmCall = confirmationService.confirm.mock.calls[0][0]; + confirmCall.accept(); + expect(shelfService.deleteShelf).toHaveBeenCalledWith(mockShelf.id); + expect(router.navigate).toHaveBeenCalledWith(['/']); + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'info', + summary: 'Success', + detail: 'Shelf was deleted' + }); + }); + + it('should show error message if deleteShelf fails', () => { + shelfService.deleteShelf.mockReturnValue(throwError(() => ({}))); + const items = service.initializeShelfMenuItems(mockShelf); + const del = items[0].items!.find(i => i.label === 'Delete Shelf'); + del!.command!({}); + const confirmCall = confirmationService.confirm.mock.calls[0][0]; + confirmCall.accept(); + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'error', + summary: 'Failed', + detail: 'Failed to delete shelf' + }); + }); + }); + + describe('initializeMagicShelfMenuItems', () => { + it('should provide all menu items for a magic shelf', () => { + userService.getCurrentUser.mockReturnValue({permissions: {admin: true}}); + const items = service.initializeMagicShelfMenuItems(mockMagicShelf); + expect(items.length).toBe(1); + const options = items[0].items!; + expect(options.some(i => i.label === 'Edit Magic Shelf')).toBe(true); + expect(options.some(i => i.label === 'Delete Magic Shelf')).toBe(true); + }); + + it('should call openMagicShelfEditDialog when Edit Magic Shelf is clicked', () => { + userService.getCurrentUser.mockReturnValue({permissions: {admin: true}}); + const items = service.initializeMagicShelfMenuItems(mockMagicShelf); + const edit = items[0].items!.find(i => i.label === 'Edit Magic Shelf'); + edit!.command!({}); + expect(dialogLauncherService.openMagicShelfEditDialog).toHaveBeenCalledWith(mockMagicShelf.id); + }); + + it('should show confirmation dialog for Delete Magic Shelf', () => { + userService.getCurrentUser.mockReturnValue({permissions: {admin: true}}); + magicShelfService.deleteShelf.mockReturnValue(of({})); + const items = service.initializeMagicShelfMenuItems(mockMagicShelf); + const del = items[0].items!.find(i => i.label === 'Delete Magic Shelf'); + del!.command!({}); + expect(confirmationService.confirm).toHaveBeenCalledTimes(1); + const confirmCall = confirmationService.confirm.mock.calls[0][0]; + expect(confirmCall.message).toContain(mockMagicShelf.name); + expect(confirmCall.header).toBe('Confirmation'); + }); + + it('should delete magic shelf, navigate, and show success message on accept', () => { + userService.getCurrentUser.mockReturnValue({permissions: {admin: true}}); + magicShelfService.deleteShelf.mockReturnValue(of({})); + const items = service.initializeMagicShelfMenuItems(mockMagicShelf); + const del = items[0].items!.find(i => i.label === 'Delete Magic Shelf'); + del!.command!({}); + const confirmCall = confirmationService.confirm.mock.calls[0][0]; + confirmCall.accept(); + expect(magicShelfService.deleteShelf).toHaveBeenCalledWith(mockMagicShelf.id); + expect(router.navigate).toHaveBeenCalledWith(['/']); + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'info', + summary: 'Success', + detail: 'Magic shelf was deleted' + }); + }); + + it('should show error message if deleteMagicShelf fails', () => { + userService.getCurrentUser.mockReturnValue({permissions: {admin: true}}); + magicShelfService.deleteShelf.mockReturnValue(throwError(() => ({}))); + const items = service.initializeMagicShelfMenuItems(mockMagicShelf); + const del = items[0].items!.find(i => i.label === 'Delete Magic Shelf'); + del!.command!({}); + const confirmCall = confirmationService.confirm.mock.calls[0][0]; + confirmCall.accept(); + expect(messageService.add).toHaveBeenCalledWith({ + severity: 'error', + summary: 'Failed', + detail: 'Failed to delete shelf' + }); + }); + + it('should disable options for public shelf if not admin', () => { + userService.getCurrentUser.mockReturnValue({permissions: {admin: false}}); + const publicMagicShelf = {...mockMagicShelf, isPublic: true}; + const items = service.initializeMagicShelfMenuItems(publicMagicShelf); + const edit = items[0].items!.find(i => i.label === 'Edit Magic Shelf'); + const del = items[0].items!.find(i => i.label === 'Delete Magic Shelf'); + expect(edit!.disabled).toBe(true); + expect(del!.disabled).toBe(true); + }); + + it('should not disable options for public shelf if admin', () => { + userService.getCurrentUser.mockReturnValue({permissions: {admin: true}}); + const publicMagicShelf = {...mockMagicShelf, isPublic: true}; + const items = service.initializeMagicShelfMenuItems(publicMagicShelf); + const edit = items[0].items!.find(i => i.label === 'Edit Magic Shelf'); + const del = items[0].items!.find(i => i.label === 'Delete Magic Shelf'); + expect(edit!.disabled).toBe(false); + expect(del!.disabled).toBe(false); + }); + }); +}); + diff --git a/booklore-ui/src/app/features/book/service/library.service.spec.ts b/booklore-ui/src/app/features/book/service/library.service.spec.ts new file mode 100644 index 000000000..e06c30c05 --- /dev/null +++ b/booklore-ui/src/app/features/book/service/library.service.spec.ts @@ -0,0 +1,167 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {EnvironmentInjector, runInInjectionContext} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {firstValueFrom, of, throwError} from 'rxjs'; + +import {LibraryService} from './library.service'; +import {BookService} from './book.service'; +import {AuthService} from '../../../shared/service/auth.service'; +import {Library} from '../model/library.model'; + +describe('LibraryService', () => { + let service: LibraryService; + let httpClientMock: any; + let bookServiceMock: any; + let authServiceMock: any; + + beforeEach(() => { + httpClientMock = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn() + }; + + bookServiceMock = { + removeBooksByLibraryId: vi.fn(), + bookState$: of({ + books: [ + {id: 1, libraryId: 10}, + {id: 2, libraryId: 20}, + {id: 3, libraryId: 10}, + {id: 4} + ] + }) + }; + + authServiceMock = { + token$: of('token') + }; + + TestBed.configureTestingModule({ + providers: [ + LibraryService, + {provide: HttpClient, useValue: httpClientMock}, + {provide: BookService, useValue: bookServiceMock}, + {provide: AuthService, useValue: authServiceMock} + ] + }); + + const injector = TestBed.inject(EnvironmentInjector); + + service = runInInjectionContext( + injector, + () => TestBed.inject(LibraryService) + ); + }); + + it('should fetch libraries and update state', () => { + const libraries: Library[] = [{id: 1, name: 'LibA', icon: 'icon', watch: true, paths: []}]; + httpClientMock.get.mockReturnValue(of(libraries)); + service['fetchLibraries']().subscribe(result => { + expect(result).toEqual(libraries); + expect(service['libraryStateSubject'].value.libraries).toEqual(libraries); + expect(service['libraryStateSubject'].value.loaded).toBe(true); + }); + }); + + it('should handle fetch libraries error', () => { + httpClientMock.get.mockReturnValue(throwError(() => new Error('fail'))); + service['libraryStateSubject'].next({libraries: null, loaded: false, error: null}); + service['fetchLibraries']().subscribe({ + error: (err: any) => { + expect(service['libraryStateSubject'].value.error).toBe('fail'); + } + }); + }); + + it('should create a library and update state', () => { + const library: Library = {id: 2, name: 'LibB', icon: 'icon', watch: true, paths: []}; + httpClientMock.post.mockReturnValue(of(library)); + service['libraryStateSubject'].next({libraries: [], loaded: true, error: null}); + service.createLibrary(library).subscribe(result => { + expect(result).toEqual(library); + expect(service['libraryStateSubject'].value.libraries).toContain(library); + }); + }); + + it('should update a library and update state', () => { + const library: Library = {id: 3, name: 'LibC', icon: 'icon', watch: true, paths: []}; + httpClientMock.put.mockReturnValue(of({...library, name: 'LibC2'})); + service['libraryStateSubject'].next({libraries: [library], loaded: true, error: null}); + service.updateLibrary({...library, name: 'LibC2'}, 3).subscribe(result => { + expect(result.name).toBe('LibC2'); + expect(service['libraryStateSubject'].value.libraries?.find(l => l.id === 3)?.name).toBe('LibC2'); + }); + }); + + it('should delete a library and update state', () => { + httpClientMock.delete.mockReturnValue(of(void 0)); + const library: Library = {id: 4, name: 'LibD', icon: 'icon', watch: true, paths: []}; + service['libraryStateSubject'].next({libraries: [library], loaded: true, error: null}); + service.deleteLibrary(4).subscribe(() => { + expect(service['libraryStateSubject'].value.libraries).toEqual([]); + expect(bookServiceMock.removeBooksByLibraryId).toHaveBeenCalledWith(4); + }); + }); + + it('should handle delete library error', () => { + httpClientMock.delete.mockReturnValue(throwError(() => new Error('delete error'))); + service['libraryStateSubject'].next({libraries: [{id: 5, name: 'LibE', icon: 'icon', watch: true, paths: []}], loaded: true, error: null}); + service.deleteLibrary(5).subscribe({ + error: () => { + expect(service['libraryStateSubject'].value.error).toBe('delete error'); + } + }); + }); + + it('should refresh a library and handle error', () => { + httpClientMock.put.mockReturnValue(throwError(() => new Error('refresh error'))); + service['libraryStateSubject'].next({libraries: [{id: 6, name: 'LibF', icon: 'icon', watch: true, paths: []}], loaded: true, error: null}); + service.refreshLibrary(6).subscribe({ + error: () => { + expect(service['libraryStateSubject'].value.error).toBe('refresh error'); + } + }); + }); + + it('should update library file naming pattern', () => { + const library: Library = {id: 7, name: 'LibG', icon: 'icon', watch: true, paths: [], fileNamingPattern: 'old'}; + const updatedLibrary = {...library, fileNamingPattern: 'new'}; + httpClientMock.patch.mockReturnValue(of(updatedLibrary)); + service['libraryStateSubject'].next({libraries: [library], loaded: true, error: null}); + service.updateLibraryFileNamingPattern(7, 'new').subscribe(result => { + expect(result.fileNamingPattern).toBe('new'); + expect(service['libraryStateSubject'].value.libraries?.find(l => l.id === 7)?.fileNamingPattern).toBe('new'); + }); + }); + + it('should check if library exists by name', () => { + const library: Library = {id: 8, name: 'LibH', icon: 'icon', watch: true, paths: []}; + service['libraryStateSubject'].next({libraries: [library], loaded: true, error: null}); + expect(service.doesLibraryExistByName('LibH')).toBe(true); + expect(service.doesLibraryExistByName('NonExistent')).toBe(false); + }); + + it('should find library by id', () => { + const library: Library = {id: 9, name: 'LibI', icon: 'icon', watch: true, paths: []}; + service['libraryStateSubject'].next({libraries: [library], loaded: true, error: null}); + expect(service.findLibraryById(9)).toEqual(library); + expect(service.findLibraryById(999)).toBeUndefined(); + }); + + it('should get libraries from state', () => { + const libraries: Library[] = [ + {id: 10, name: 'LibJ', icon: 'icon', watch: true, paths: []} + ]; + service['libraryStateSubject'].next({libraries, loaded: true, error: null}); + expect(service.getLibrariesFromState()).toEqual(libraries); + }); + + it('should get book count for library', async () => { + const count = await firstValueFrom(service.getBookCount(10)); + expect(count).toBe(2); + }); +}); diff --git a/booklore-ui/src/app/features/book/service/library.service.ts b/booklore-ui/src/app/features/book/service/library.service.ts index f1711934c..55e03da89 100644 --- a/booklore-ui/src/app/features/book/service/library.service.ts +++ b/booklore-ui/src/app/features/book/service/library.service.ts @@ -133,7 +133,6 @@ export class LibraryService { ); } - doesLibraryExistByName(name: string): boolean { return (this.libraryStateSubject.value.libraries || []).some(l => l.name === name); } @@ -146,12 +145,6 @@ export class LibraryService { return this.libraryStateSubject.value.libraries || []; } - getLibraryPathById(pathId: number): string | undefined { - return this.libraryStateSubject.value.libraries - ?.find(lib => lib.paths.some(p => p.id === pathId)) - ?.paths.find(p => p.id === pathId)?.path; - } - getBookCount(libraryId: number): Observable { return this.bookService.bookState$.pipe( map(state => (state.books || []).filter(b => b.libraryId === libraryId).length) diff --git a/booklore-ui/src/app/features/book/service/metadata-task.spec.ts b/booklore-ui/src/app/features/book/service/metadata-task.spec.ts new file mode 100644 index 000000000..e167f6c04 --- /dev/null +++ b/booklore-ui/src/app/features/book/service/metadata-task.spec.ts @@ -0,0 +1,123 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {EnvironmentInjector, runInInjectionContext} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {firstValueFrom, of, throwError} from 'rxjs'; + +import {MetadataFetchTask, MetadataTaskService} from './metadata-task'; +import {API_CONFIG} from '../../../core/config/api-config'; +import {MetadataBatchProgressNotification, MetadataBatchStatus} from '../../../shared/model/metadata-batch-progress.model'; + +describe('MetadataTaskService', () => { + let service: MetadataTaskService; + let httpClientMock: any; + + beforeEach(() => { + httpClientMock = { + get: vi.fn(), + delete: vi.fn(), + post: vi.fn() + }; + + TestBed.configureTestingModule({ + providers: [ + MetadataTaskService, + { provide: HttpClient, useValue: httpClientMock } + ] + }); + + const injector = TestBed.inject(EnvironmentInjector); + + service = runInInjectionContext( + injector, + () => TestBed.inject(MetadataTaskService) + ); + }); + + it('should get task with proposals', async () => { + const task: MetadataFetchTask = { + id: 'task1', + status: 'IN_PROGRESS', + completed: 1, + totalBooks: 2, + startedAt: '2024-01-01T00:00:00Z', + completedAt: null, + initiatedBy: 'user1', + errorMessage: null, + proposals: [] + }; + httpClientMock.get.mockReturnValue(of({ task })); + const result = await firstValueFrom(service.getTaskWithProposals('task1')); + expect(result).toEqual(task); + expect(httpClientMock.get).toHaveBeenCalledWith(`${API_CONFIG.BASE_URL}/api/metadata/tasks/task1`); + }); + + it('should handle error in getTaskWithProposals', async () => { + httpClientMock.get.mockReturnValue(throwError(() => new Error('fail'))); + try { + await firstValueFrom(service.getTaskWithProposals('task2')); + } catch (e: any) { + expect(e.message).toBe('fail'); + } + }); + + it('should delete a task', async () => { + httpClientMock.delete.mockReturnValue(of(void 0)); + await firstValueFrom(service.deleteTask('task3')); + expect(httpClientMock.delete).toHaveBeenCalledWith(`${API_CONFIG.BASE_URL}/api/metadata/tasks/task3`); + }); + + it('should handle error in deleteTask', async () => { + httpClientMock.delete.mockReturnValue(throwError(() => new Error('delete error'))); + try { + await firstValueFrom(service.deleteTask('task4')); + } catch (e: any) { + expect(e.message).toBe('delete error'); + } + }); + + it('should update proposal status', async () => { + httpClientMock.post.mockReturnValue(of(void 0)); + await firstValueFrom(service.updateProposalStatus('task5', 123, 'ACCEPTED')); + expect(httpClientMock.post).toHaveBeenCalledWith( + `${API_CONFIG.BASE_URL}/api/metadata/tasks/task5/proposals/123/status`, + null, + { params: { status: 'ACCEPTED' } } + ); + }); + + it('should handle error in updateProposalStatus', async () => { + httpClientMock.post.mockReturnValue(throwError(() => new Error('status error'))); + try { + await firstValueFrom(service.updateProposalStatus('task6', 456, 'REJECTED')); + } catch (e: any) { + expect(e.message).toBe('status error'); + } + }); + + it('should get active tasks', async () => { + const notifications: MetadataBatchProgressNotification[] = [ + { + taskId: 't1', + completed: 1, + total: 2, + message: 'Running', + status: MetadataBatchStatus.IN_PROGRESS, + review: false + } + ]; + httpClientMock.get.mockReturnValue(of(notifications)); + const result = await firstValueFrom(service.getActiveTasks()); + expect(result).toEqual(notifications); + expect(httpClientMock.get).toHaveBeenCalledWith(`${API_CONFIG.BASE_URL}/api/metadata/tasks/active`); + }); + + it('should handle error in getActiveTasks', async () => { + httpClientMock.get.mockReturnValue(throwError(() => new Error('active error'))); + try { + await firstValueFrom(service.getActiveTasks()); + } catch (e: any) { + expect(e.message).toBe('active error'); + } + }); +}); diff --git a/booklore-ui/src/app/features/book/service/new-pdf-reader.service.spec.ts b/booklore-ui/src/app/features/book/service/new-pdf-reader.service.spec.ts new file mode 100644 index 000000000..e76f2a994 --- /dev/null +++ b/booklore-ui/src/app/features/book/service/new-pdf-reader.service.spec.ts @@ -0,0 +1,70 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {EnvironmentInjector, runInInjectionContext} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; + +import {NewPdfReaderService} from './new-pdf-reader.service'; +import {AuthService} from '../../../shared/service/auth.service'; +import {API_CONFIG} from '../../../core/config/api-config'; + +describe('NewPdfReaderService', () => { + let service: NewPdfReaderService; + let authServiceMock: any; + let httpClientMock: any; + + beforeEach(() => { + authServiceMock = { + getOidcAccessToken: vi.fn(), + getInternalAccessToken: vi.fn() + }; + + httpClientMock = { + get: vi.fn() + }; + + TestBed.configureTestingModule({ + providers: [ + NewPdfReaderService, + {provide: AuthService, useValue: authServiceMock}, + {provide: HttpClient, useValue: httpClientMock} + ] + }); + + const injector = TestBed.inject(EnvironmentInjector); + + service = runInInjectionContext( + injector, + () => TestBed.inject(NewPdfReaderService) + ); + }); + + it('should append token from OIDC access token', () => { + authServiceMock.getOidcAccessToken.mockReturnValue('oidc-token'); + authServiceMock.getInternalAccessToken.mockReturnValue(null); + + const url = 'http://test.com/resource'; + const result = (service as any).appendToken(url); + + expect(result).toContain('token=oidc-token'); + }); + + it('should not append token if both tokens are null', () => { + authServiceMock.getOidcAccessToken.mockReturnValue(null); + authServiceMock.getInternalAccessToken.mockReturnValue(null); + + const url = 'http://test.com/resource'; + const result = (service as any).appendToken(url); + + expect(result).toBe(url); + }); + + it('should call http.get with correct URL', () => { + authServiceMock.getOidcAccessToken.mockReturnValue('token123'); + + service.getAvailablePages(42); + + expect(httpClientMock.get).toHaveBeenCalledWith( + `${API_CONFIG.BASE_URL}/api/v1/pdf/42/pages?token=token123` + ); + }); +}); diff --git a/booklore-ui/src/app/features/book/service/shelf.service.spec.ts b/booklore-ui/src/app/features/book/service/shelf.service.spec.ts new file mode 100644 index 000000000..4295fa488 --- /dev/null +++ b/booklore-ui/src/app/features/book/service/shelf.service.spec.ts @@ -0,0 +1,129 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {EnvironmentInjector, runInInjectionContext} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {of, throwError, firstValueFrom} from 'rxjs'; + +import {ShelfService} from './shelf.service'; +import {BookService} from './book.service'; +import {Shelf} from '../model/shelf.model'; + +describe('ShelfService', () => { + let service: ShelfService; + let httpClientMock: any; + let bookServiceMock: any; + + beforeEach(() => { + httpClientMock = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn() + }; + + bookServiceMock = { + removeBooksFromShelf: vi.fn(), + bookState$: of({ + books: [ + {id: 1, shelves: [{id: 10}]}, + {id: 2, shelves: [{id: 20}]}, + {id: 3, shelves: []}, + {id: 4} + ] + }) + }; + + TestBed.configureTestingModule({ + providers: [ + ShelfService, + {provide: HttpClient, useValue: httpClientMock}, + {provide: BookService, useValue: bookServiceMock} + ] + }); + + const injector = TestBed.inject(EnvironmentInjector); + + service = runInInjectionContext( + injector, + () => TestBed.inject(ShelfService) + ); + }); + + it('should fetch shelves and update state', () => { + const shelves: Shelf[] = [{id: 1, name: 'A', icon: 'icon'}]; + httpClientMock.get.mockReturnValue(of(shelves)); + service['fetchShelves']().subscribe(result => { + expect(result).toEqual(shelves); + expect(service['shelfStateSubject'].value.shelves).toEqual(shelves); + expect(service['shelfStateSubject'].value.loaded).toBe(true); + }); + }); + + it('should handle fetch shelves error', () => { + httpClientMock.get.mockReturnValue(throwError(() => new Error('fail'))); + service['shelfStateSubject'].next({shelves: null, loaded: false, error: null}); + service['fetchShelves']().subscribe({ + error: (err: any) => { + expect(service['shelfStateSubject'].value.error).toBe('fail'); + } + }); + }); + + it('should create a shelf and update state', () => { + const shelf: Shelf = {id: 2, name: 'B', icon: 'icon'}; + httpClientMock.post.mockReturnValue(of(shelf)); + service['shelfStateSubject'].next({shelves: [], loaded: true, error: null}); + service.createShelf(shelf).subscribe(result => { + expect(result).toEqual(shelf); + expect(service['shelfStateSubject'].value.shelves).toContain(shelf); + }); + }); + + it('should update a shelf and update state', () => { + const shelf: Shelf = {id: 3, name: 'C', icon: 'icon'}; + httpClientMock.put.mockReturnValue(of({...shelf, name: 'C2'})); + service['shelfStateSubject'].next({shelves: [shelf], loaded: true, error: null}); + service.updateShelf({...shelf, name: 'C2'}, 3).subscribe(result => { + expect(result.name).toBe('C2'); + expect(service['shelfStateSubject'].value.shelves?.find(s => s.id === 3)?.name).toBe('C2'); + }); + }); + + it('should delete a shelf and update state', () => { + httpClientMock.delete.mockReturnValue(of(void 0)); + const shelf: Shelf = {id: 4, name: 'D', icon: 'icon'}; + service['shelfStateSubject'].next({shelves: [shelf], loaded: true, error: null}); + service.deleteShelf(4).subscribe(() => { + expect(service['shelfStateSubject'].value.shelves).toEqual([]); + expect(bookServiceMock.removeBooksFromShelf).toHaveBeenCalledWith(4); + }); + }); + + it('should handle delete shelf error', () => { + httpClientMock.delete.mockReturnValue(throwError(() => new Error('delete error'))); + service['shelfStateSubject'].next({shelves: [{id: 5, name: 'E', icon: 'icon'}], loaded: true, error: null}); + service.deleteShelf(5).subscribe({ + error: () => { + expect(service['shelfStateSubject'].value.error).toBe('delete error'); + } + }); + }); + + it('should get shelf by id', () => { + const shelf: Shelf = {id: 6, name: 'F', icon: 'icon'}; + service['shelfStateSubject'].next({shelves: [shelf], loaded: true, error: null}); + expect(service.getShelfById(6)).toEqual(shelf); + expect(service.getShelfById(999)).toBeUndefined(); + }); + + it('should get book count for shelf', async () => { + const count = await firstValueFrom(service.getBookCount(10)); + expect(count).toBe(1); + }); + + it('should get unshelved book count', async () => { + const count = await firstValueFrom(service.getUnshelvedBookCount()); + expect(count).toBe(2); + }); +}); + diff --git a/booklore-ui/src/app/features/book/service/sort.service.spec.ts b/booklore-ui/src/app/features/book/service/sort.service.spec.ts new file mode 100644 index 000000000..7c1d15e91 --- /dev/null +++ b/booklore-ui/src/app/features/book/service/sort.service.spec.ts @@ -0,0 +1,142 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {SortService} from './sort.service'; +import {Book} from '../model/book.model'; +import {SortDirection} from '../model/sort.model'; + +function makeBook(partial: Partial): Book { + return { + id: 1, + bookType: "PDF", + libraryId: 1, + libraryName: "Lib", + ...partial, + } as Book; +} + +describe('SortService', () => { + let service: SortService; + let books: Book[]; + + beforeEach(() => { + service = new SortService(); + books = [ + makeBook({ + id: 1, + fileName: 'Book10.pdf', + metadata: { + bookId: 1, + title: 'Book 10', + authors: ['Alice'], + publishedDate: '2020-01-01', + seriesName: 'Series A', + seriesNumber: 2, + rating: 4.5, + reviewCount: 10, + }, + personalRating: 3, + addedOn: '2021-01-01T00:00:00Z', + lastReadTime: '2022-01-01T00:00:00Z', + fileSizeKb: 1000, + }), + makeBook({ + id: 2, + fileName: 'Book2.pdf', + metadata: { + bookId: 2, + title: 'Book 2', + authors: ['Bob'], + publishedDate: '2019-01-01', + seriesName: 'Series A', + seriesNumber: 1, + rating: 4.8, + reviewCount: 20, + }, + personalRating: 5, + addedOn: '2020-01-01T00:00:00Z', + lastReadTime: '2023-01-01T00:00:00Z', + fileSizeKb: 2000, + }), + makeBook({ + id: 3, + fileName: 'Book1.pdf', + metadata: { + bookId: 3, + title: 'Book 1', + authors: ['Alice'], + publishedDate: '2021-01-01', + rating: 4.0, + reviewCount: 5, + }, + personalRating: 4, + addedOn: '2022-01-01T00:00:00Z', + lastReadTime: '2021-01-01T00:00:00Z', + fileSizeKb: 500, + }), + ]; + }); + + it('should sort by title (natural order)', () => { + const sorted = service.applySort(books, {field: 'title', direction: SortDirection.ASCENDING, label: 'Title'}); + expect(sorted.map(b => b.metadata?.title)).toEqual(['Book 1', 'Book 2', 'Book 10']); + }); + + it('should sort by author', () => { + const sorted = service.applySort(books, {field: 'author', direction: SortDirection.ASCENDING, label: 'Author'}); + expect(sorted.map(b => b.metadata?.authors?.[0])).toEqual(['Alice', 'Alice', 'Bob']); + }); + + it('should sort by publishedDate descending', () => { + const sorted = service.applySort(books, {field: 'publishedDate', direction: SortDirection.DESCENDING, label: 'Published Date'}); + expect(sorted.map(b => b.metadata?.title)).toEqual(['Book 1', 'Book 10', 'Book 2']); + }); + + it('should sort by series (titleSeries)', () => { + const sorted = service.applySort(books, {field: 'titleSeries', direction: SortDirection.ASCENDING, label: 'Series'}); + expect(sorted.map(b => b.metadata?.title)).toEqual(['Book 1', 'Book 2', 'Book 10']); + }); + + it('should sort by personalRating descending', () => { + const sorted = service.applySort(books, {field: 'personalRating', direction: SortDirection.DESCENDING, label: 'Personal Rating'}); + expect(sorted.map(b => b.personalRating)).toEqual([5, 4, 3]); + }); + + it('should sort by fileName (natural order)', () => { + const sorted = service.applySort(books, {field: 'fileName', direction: SortDirection.ASCENDING, label: 'File Name'}); + expect(sorted.map(b => b.fileName)).toEqual(['Book1.pdf', 'Book2.pdf', 'Book10.pdf']); + }); + + it('should sort by fileSizeKb ascending', () => { + const sorted = service.applySort(books, {field: 'fileSizeKb', direction: SortDirection.ASCENDING, label: 'File Size'}); + expect(sorted.map(b => b.fileSizeKb)).toEqual([500, 1000, 2000]); + }); + + it('should sort by addedOn descending', () => { + const sorted = service.applySort(books, {field: 'addedOn', direction: SortDirection.DESCENDING, label: 'Added On'}); + expect(sorted.map(b => b.addedOn)).toEqual([ + '2022-01-01T00:00:00Z', + '2021-01-01T00:00:00Z', + '2020-01-01T00:00:00Z', + ]); + }); + + it('should handle missing extractor gracefully', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => { + }); + const sorted = service.applySort(books, {field: 'nonexistent', direction: SortDirection.ASCENDING, label: 'Nonexistent'} as any); + expect(sorted).toBe(books); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('should return original array if no sort option', () => { + const sorted = service.applySort(books, null); + expect(sorted).toBe(books); + }); + + it('should sort by random (order is not predictable)', () => { + const sorted = service.applySort(books, {field: 'random', direction: SortDirection.ASCENDING, label: 'Random'}); + expect(sorted.length).toBe(3); + // Can't assert order, but should be a permutation of the input + expect(sorted.map(b => b.id).sort()).toEqual([1, 2, 3]); + }); +}); diff --git a/booklore-ui/tsconfig.spec.json b/booklore-ui/tsconfig.spec.json index 5fb748d92..64e3a73bc 100644 --- a/booklore-ui/tsconfig.spec.json +++ b/booklore-ui/tsconfig.spec.json @@ -1,10 +1,9 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", "types": [ + "node", "jasmine" ] },