diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 62e7f36..917d61d 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -20,6 +20,18 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/package-lock.json b/package-lock.json index 2bc6605..56c4e44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,18 +42,31 @@ "devDependencies": { "@react-router/dev": "^7.13.1", "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^20", "@types/node-cron": "^3.0.11", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4", + "@vitest/coverage-v8": "^4.1.5", + "@vitest/ui": "^4.1.5", "eslint": "^9", + "jsdom": "^29.1.1", "tailwindcss": "^4", "typescript": "^5", "vite": "^6", - "vite-tsconfig-paths": "^5" + "vite-tsconfig-paths": "^5", + "vitest": "^4.1.5" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -67,6 +80,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -527,6 +591,169 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -1121,6 +1348,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -1307,6 +1552,13 @@ "pako": "^1.0.10" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@prisma/client": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", @@ -2961,6 +3213,13 @@ "win32" ] }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -3247,6 +3506,82 @@ "tailwindcss": "4.2.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -3258,6 +3593,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3305,6 +3648,24 @@ "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", "license": "MIT" }, + "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/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", @@ -3384,6 +3745,193 @@ "node": ">=0.10.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "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 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/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/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/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/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.5.tgz", + "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.5" + } + }, + "node_modules/@vitest/ui/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/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abs-svg-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", @@ -3450,6 +3998,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "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", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3491,11 +4050,50 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "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/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-dead-code-elimination": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", @@ -3763,6 +4361,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", @@ -3968,6 +4576,27 @@ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3975,6 +4604,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -4003,6 +4646,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/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -4037,6 +4687,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -4068,6 +4728,14 @@ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4122,6 +4790,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4371,6 +5052,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", @@ -4410,6 +5101,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -4511,6 +5212,13 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4586,9 +5294,9 @@ } }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -4815,6 +5523,26 @@ "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==", "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -4888,6 +5616,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4931,6 +5669,13 @@ "node": ">=0.10.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-url": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", @@ -4952,6 +5697,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "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-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/jay-peg": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz", @@ -4990,6 +5774,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5401,6 +6236,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5411,6 +6257,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "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-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5420,6 +6307,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-engine": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz", @@ -5488,6 +6382,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -5540,6 +6444,16 @@ "node": ">= 0.8" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5647,6 +6561,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", @@ -5753,6 +6678,19 @@ "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5815,6 +6753,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkg-types": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", @@ -5892,6 +6843,44 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prisma": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", @@ -6161,6 +7150,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -6255,6 +7258,19 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "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/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6426,6 +7442,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/simple-swizzle": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", @@ -6435,6 +7458,21 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6462,6 +7500,13 @@ "source-map": "^0.6.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", @@ -6470,6 +7515,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -6479,6 +7531,19 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6522,6 +7587,13 @@ "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", "license": "ISC" }, + "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/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -6559,6 +7631,23 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "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.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "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", @@ -6594,19 +7683,36 @@ } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -6615,6 +7721,42 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tsconfck": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", @@ -6680,6 +7822,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6993,16 +8145,156 @@ } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, "engines": { - "node": ">=12" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "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.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "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/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/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/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/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": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/which": { @@ -7021,6 +8313,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", @@ -7031,6 +8340,23 @@ "node": ">=0.10.0" } }, + "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/xsd-schema-validator": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/xsd-schema-validator/-/xsd-schema-validator-0.10.0.tgz", diff --git a/package.json b/package.json index e6879ef..e1eeafb 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,10 @@ "start": "react-router-serve ./build/server/index.js", "typecheck": "react-router typegen && tsc", "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage", "db:migrate": "prisma migrate dev", "db:seed": "npx ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts", "db:studio": "prisma studio", @@ -54,15 +58,21 @@ "devDependencies": { "@react-router/dev": "^7.13.1", "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^20", "@types/node-cron": "^3.0.11", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4", + "@vitest/coverage-v8": "^4.1.5", + "@vitest/ui": "^4.1.5", "eslint": "^9", + "jsdom": "^29.1.1", "tailwindcss": "^4", "typescript": "^5", "vite": "^6", - "vite-tsconfig-paths": "^5" + "vite-tsconfig-paths": "^5", + "vitest": "^4.1.5" } } diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..ed29bfc --- /dev/null +++ b/tests/README.md @@ -0,0 +1,67 @@ +# Tests - Annas RechnungsManager + +Vitest-basiertes Test-Framework für kritische Geschäftslogik. + +## Setup + +```bash +# Install dependencies (already done) +npm install + +# Run tests +npm run test # Single run +npm run test:watch # Watch mode +npm run test:ui # Browser UI +npm run test:coverage # With coverage report +``` + +## Test Structure + +``` +tests/ +├── lib/ +│ ├── tax.test.ts # Steuerberechnungen (§14 UStG) +│ └── schemas.test.ts # Zod-Validierung (Input-Sicherheit) +└── README.md +``` + +## Coverage + +- ✅ **tax.ts** - German tax calculations (19%, 7%, Kleinunternehmer) +- ✅ **schemas.ts** - Input validation (Zod schemas) +- ⚠️ **invoice-number.server.ts** - Requires Prisma mocking (TODO) + +## Critical Test Areas + +1. **Tax Calculations** - Must be correct per German tax law +2. **Input Validation** - Prevent invalid data in DB +3. **Invoice Logic** - §14 UStG compliance +4. **Buchhaltung** - Double-entry bookkeeping accuracy + +## Running Specific Tests + +```bash +# Run only tax tests +npx vitest run tests/lib/tax.test.ts + +# Run with coverage +npm run test:coverage +# Opens: ./coverage/index.html +``` + +## Writing New Tests + +```typescript +import { describe, it, expect } from "vitest"; +import { myFunction } from "@/lib/my-module"; + +describe("myModule", () => { + it("should do something", () => { + expect(myFunction()).toBe(expected); + }); +}); +``` + +## CI Integration + +Tests laufen automatisch in der Gitea Actions Pipeline (`.gitea/workflows/build.yml`). diff --git a/tests/components/invoice-status-badge.test.tsx b/tests/components/invoice-status-badge.test.tsx new file mode 100644 index 0000000..4a40b56 --- /dev/null +++ b/tests/components/invoice-status-badge.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge"; +import { InvoiceStatus } from "@prisma/client"; + +describe("InvoiceStatusBadge", () => { + it("should render DRAFT status correctly", () => { + render(); + expect(screen.getByText("Entwurf")).toBeInTheDocument(); + }); + + it("should render SENT status correctly", () => { + render(); + expect(screen.getByText("Versendet")).toBeInTheDocument(); + }); + + it("should render PAID status correctly", () => { + render(); + expect(screen.getByText("Bezahlt")).toBeInTheDocument(); + }); + + it("should render CANCELLED status correctly", () => { + render(); + expect(screen.getByText("Storniert")).toBeInTheDocument(); + }); + + it("should render DELETED status correctly", () => { + render(); + expect(screen.getByText("Gelöscht")).toBeInTheDocument(); + }); + + it("should have proper badge structure", () => { + render(); + const badge = screen.getByText("Bezahlt"); + expect(badge).toBeInTheDocument(); + // Badge should be a div element + expect(badge.tagName).toBe("DIV"); + }); +}); diff --git a/tests/integration/api-simple.test.ts b/tests/integration/api-simple.test.ts new file mode 100644 index 0000000..b9e3a54 --- /dev/null +++ b/tests/integration/api-simple.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from "vitest"; + +/** + * Simple Integration Tests + * + * These tests verify that the API logic works correctly + * without requiring a full database connection. + */ + +describe("API Integration (Simple)", () => { + describe("Company API Logic", () => { + it("should validate company creation data", async () => { + const { companySchema } = await import("@/lib/schemas"); + + const validCompany = { + name: "Test GmbH", + address: "Hauptstraße 1", + zip: "12345", + city: "Berlin", + country: "DE", + invoicePrefix: "RE", + kleinunternehmer: false, + taxId: null, + vatId: null, + bankIban: null, + bankBic: "", + }; + + const result = companySchema.safeParse(validCompany); + expect(result.success).toBe(true); + }); + + it("should reject invalid company data", async () => { + const { companySchema } = await import("@/lib/schemas"); + + const invalidCompany = { + name: "", // Invalid: empty name + address: "Test St. 1", + zip: "12345", + city: "Berlin", + }; + + const result = companySchema.safeParse(invalidCompany); + expect(result.success).toBe(false); + }); + }); + + describe("Invoice API Logic", () => { + it("should validate invoice creation data", async () => { + const { invoiceSchema } = await import("@/lib/schemas"); + + const validInvoice = { + companyId: "company-123", + customerId: "customer-456", + issueDate: "2026-05-08", + dueDate: "2026-06-08", + kleinunternehmer: false, + items: [ + { + position: 1, + description: "Web Development", + quantity: 10, + unit: "Stunden", + unitPrice: 100, + taxRate: 19, + netAmount: 1000, + taxAmount: 190, + grossAmount: 1190, + }, + ], + netTotal: 1000, + taxTotal: 190, + grossTotal: 1190, + }; + + const result = invoiceSchema.safeParse(validInvoice); + expect(result.success).toBe(true); + }); + + it("should reject invoice with no items", async () => { + const { invoiceSchema } = await import("@/lib/schemas"); + + const invalidInvoice = { + companyId: "company-123", + customerId: "customer-456", + issueDate: "2026-05-08", + dueDate: "2026-06-08", + items: [], // Invalid: no items + netTotal: 0, + taxTotal: 0, + grossTotal: 0, + }; + + const result = invoiceSchema.safeParse(invalidInvoice); + expect(result.success).toBe(false); + }); + }); + + describe("Customer API Logic", () => { + it("should validate customer data", async () => { + const { customerSchema } = await import("@/lib/schemas"); + + const validCustomer = { + companyId: "company-123", + name: "Max Mustermann", + address: "Musterstraße 1", + zip: "12345", + city: "Berlin", + country: "DE", + }; + + const result = customerSchema.safeParse(validCustomer); + expect(result.success).toBe(true); + }); + + it("should require companyId", async () => { + const { customerSchema } = await import("@/lib/schemas"); + + const invalidCustomer = { + name: "Max Mustermann", + address: "Musterstraße 1", + zip: "12345", + city: "Berlin", + }; + + const result = customerSchema.safeParse(invalidCustomer); + expect(result.success).toBe(false); + }); + }); + + describe("Tax Calculation Integration", () => { + it("should calculate invoice totals from items", async () => { + const { calcItemAmounts, calcInvoiceTotals } = await import("@/lib/tax"); + + const items = [ + { + quantity: 10, + unitPrice: 100, + taxRate: 19, + }, + { + quantity: 5, + unitPrice: 200, + taxRate: 7, + }, + ].map((item, idx) => ({ + position: idx + 1, + description: `Service ${idx + 1}`, + ...item, + ...calcItemAmounts(item.quantity, item.unitPrice, item.taxRate), + })); + + const totals = calcInvoiceTotals(items); + + expect(totals.netTotal).toBe(2000); // 1000 + 1000 + expect(totals.taxTotal).toBe(260); // 190 + 70 (5*200*7% = 70) + expect(totals.grossTotal).toBe(2260); // 1190 + 1070 + }); + + it("should handle Kleinunternehmer calculation", async () => { + const { calcItemAmountsKleinunternehmer, calcInvoiceTotals } = await import("@/lib/tax"); + + const items = [ + { + quantity: 10, + unitPrice: 100, + }, + ].map((item, idx) => ({ + position: idx + 1, + description: "Service", + ...item, + ...calcItemAmountsKleinunternehmer(item.quantity, item.unitPrice), + taxRate: 0, + })); + + const totals = calcInvoiceTotals(items); + + expect(totals.netTotal).toBe(1000); + expect(totals.taxTotal).toBe(0); + expect(totals.grossTotal).toBe(1000); + }); + }); + + describe("Authentication Logic", () => { + it("should identify unauthorized requests", () => { + const user = null; + const isAuthenticated = user !== null; + expect(isAuthenticated).toBe(false); + }); + + it("should identify authorized requests", () => { + const user = { id: "user-123", role: "ADMIN" }; + const isAuthenticated = user !== null; + expect(isAuthenticated).toBe(true); + expect(user.role).toBe("ADMIN"); + }); + }); +}); diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts new file mode 100644 index 0000000..5321b61 --- /dev/null +++ b/tests/integration/api.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { testPrisma, setupTestDatabase, cleanupTestDatabase, createTestUser, createTestCompany, createTestCustomer } from "./setup"; +import bcrypt from "bcryptjs"; + +/** + * Integration Tests for API Routes + * + * These tests require a test database. + * They will skip gracefully if the database is not available. + */ + +describe("API Integration Tests (Database Required)", () => { + let testUser: any; + let testCompany: any; + let testCustomer: any; + let dbAvailable = false; + + // Setup before all tests + beforeAll(async () => { + dbAvailable = await setupTestDatabase(); + + if (!dbAvailable) { + console.warn("Skipping database integration tests - no test database available"); + return; + } + + // Create test user + testUser = await createTestUser("test@example.com", "testuser"); + + // Create test company + testCompany = await createTestCompany(testUser.id, "Integration Test GmbH"); + + // Create test customer + testCustomer = await createTestCustomer(testCompany.id, "Integration Test Customer"); + }); + + // Cleanup after all tests + afterAll(async () => { + if (!dbAvailable) return; + await cleanupTestDatabase(); + await testPrisma.$disconnect(); + }); + + // Clean data between tests (but keep user/company/customer) + beforeEach(async () => { + if (!dbAvailable) return; + + // Delete invoices and related items + await testPrisma.invoiceItem.deleteMany({ + where: { invoice: { companyId: testCompany.id } }, + }); + await testPrisma.invoice.deleteMany({ + where: { companyId: testCompany.id }, + }); + }); + + // Helper to skip tests if no database + const dbTest = (name: string, fn: () => Promise) => { + it(name, async () => { + if (!dbAvailable) { + console.warn(`Skipping "${name}" - no test database`); + return; + } + await fn(); + }); + }; + + describe("Companies API", () => { + dbTest("should list companies for a user", async () => { + const companies = await testPrisma.company.findMany({ + where: { userId: testUser.id }, + }); + + expect(companies).toBeDefined(); + expect(companies.length).toBeGreaterThan(0); + expect(companies[0].name).toBe("Integration Test GmbH"); + }); + + dbTest("should create a new company", async () => { + const newCompanyData = { + name: "New Test Company", + address: "New Street 1", + zip: "99999", + city: "Munich", + country: "DE", + }; + + const company = await testPrisma.company.create({ + data: { + ...newCompanyData, + userId: testUser.id, + }, + }); + + expect(company).toBeDefined(); + expect(company.name).toBe("New Test Company"); + expect(company.userId).toBe(testUser.id); + }); + }); + + describe("Invoices API", () => { + dbTest("should create a new invoice", async () => { + const { calcItemAmounts, calcInvoiceTotals } = await import("@/lib/tax"); + + const invoiceData = { + companyId: testCompany.id, + customerId: testCustomer.id, + issueDate: new Date(), + dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + kleinunternehmer: false, + items: [ + { + position: 1, + description: "Test Service", + quantity: 10, + unit: "Stunden", + unitPrice: 100, + taxRate: 19, + netAmount: 1000, + taxAmount: 190, + grossAmount: 1190, + }, + ], + }; + + // Recalculate server-side (as the API does) + const recalculatedItems = invoiceData.items.map(item => ({ + ...item, + ...calcItemAmounts(item.quantity, item.unitPrice, item.taxRate), + })); + const totals = calcInvoiceTotals(recalculatedItems); + + const invoice = await testPrisma.invoice.create({ + data: { + companyId: invoiceData.companyId, + customerId: invoiceData.customerId, + issueDate: invoiceData.issueDate, + dueDate: invoiceData.dueDate, + status: "DRAFT", + kleinunternehmer: invoiceData.kleinunternehmer, + netTotal: totals.netTotal, + taxTotal: totals.taxTotal, + grossTotal: totals.grossTotal, + items: { + create: recalculatedItems.map((item, idx) => ({ + position: idx + 1, + description: item.description, + quantity: item.quantity, + unit: item.unit, + unitPrice: item.unitPrice, + taxRate: item.taxRate, + netAmount: item.netAmount, + taxAmount: item.taxAmount, + grossAmount: item.grossAmount, + })), + }, + }, + include: { items: true, customer: true }, + }); + + expect(invoice).toBeDefined(); + expect(invoice.status).toBe("DRAFT"); + expect(invoice.items).toHaveLength(1); + expect(invoice.grossTotal).toBe(1190); + }); + }); + + describe("Buchungen API", () => { + dbTest("should create a new Buchung", async () => { + const buchungData = { + companyId: testCompany.id, + date: new Date(), + account: "KASSE" as const, + type: "EINLAGE" as const, + amount: 1000, + description: "Initial investment", + isBusinessRecord: false, + }; + + const buchung = await testPrisma.buchung.create({ + data: buchungData, + }); + + expect(buchung).toBeDefined(); + expect(buchung.account).toBe("KASSE"); + expect(buchung.type).toBe("EINLAGE"); + expect(buchung.amount.toNumber()).toBe(1000); + }); + }); +}); diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts new file mode 100644 index 0000000..a113959 --- /dev/null +++ b/tests/integration/setup.ts @@ -0,0 +1,128 @@ +import { PrismaClient } from "@prisma/client"; +import { execSync } from "child_process"; + +/** + * Integration Test Setup + * Uses a separate test database to avoid polluting development data + */ + +const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL || + "mysql://annas_user:annas_password@localhost:3306/annas_rechnungen_test"; + +export const testPrisma = new PrismaClient({ + datasources: { + db: { + url: TEST_DATABASE_URL, + }, + }, +}); + +/** + * Setup test database: push schema and seed if needed + * Returns true if successful, false if database unavailable + */ +export async function setupTestDatabase(): Promise { + console.log("Setting up test database..."); + + try { + // Push schema to test database + execSync(`npx prisma db push --force-reset`, { + env: { + ...process.env, + DATABASE_URL: TEST_DATABASE_URL, + }, + stdio: "inherit", + }); + + console.log("Test database ready"); + return true; + } catch (error) { + console.warn("Test database not available:", error); + return false; + } +} + +/** + * Clean up test database + */ +export async function cleanupTestDatabase() { + // Delete all data in correct order (respecting foreign keys) + const tables = [ + "audit_logs", + "invoice_items", + "invoices", + "buchung_kategorien", + "buchungen", + "anlagegueter", + "services", + "customers", + "companies", + "users", + ]; + + for (const table of tables) { + await testPrisma.$executeRawUnsafe(`DELETE FROM ${table}`); + } +} + +/** + * Create a test user + */ +export async function createTestUser(email: string, username: string) { + const bcrypt = await import("bcryptjs"); + const passwordHash = await bcrypt.default.hash("test1234", 10); + + return testPrisma.user.create({ + data: { + email, + username, + name: "Test User", + passwordHash, + role: "ADMIN", + }, + }); +} + +/** + * Create a test company for a user + */ +export async function createTestCompany(userId: string, name: string = "Test GmbH") { + return testPrisma.company.create({ + data: { + name, + address: "Teststraße 1", + zip: "12345", + city: "Berlin", + country: "DE", + userId, + }, + }); +} + +/** + * Create a test customer for a company + */ +export async function createTestCustomer(companyId: string, name: string = "Test Customer") { + return testPrisma.customer.create({ + data: { + name, + address: "Kundenstraße 1", + zip: "54321", + city: "Hamburg", + country: "DE", + companyId, + }, + }); +} + +/** + * Generate auth cookie for test requests + * (Simplified - in real scenario, you'd create a session) + */ +export function getAuthHeaders(userId: string) { + // For integration tests, we might mock the session or use a test session + return { + "Content-Type": "application/json", + "X-Test-User-Id": userId, // Custom header for test auth bypass + }; +} diff --git a/tests/lib/afa.test.ts b/tests/lib/afa.test.ts new file mode 100644 index 0000000..292e498 --- /dev/null +++ b/tests/lib/afa.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from "vitest"; +import { + jahresAfa, + erwerbsjahrAfa, + afaFuerJahr, + kumulierteAfa, + buchwert, + assetStatus, + AnlagegutRaw, +} from "@/lib/afa"; + +describe("afa.ts - Asset Depreciation (§7 EStG)", () => { + const sampleAsset: AnlagegutRaw = { + anschaffungskosten: 12000, + nutzungsdauerJahre: 3, + restwert: 0, + anschaffungsdatum: "2024-03-15", // March 15, 2024 + aktiv: true, + }; + + describe("jahresAfa", () => { + it("should calculate full annual depreciation", () => { + // 12000 over 3 years = 4000 per year + expect(jahresAfa(12000, 0, 3)).toBe(4000); + }); + + it("should handle residual value (restwert)", () => { + // 12000 - 2000 restwert = 10000 over 3 years + expect(jahresAfa(12000, 2000, 3)).toBe(3333.33); + }); + + it("should handle different depreciation periods", () => { + // 24000 over 8 years = 3000 per year + expect(jahresAfa(24000, 0, 8)).toBe(3000); + }); + }); + + describe("erwerbsjahrAfa", () => { + it("should calculate pro-rata depreciation in acquisition year", () => { + // Acquired March 15 = 10 months remaining (Mar-Dec, inclusive) = 10/12 + // 4000 * (10/12) = 3333.33 + const acqDate = new Date("2024-03-15"); + const result = erwerbsjahrAfa(12000, 0, 3, acqDate); + expect(result).toBe(3333.33); + }); + + it("should calculate full year if acquired in January", () => { + const acqDate = new Date("2024-01-01"); + const result = erwerbsjahrAfa(12000, 0, 3, acqDate); + expect(result).toBe(4000); + }); + + it("should calculate 1/12 if acquired in December", () => { + const acqDate = new Date("2024-12-01"); + const result = erwerbsjahrAfa(12000, 0, 3, acqDate); + expect(result).toBe(333.33); + }); + }); + + describe("afaFuerJahr", () => { + it("should return 0 for years before acquisition", () => { + expect(afaFuerJahr(sampleAsset, 2023)).toBe(0); + }); + + it("should return pro-rata for acquisition year", () => { + // 2024 acquisition, 10 months = 3333.33 + const result = afaFuerJahr(sampleAsset, 2024); + expect(result).toBe(3333.33); + }); + + it("should return full annual amount for middle years", () => { + expect(afaFuerJahr(sampleAsset, 2025)).toBe(4000); + expect(afaFuerJahr(sampleAsset, 2026)).toBe(4000); + }); + + it("should return 0 after full depreciation period", () => { + // 3 years: 2024, 2025, 2026 -> after 2026 = 0 + expect(afaFuerJahr(sampleAsset, 2027)).toBe(0); + }); + + it("should handle asset with residual value", () => { + const assetWithRestwert: AnlagegutRaw = { + anschaffungskosten: 5000, + nutzungsdauerJahre: 5, + restwert: 500, + anschaffungsdatum: "2024-01-01", + aktiv: true, + }; + // (5000-500) / 5 = 900 per year + expect(afaFuerJahr(assetWithRestwert, 2024)).toBe(900); + expect(afaFuerJahr(assetWithRestwert, 2028)).toBe(900); + expect(afaFuerJahr(assetWithRestwert, 2029)).toBe(0); + }); + }); + + describe("kumulierteAfa", () => { + it("should calculate cumulative depreciation correctly", () => { + // 2024: 3333.33, 2025: 4000, 2026: 4000 = 11333.33 + expect(kumulierteAfa(sampleAsset, 2026)).toBe(11333.33); + }); + + it("should return 0 for year before acquisition", () => { + expect(kumulierteAfa(sampleAsset, 2023)).toBe(0); + }); + + it("should return full depreciation after end of period", () => { + // After 3 years: 3333.33 + 4000 + 4000 = 11333.33 + // But asset cost is 12000, so 12000 - 11333.33 = 666.67 remaining + const result = kumulierteAfa(sampleAsset, 2027); + expect(result).toBe(11333.33); + }); + }); + + describe("buchwert", () => { + it("should calculate book value correctly", () => { + // 2024: 3333.33 depreciation -> 12000 - 3333.33 = 8666.67 + expect(buchwert(sampleAsset, 2024)).toBe(8666.67); + // 2025: another 4000 -> 8666.67 - 4000 = 4666.67 + expect(buchwert(sampleAsset, 2025)).toBe(4666.67); + // 2026: another 4000 -> 4666.67 - 4000 = 666.67 + expect(buchwert(sampleAsset, 2026)).toBe(666.67); + }); + + it("should not go below residual value", () => { + const assetWithRestwert: AnlagegutRaw = { + anschaffungskosten: 5000, + nutzungsdauerJahre: 5, + restwert: 500, + anschaffungsdatum: "2024-01-01", + aktiv: true, + }; + // After full depreciation: 5000 - (900 * 5) = 500 + expect(buchwert(assetWithRestwert, 2028)).toBe(500); + expect(buchwert(assetWithRestwert, 2030)).toBe(500); + }); + + it("should return acquisition cost for year before acquisition", () => { + expect(buchwert(sampleAsset, 2023)).toBe(12000); + }); + }); + + describe("assetStatus", () => { + it("should return 'inaktiv' for inactive assets", () => { + const inactive: AnlagegutRaw = { ...sampleAsset, aktiv: false }; + expect(assetStatus(inactive, 2025)).toBe("inaktiv"); + }); + + it("should return 'aktiv' for active assets within depreciation period", () => { + expect(assetStatus(sampleAsset, 2024)).toBe("aktiv"); + expect(assetStatus(sampleAsset, 2025)).toBe("aktiv"); + expect(assetStatus(sampleAsset, 2026)).toBe("aktiv"); + }); + + it("should return 'vollständig abgeschrieben' after depreciation period", () => { + expect(assetStatus(sampleAsset, 2027)).toBe("vollständig abgeschrieben"); + expect(assetStatus(sampleAsset, 2030)).toBe("vollständig abgeschrieben"); + }); + }); +}); diff --git a/tests/lib/buchungen.test.ts b/tests/lib/buchungen.test.ts new file mode 100644 index 0000000..4bc10b0 --- /dev/null +++ b/tests/lib/buchungen.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from "vitest"; +import { Decimal } from "@prisma/client"; + +describe("Buchungen - Double-Entry Bookkeeping Logic", () => { + describe("TransactionAccount Enum", () => { + it("should have KASSE and BANK accounts", () => { + // These are the two main accounts for German bookkeeping + const accounts = ["KASSE", "BANK"]; + expect(accounts).toContain("KASSE"); + expect(accounts).toContain("BANK"); + }); + }); + + describe("TransactionType Enum", () => { + it("should have EINLAGE and ENTNAHME types", () => { + // EINLAGE = Owner investment, ENTNAHME = Owner withdrawal + const types = ["EINLAGE", "ENTNAHME"]; + expect(types).toContain("EINLAGE"); + expect(types).toContain("ENTNAHME"); + }); + }); + + describe("Zahlungsart Enum", () => { + it("should have KASSE and BANK payment methods", () => { + const paymentMethods = ["KASSE", "BANK"]; + expect(paymentMethods).toContain("KASSE"); + expect(paymentMethods).toContain("BANK"); + }); + }); + + describe("Buchung Business Logic", () => { + it("should calculate correct sign for EINLAGE (positive)", () => { + // EINLAGE increases the company's assets + const amount = 1000; + const type = "EINLAGE"; + + // In German bookkeeping: EINLAGE is recorded as positive (credit to equity) + const signedAmount = type === "EINLAGE" ? amount : -amount; + expect(signedAmount).toBe(1000); + }); + + it("should calculate correct sign for ENTNAHME (negative)", () => { + // ENTNAHME decreases the company's assets + const amount = 500; + const type = "ENTNAHME"; + + // In German bookkeeping: ENTNAHME is recorded as negative (debit to equity) + const signedAmount = type === "EINLAGE" ? amount : -amount; + expect(signedAmount).toBe(-500); + }); + + it("should identify business records correctly", () => { + // Business records (isBusinessRecord = true) come from Einnahmen/Ausgaben + const isBusinessRecord = true; + const hasKategorie = true; + + expect(isBusinessRecord).toBe(true); + expect(hasKategorie).toBe(true); + }); + + it("should handle non-business records (private)", () => { + // Non-business records might not have a kategorie + const isBusinessRecord = false; + const kategorie = null; + + expect(isBusinessRecord).toBe(false); + expect(kategorie).toBeNull(); + }); + + it("should validate Decimal precision for amounts", () => { + // Prisma Decimal(10,2) - max 10 digits, 2 decimal places + const amount = 12345678.90; // 8 digits before decimal, 2 after + const maxAmount = 99999999.99; // Max for DECIMAL(10,2) + + expect(amount).toBeLessThanOrEqual(maxAmount); + expect(Number(amount.toFixed(2))).toBe(12345678.9); + }); + }); + + describe("Buchung Link Logic (Linked Transactions)", () => { + it("should allow linking related transactions", () => { + // Example: An invoice payment might be linked to the invoice + const buchungId = "buchung-123"; + const linkedBuchungId = "buchung-456"; + + // A Buchung can be linked to another (e.g., invoice payment -> invoice) + const link = { source: buchungId, target: linkedBuchungId }; + expect(link.source).toBe("buchung-123"); + expect(link.target).toBe("buchung-456"); + }); + }); + + describe("Kategorie Logic", () => { + it("should have unique category names per company", () => { + // BuchungKategorie has @@unique([companyId, name, typ]) + const companyId = "company-123"; + const categories = [ + { companyId, name: "Fußpflege", typ: "EINNAHME" }, + { companyId, name: "Miete", typ: "AUSGABE" }, + ]; + + const uniqueCheck = new Set(categories.map(c => `${c.companyId}-${c.name}-${c.typ}`)); + expect(uniqueCheck.size).toBe(categories.length); + }); + + it("should distinguish between EINNAHME and AUSGABE", () => { + const einnahme: "EINNAHME" = "EINNAHME"; + const ausgabe: "AUSGABE" = "AUSGABE"; + + expect(einnahme).toBe("EINNAHME"); + expect(ausgabe).toBe("AUSGABE"); + expect(einnahme).not.toBe(ausgabe); + }); + }); + + describe("Date-Based Queries", () => { + it("should filter Buchungen by date range", () => { + const buchungen = [ + { date: new Date("2026-01-15"), amount: 100 }, + { date: new Date("2026-02-20"), amount: 200 }, + { date: new Date("2026-03-10"), amount: 300 }, + ]; + + const startDate = new Date("2026-02-01"); + const endDate = new Date("2026-03-31"); + + const filtered = buchungen.filter(b => + b.date >= startDate && b.date <= endDate + ); + + expect(filtered).toHaveLength(2); + expect(filtered[0].amount).toBe(200); + expect(filtered[1].amount).toBe(300); + }); + + it("should group Buchungen by month for reports", () => { + const buchungen = [ + { date: new Date("2026-01-10"), amount: 100 }, + { date: new Date("2026-01-20"), amount: 150 }, + { date: new Date("2026-02-05"), amount: 200 }, + ]; + + const grouped = buchungen.reduce((acc, b) => { + const month = b.date.getMonth(); // 0-indexed + acc[month] = (acc[month] || 0) + b.amount; + return acc; + }, {} as Record); + + expect(grouped[0]).toBe(250); // January (month 0) + expect(grouped[1]).toBe(200); // February (month 1) + }); + }); +}); diff --git a/tests/lib/client-validation.test.ts b/tests/lib/client-validation.test.ts new file mode 100644 index 0000000..b49cb07 --- /dev/null +++ b/tests/lib/client-validation.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + isDebugMode, + setDebugMode, + validateTaxId, + validateVatId, + validateIban, + validateBic, + validateWebsite, + validateCompanyForm, + getFieldError, + hasFieldError, + type ValidationError, + type CompanyFormData, +} from "@/lib/client-validation"; + +describe("client-validation.ts", () => { + // Mock localStorage + const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { store[key] = value; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; + })(); + + beforeEach(() => { + // Reset localStorage mock + localStorageMock.clear(); + (global as any).localStorage = localStorageMock; + (global as any).window = { localStorage: localStorageMock }; + }); + + afterEach(() => { + (global as any).window = undefined; + }); + + describe("Debug Mode", () => { + it("should be disabled by default (no window)", () => { + (global as any).window = undefined; + expect(isDebugMode()).toBe(false); + }); + + it("should detect debug mode from localStorage", () => { + setDebugMode(true); + expect(isDebugMode()).toBe(true); + }); + + it("should disable debug mode", () => { + setDebugMode(true); + expect(isDebugMode()).toBe(true); + + setDebugMode(false); + expect(isDebugMode()).toBe(false); + }); + }); + + describe("validateTaxId", () => { + it("should return null for empty/null values", () => { + expect(validateTaxId("")).toBeNull(); + expect(validateTaxId(null)).toBeNull(); + expect(validateTaxId(undefined)).toBeNull(); + }); + + it("should validate correct 10-digit tax ID", () => { + expect(validateTaxId("1234567890")).toBeNull(); + }); + + it("should reject invalid tax IDs", () => { + const result = validateTaxId("123"); // Too short + expect(result).not.toBeNull(); + expect(result?.field).toBe("taxId"); + expect(result?.message).toContain("10 Ziffern"); + }); + + it("should reject tax ID with letters", () => { + const result = validateTaxId("abc4567890"); + expect(result).not.toBeNull(); + }); + }); + + describe("validateVatId", () => { + it("should return null for empty/null values", () => { + expect(validateVatId("")).toBeNull(); + expect(validateVatId(null)).toBeNull(); + }); + + it("should validate correct DE VAT ID", () => { + expect(validateVatId("DE123456789")).toBeNull(); + }); + + it("should reject VAT ID without DE prefix", () => { + const result = validateVatId("1234567890"); + expect(result).not.toBeNull(); + expect(result?.field).toBe("vatId"); + expect(result?.message).toContain("DE + 9 Ziffern"); + }); + + it("should reject VAT ID with wrong length", () => { + const result = validateVatId("DE12345"); // Too short + expect(result).not.toBeNull(); + }); + }); + + describe("validateIban", () => { + it("should return null for empty/null values", () => { + expect(validateIban("")).toBeNull(); + expect(validateIban(null)).toBeNull(); + }); + + it("should validate correct IBAN", () => { + expect(validateIban("DE89370400440532013000")).toBeNull(); + }); + + it("should reject invalid IBAN", () => { + const result = validateIban("INVALID"); + expect(result).not.toBeNull(); + expect(result?.field).toBe("bankIban"); + expect(result?.message).toContain("IBAN"); + }); + }); + + describe("validateBic", () => { + it("should return null for empty/null values", () => { + expect(validateBic("")).toBeNull(); + expect(validateBic(null)).toBeNull(); + }); + + it("should validate correct BIC", () => { + expect(validateBic("DEUTDEFF")).toBeNull(); + }); + + it("should reject invalid BIC", () => { + const result = validateBic("123"); // Too short + expect(result).not.toBeNull(); + expect(result?.field).toBe("bankBic"); + }); + }); + + describe("validateWebsite", () => { + it("should return null for empty/null values", () => { + expect(validateWebsite("")).toBeNull(); + expect(validateWebsite(null)).toBeNull(); + }); + + it("should validate correct website URLs", () => { + expect(validateWebsite("https://example.com")).toBeNull(); + expect(validateWebsite("http://example.com")).toBeNull(); + }); + + it("should reject website without protocol", () => { + const result = validateWebsite("example.com"); + expect(result).not.toBeNull(); + expect(result?.field).toBe("website"); + expect(result?.message).toContain("http:// oder https://"); + }); + + it("should reject too long website URLs", () => { + const longUrl = "https://" + "a".repeat(250); + const result = validateWebsite(longUrl); + expect(result).not.toBeNull(); + expect(result?.message).toContain("255"); + }); + }); + + describe("validateCompanyForm", () => { + const validFormData: CompanyFormData = { + name: "Test GmbH", + address: "Hauptstraße 1", + zip: "12345", + city: "Berlin", + }; + + it("should pass validation for valid data", () => { + const errors = validateCompanyForm(validFormData); + expect(errors).toHaveLength(0); + }); + + it("should require name", () => { + const data = { ...validFormData, name: "" }; + const errors = validateCompanyForm(data); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].field).toBe("name"); + }); + + it("should require address", () => { + const data = { ...validFormData, address: "" }; + const errors = validateCompanyForm(data); + const addressError = errors.find(e => e.field === "address"); + expect(addressError).toBeDefined(); + }); + + it("should require zip", () => { + const data = { ...validFormData, zip: "" }; + const errors = validateCompanyForm(data); + const zipError = errors.find(e => e.field === "zip"); + expect(zipError).toBeDefined(); + }); + + it("should require city", () => { + const data = { ...validFormData, city: "" }; + const errors = validateCompanyForm(data); + const cityError = errors.find(e => e.field === "city"); + expect(cityError).toBeDefined(); + }); + + it("should validate zip format (digits only)", () => { + const data = { ...validFormData, zip: "abc" }; + const errors = validateCompanyForm(data); + const zipError = errors.find(e => e.field === "zip"); + expect(zipError).toBeDefined(); + expect(zipError?.message).toContain("Zahlen"); + }); + + it("should validate optional fields when provided", () => { + const data = { + ...validFormData, + taxId: "123", // Invalid + vatId: "INVALID", // Invalid + website: "example.com", // Invalid (no protocol) + }; + const errors = validateCompanyForm(data); + expect(errors.length).toBeGreaterThanOrEqual(3); + }); + + it("should accept valid optional fields", () => { + const data = { + ...validFormData, + taxId: "1234567890", + vatId: "DE123456789", + website: "https://example.com", + }; + const errors = validateCompanyForm(data); + const hasTaxIdError = errors.some(e => e.field === "taxId"); + const hasVatIdError = errors.some(e => e.field === "vatId"); + const hasWebsiteError = errors.some(e => e.field === "website"); + expect(hasTaxIdError).toBe(false); + expect(hasVatIdError).toBe(false); + expect(hasWebsiteError).toBe(false); + }); + + it("should validate email format", () => { + const data = { ...validFormData, email: "invalid-email" }; + const errors = validateCompanyForm(data); + const emailError = errors.find(e => e.field === "email"); + expect(emailError).toBeDefined(); + }); + + it("should accept valid email", () => { + const data = { ...validFormData, email: "test@example.com" }; + const errors = validateCompanyForm(data); + const emailError = errors.find(e => e.field === "email"); + expect(emailError).toBeUndefined(); + }); + + it("should validate field length limits", () => { + const data = { + ...validFormData, + name: "a".repeat(300), // Too long + phone: "a".repeat(30), // Too long + }; + const errors = validateCompanyForm(data); + expect(errors.length).toBeGreaterThan(0); + }); + }); + + describe("getFieldError", () => { + it("should return error message for field", () => { + const errors: ValidationError[] = [ + { field: "name", message: "Name required" }, + { field: "email", message: "Invalid email" }, + ]; + expect(getFieldError("name", errors)).toBe("Name required"); + expect(getFieldError("email", errors)).toBe("Invalid email"); + }); + + it("should return null for field without error", () => { + const errors: ValidationError[] = []; + expect(getFieldError("name", errors)).toBeNull(); + }); + }); + + describe("hasFieldError", () => { + it("should return true if field has error", () => { + const errors: ValidationError[] = [ + { field: "name", message: "Name required" }, + ]; + expect(hasFieldError("name", errors)).toBe(true); + expect(hasFieldError("email", errors)).toBe(false); + }); + + it("should return false for empty errors", () => { + expect(hasFieldError("name", [])).toBe(false); + }); + }); +}); diff --git a/tests/lib/einnahmen-ausgaben.test.ts b/tests/lib/einnahmen-ausgaben.test.ts new file mode 100644 index 0000000..500db97 --- /dev/null +++ b/tests/lib/einnahmen-ausgaben.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { + EINNAHME_KATEGORIEN, + EINNAHME_LABELS, + type EinnahmeKategorieKey, +} from "@/lib/einnahmen"; +import { + AUSGABE_KATEGORIEN, + KATEGORIE_LABELS, + type AusgabeKategorieKey, +} from "@/lib/ausgaben"; + +describe("einnahmen.ts - Revenue Categories", () => { + it("should have all expected categories", () => { + expect(EINNAHME_KATEGORIEN).toContain("FUSSPFLEGE"); + expect(EINNAHME_KATEGORIEN).toContain("PRIVATEINLAGEN"); + expect(EINNAHME_KATEGORIEN).toContain("DARLEHEN"); + expect(EINNAHME_KATEGORIEN).toContain("STEUERERSTATTUNGEN"); + expect(EINNAHME_KATEGORIEN).toContain("ZINSERTRAEGE"); + expect(EINNAHME_KATEGORIEN).toContain("VERMIETUNG_VERPACHTUNG"); + expect(EINNAHME_KATEGORIEN).toContain("VERAEUSSERUNGSERLOES"); + expect(EINNAHME_KATEGORIEN).toContain("EIGENVERBRAUCH"); + expect(EINNAHME_KATEGORIEN).toContain("SONSTIGE_EINNAHMEN"); + }); + + it("should have 10 revenue categories", () => { + expect(EINNAHME_KATEGORIEN).toHaveLength(10); + }); + + it("should have labels for all categories", () => { + EINNAHME_KATEGORIEN.forEach((key) => { + expect(EINNAHME_LABELS[key as EinnahmeKategorieKey]).toBeDefined(); + expect(typeof EINNAHME_LABELS[key as EinnahmeKategorieKey]).toBe("string"); + }); + }); + + it("should have correct labels", () => { + expect(EINNAHME_LABELS.FUSSPFLEGE).toBe("Fußpflege/Verkauf/Gutscheine"); + expect(EINNAHME_LABELS.PRIVATEINLAGEN).toBe("Privateinlagen"); + expect(EINNAHME_LABELS.DARLEHEN).toBe("Darlehen"); + expect(EINNAHME_LABELS.ZINSERTRAEGE).toBe("Zinserträge"); + }); + + it("should have valid TypeScript types", () => { + const testKey: EinnahmeKategorieKey = "FUSSPFLEGE"; + expect(testKey).toBe("FUSSPFLEGE"); + }); +}); + +describe("ausgaben.ts - Expense Categories", () => { + it("should have all expected categories", () => { + expect(AUSGABE_KATEGORIEN).toContain("WAREN_ROHSTOFFE"); + expect(AUSGABE_KATEGORIEN).toContain("GERINGWERTIGE_WIRTSCHAFTSGUETER"); + expect(AUSGABE_KATEGORIEN).toContain("ABSCHREIBUNGEN"); + expect(AUSGABE_KATEGORIEN).toContain("MIETE"); + expect(AUSGABE_KATEGORIEN).toContain("STROM_WASSER"); + expect(AUSGABE_KATEGORIEN).toContain("TELEKOMMUNIKATION"); + expect(AUSGABE_KATEGORIEN).toContain("FORTBILDUNG_MESSEN"); + expect(AUSGABE_KATEGORIEN).toContain("BEITRAEGE"); + expect(AUSGABE_KATEGORIEN).toContain("VERSICHERUNGEN"); + expect(AUSGABE_KATEGORIEN).toContain("WERBEKOSTEN"); + expect(AUSGABE_KATEGORIEN).toContain("ZINSEN"); + expect(AUSGABE_KATEGORIEN).toContain("REISEKOSTEN"); + expect(AUSGABE_KATEGORIEN).toContain("REPARATUREN_INSTANDHALTUNG"); + expect(AUSGABE_KATEGORIEN).toContain("BUEROBEDARF"); + expect(AUSGABE_KATEGORIEN).toContain("REPRAESENTATIONSKOSTEN"); + expect(AUSGABE_KATEGORIEN).toContain("SONSTIGER_BETRIEBSBEDARF"); + expect(AUSGABE_KATEGORIEN).toContain("NEBENKOSTEN_GELDVERKEHR"); + }); + + it("should have 17 expense categories", () => { + expect(AUSGABE_KATEGORIEN).toHaveLength(17); + }); + + it("should have labels for all categories", () => { + AUSGABE_KATEGORIEN.forEach((key) => { + expect(KATEGORIE_LABELS[key as AusgabeKategorieKey]).toBeDefined(); + expect(typeof KATEGORIE_LABELS[key as AusgabeKategorieKey]).toBe("string"); + }); + }); + + it("should have correct labels", () => { + expect(KATEGORIE_LABELS.WAREN_ROHSTOFFE).toBe("Waren, Rohstoffe, Hilfsstoffe"); + expect(KATEGORIE_LABELS.GERINGWERTIGE_WIRTSCHAFTSGUETER).toBe( + "Geringwertige Wirtschaftsgüter" + ); + expect(KATEGORIE_LABELS.MIETE).toBe("Miete"); + expect(KATEGORIE_LABELS.ZINSEN).toBe("Zinsen"); + }); + + it("should have valid TypeScript types", () => { + const testKey: AusgabeKategorieKey = "MIETE"; + expect(testKey).toBe("MIETE"); + }); +}); + +describe("Category Integration", () => { + it("should not have overlapping keys between revenue and expenses", () => { + const overlap = EINNAHME_KATEGORIEN.filter((key) => + AUSGABE_KATEGORIEN.includes(key as any) + ); + expect(overlap).toHaveLength(0); + }); + + it("should have consistent naming convention (uppercase with underscores)", () => { + const validPattern = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/; + + EINNAHME_KATEGORIEN.forEach((key) => { + expect(key).toMatch(validPattern); + }); + + AUSGABE_KATEGORIEN.forEach((key) => { + expect(key).toMatch(validPattern); + }); + }); +}); diff --git a/tests/lib/invoice-number.test.ts b/tests/lib/invoice-number.test.ts new file mode 100644 index 0000000..76711bd --- /dev/null +++ b/tests/lib/invoice-number.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import prisma from "@/lib/prisma.server"; +import { generateInvoiceNumber } from "@/lib/invoice-number.server"; + +// Mock the Prisma client +vi.mock("@/lib/prisma.server", () => ({ + default: { + company: { + update: vi.fn(), + }, + }, +})); + +describe("invoice-number.server.ts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should generate invoice number with year and sequence", async () => { + const mockCompany = { + invoicePrefix: "RE", + invoiceSequence: 5, // Already incremented by Prisma + }; + + (prisma.company.update as any).mockResolvedValue(mockCompany); + + const result = await generateInvoiceNumber("company-123"); + + // invoiceSequence is already 5 (after increment), so we expect 005 + expect(result).toBe("RE-2026-005"); + expect(prisma.company.update).toHaveBeenCalledWith({ + where: { id: "company-123" }, + data: { invoiceSequence: { increment: 1 } }, + select: { invoicePrefix: true, invoiceSequence: true }, + }); + }); + + it("should pad sequence with zeros", async () => { + const mockCompany = { + invoicePrefix: "RG", + invoiceSequence: 2, // Already incremented by Prisma + }; + + (prisma.company.update as any).mockResolvedValue(mockCompany); + + const result = await generateInvoiceNumber("company-456"); + + expect(result).toBe("RG-2026-002"); + }); + + it("should handle custom prefix", async () => { + const mockCompany = { + invoicePrefix: "INV", + invoiceSequence: 10, // Already incremented by Prisma + }; + + (prisma.company.update as any).mockResolvedValue(mockCompany); + + const result = await generateInvoiceNumber("company-789"); + + expect(result).toBe("INV-2026-010"); + }); +}); diff --git a/tests/lib/kategorie-defaults.test.ts b/tests/lib/kategorie-defaults.test.ts new file mode 100644 index 0000000..824b112 --- /dev/null +++ b/tests/lib/kategorie-defaults.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from "vitest"; +import { + DEFAULT_AUSGABE_KATEGORIEN, + DEFAULT_EINNAHME_KATEGORIEN, +} from "@/lib/kategorie-defaults"; + +describe("kategorie-defaults.ts", () => { + describe("DEFAULT_AUSGABE_KATEGORIEN", () => { + it("should have 15 default expense categories", () => { + expect(DEFAULT_AUSGABE_KATEGORIEN).toHaveLength(15); + }); + + it("should include common expense categories", () => { + expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Waren, Rohstoffe, Hilfsstoffe"); + expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Miete"); + expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Strom, Wasser"); + expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Telekommunikationskosten"); + expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Bürobedarf"); + }); + + it("should include GERINGWERTIGE_WIRTSCHAFTSGÜTER", () => { + expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Geringwertige Wirtschaftsgüter"); + }); + + it("should include Abschreibungen", () => { + expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Abschreibungen"); + }); + + it("should include Zinsen", () => { + expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Zinsen"); + }); + + it("should include Reisekosten", () => { + expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Reisekosten"); + }); + + it("should have unique values", () => { + const unique = new Set(DEFAULT_AUSGABE_KATEGORIEN); + expect(unique.size).toBe(DEFAULT_AUSGABE_KATEGORIEN.length); + }); + + it("should not have empty strings", () => { + DEFAULT_AUSGABE_KATEGORIEN.forEach((cat) => { + expect(cat).not.toBe(""); + expect(cat.trim().length).toBeGreaterThan(0); + }); + }); + }); + + describe("DEFAULT_EINNAHME_KATEGORIEN", () => { + it("should have 9 default revenue categories", () => { + expect(DEFAULT_EINNAHME_KATEGORIEN).toHaveLength(9); + }); + + it("should include Fußpflege category", () => { + expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Fußpflege/Verkauf/Gutscheine"); + }); + + it("should include Privateinlagen", () => { + expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Privateinlagen"); + }); + + it("should include Darlehen", () => { + expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Darlehen"); + }); + + it("should include Steuererstattungen", () => { + expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Steuererstattungen"); + }); + + it("should include Zinserträge", () => { + expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Zinserträge"); + }); + + it("should include Miet-/Pachteinnahmen", () => { + expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Miet-/Pachteinnahmen"); + }); + + it("should include Veräußerungserlöse", () => { + expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Veräußerungserlöse"); + }); + + it("should have unique values", () => { + const unique = new Set(DEFAULT_EINNAHME_KATEGORIEN); + expect(unique.size).toBe(DEFAULT_EINNAHME_KATEGORIEN.length); + }); + + it("should not have empty strings", () => { + DEFAULT_EINNAHME_KATEGORIEN.forEach((cat) => { + expect(cat).not.toBe(""); + expect(cat.trim().length).toBeGreaterThan(0); + }); + }); + }); + + describe("Category Integration", () => { + it("should not have overlapping categories between income and expenses", () => { + const overlap = DEFAULT_AUSGABE_KATEGORIEN.filter((cat) => + DEFAULT_EINNAHME_KATEGORIEN.includes(cat) + ); + expect(overlap).toHaveLength(0); + }); + + it("should have consistent German language", () => { + // Check that categories contain German umlauts or common German words + const allCategories = [ + ...DEFAULT_AUSGABE_KATEGORIEN, + ...DEFAULT_EINNAHME_KATEGORIEN, + ]; + + allCategories.forEach((cat) => { + expect(cat.length).toBeGreaterThan(0); + // Should not contain numbers at start (heuristic check) + expect(/^\d/.test(cat)).toBe(false); + }); + }); + + it("should cover common business categories", () => { + // Expenses should cover: rent, utilities, telecom, office supplies + const expenseStr = DEFAULT_AUSGABE_KATEGORIEN.join(" "); + expect(expenseStr).toContain("Miete"); + expect(expenseStr).toContain("Bürobedarf"); + + // Revenue should cover: services, loans, interest + const revenueStr = DEFAULT_EINNAHME_KATEGORIEN.join(" "); + expect(revenueStr).toContain("Darlehen"); + expect(revenueStr).toContain("Zinserträge"); + }); + }); + + describe("Practical Usage", () => { + it("should be usable as default values for new companies", () => { + // Simulate creating default categories for a new company + const companyId = "new-company-123"; + + const defaultExpenseCategories = DEFAULT_AUSGABE_KATEGORIEN.map((name) => ({ + companyId, + name, + typ: "AUSGABE" as const, + })); + + const defaultRevenueCategories = DEFAULT_EINNAHME_KATEGORIEN.map((name) => ({ + companyId, + name, + typ: "EINNAHME" as const, + })); + + expect(defaultExpenseCategories).toHaveLength(15); + expect(defaultRevenueCategories).toHaveLength(9); + expect(defaultExpenseCategories[0].typ).toBe("AUSGABE"); + expect(defaultRevenueCategories[0].typ).toBe("EINNAHME"); + }); + + it("should allow adding custom expense categories", () => { + const customCategories: string[] = [ + ...DEFAULT_AUSGABE_KATEGORIEN, + "Custom Expense Category", + ]; + + expect(customCategories.length).toBe(16); // 15 defaults + 1 custom + expect(customCategories[15]).toBe("Custom Expense Category"); + }); + + }); +}); diff --git a/tests/lib/schemas.test.ts b/tests/lib/schemas.test.ts new file mode 100644 index 0000000..9f151b2 --- /dev/null +++ b/tests/lib/schemas.test.ts @@ -0,0 +1,243 @@ +import { + currencySchema, + taxRateSchema, + ibanSchema, + taxIdSchema, + vatIdSchema, + invoiceSchema, + companySchema, + customerSchema, +} from "@/lib/schemas"; + +describe("schemas.ts - Zod Validation", () => { + describe("currencySchema", () => { + it("should accept valid currency values", () => { + expect(currencySchema.parse(0)).toBe(0); + expect(currencySchema.parse(100)).toBe(100); + expect(currencySchema.parse(99.99)).toBe(99.99); + expect(currencySchema.parse(1000.5)).toBe(1000.5); + }); + + it("should reject negative values", () => { + expect(() => currencySchema.parse(-1)).toThrow("Geldbeträge dürfen nicht negativ sein"); + }); + + it("should reject more than 2 decimal places", () => { + expect(() => currencySchema.parse(1.999)).toThrow( + "Geldbeträge dürfen maximal 2 Dezimalstellen haben" + ); + }); + + it("should accept exactly 2 decimal places", () => { + expect(currencySchema.parse(1.99)).toBe(1.99); + }); + }); + + describe("taxRateSchema", () => { + it("should accept valid German tax rates", () => { + expect(taxRateSchema.parse(0)).toBe(0); + expect(taxRateSchema.parse(7)).toBe(7); + expect(taxRateSchema.parse(19)).toBe(19); + }); + + it("should reject invalid tax rates", () => { + expect(() => taxRateSchema.parse(5)).toThrow( + "Steuersatz muss 0 (steuerfrei), 7 (ermäßigt) oder 19 (Standard) sein" + ); + expect(() => taxRateSchema.parse(20)).toThrow(); + expect(() => taxRateSchema.parse(15)).toThrow(); + }); + + it("should reject non-integer values", () => { + expect(() => taxRateSchema.parse(7.5)).toThrow("Steuersatz muss eine ganze Zahl sein"); + }); + }); + + describe("ibanSchema", () => { + it("should accept valid IBANs", () => { + expect(ibanSchema.parse("DE89370400440532013000")).toBe("DE89370400440532013000"); + expect(ibanSchema.parse("AT611904300234573201")).toBe("AT611904300234573201"); + expect(ibanSchema.parse("CH9300762011623852957")).toBe("CH9300762011623852957"); + }); + + it("should accept empty string", () => { + expect(ibanSchema.parse("")).toBe(""); + }); + + it("should reject invalid IBAN format", () => { + // No country code - starts with numbers + expect(() => ibanSchema.parse("1234567890")).toThrow(); + // Too short - only 4 chars (need at least 5: 2 letters + 2 digits + 1) + expect(() => ibanSchema.parse("DE1")).toThrow(); + }); + }); + + describe("taxIdSchema", () => { + it("should accept valid 10-digit tax IDs", () => { + expect(taxIdSchema.parse("1234567890")).toBe("1234567890"); + }); + + it("should accept empty string", () => { + expect(taxIdSchema.parse("")).toBe(""); + }); + + it("should reject invalid tax IDs", () => { + expect(() => taxIdSchema.parse("123")).toThrow("Steuernummer muss 10 Ziffern haben"); + expect(() => taxIdSchema.parse("12345678901")).toThrow(); + expect(() => taxIdSchema.parse("abcdefghij")).toThrow(); + }); + }); + + describe("vatIdSchema", () => { + it("should accept valid DE VAT IDs", () => { + expect(vatIdSchema.parse("DE123456789")).toBe("DE123456789"); + }); + + it("should accept empty string", () => { + expect(vatIdSchema.parse("")).toBe(""); + }); + + it("should reject invalid VAT IDs", () => { + expect(() => vatIdSchema.parse("DE123")).toThrow( + "USt-IdNr. muss im Format DE + 9 Ziffern sein" + ); + expect(() => vatIdSchema.parse("1234567890")).toThrow(); + expect(() => vatIdSchema.parse("DE12345678")).toThrow(); + }); + }); + + describe("invoiceSchema", () => { + const validInvoice = { + companyId: "cm123abc", + customerId: "cm456def", + issueDate: "2026-05-08", + dueDate: "2026-06-08", + kleinunternehmer: false, + items: [ + { + position: 1, + description: "Web Development", + quantity: 10, + unit: "Stunden", + unitPrice: 100, + taxRate: 19, + netAmount: 1000, + taxAmount: 190, + grossAmount: 1190, + }, + ], + netTotal: 1000, + taxTotal: 190, + grossTotal: 1190, + }; + + it("should accept valid invoice", () => { + const result = invoiceSchema.parse(validInvoice); + expect(result.companyId).toBe("cm123abc"); + expect(result.items).toHaveLength(1); + }); + + it("should require at least one item", () => { + const invalid = { ...validInvoice, items: [] }; + expect(() => invoiceSchema.parse(invalid)).toThrow( + "Mindestens ein Rechnungsposition erforderlich" + ); + }); + + it("should accept optional deliveryDate", () => { + const withDelivery = { ...validInvoice, deliveryDate: "2026-05-07" }; + expect(() => invoiceSchema.parse(withDelivery)).not.toThrow(); + }); + + it("should reject invalid dates", () => { + const invalid = { ...validInvoice, issueDate: "not-a-date" }; + expect(() => invoiceSchema.parse(invalid)).toThrow("Ungültiges Datum"); + }); + + it("should accept optional notes with max length", () => { + const withNotes = { ...validInvoice, notes: "Test notes" }; + expect(() => invoiceSchema.parse(withNotes)).not.toThrow(); + }); + }); + + describe("companySchema", () => { + const validCompany = { + name: "Test GmbH", + taxId: null, + vatId: null, + address: "Hauptstraße 1", + zip: "12345", + city: "Berlin", + country: "DE", + invoicePrefix: "RE", + kleinunternehmer: false, + bankIban: null, + // bankBic is optional, can be omitted or empty string + }; + + it("should accept valid company", () => { + const result = companySchema.parse(validCompany); + expect(result.name).toBe("Test GmbH"); + }); + + it("should require name and address", () => { + const invalid = { ...validCompany, name: "" }; + expect(() => companySchema.parse(invalid)).toThrow("Firmenname erforderlich"); + }); + + it("should validate email format", () => { + const withEmail = { ...validCompany, email: "test@example.com" }; + expect(() => companySchema.parse(withEmail)).not.toThrow(); + }); + + it("should reject invalid email", () => { + const invalid = { ...validCompany, email: "not-an-email" }; + expect(() => companySchema.parse(invalid)).toThrow("Ungültige E-Mail-Adresse"); + }); + + it("should validate website starts with http/https", () => { + const withWebsite = { ...validCompany, website: "https://example.com" }; + expect(() => companySchema.parse(withWebsite)).not.toThrow(); + }); + + it("should reject website without protocol", () => { + const invalid = { ...validCompany, website: "example.com" }; + expect(() => companySchema.parse(invalid)).toThrow( + "Website muss mit http:// oder https:// beginnen" + ); + }); + + it("should accept valid IBAN", () => { + const withIban = { ...validCompany, bankIban: "DE89370400440532013000" }; + expect(() => companySchema.parse(withIban)).not.toThrow(); + }); + }); + + describe("customerSchema", () => { + const validCustomer = { + companyId: "cm123abc", + name: "Max Mustermann", + address: "Musterstraße 1", + zip: "12345", + city: "Berlin", + country: "DE", + }; + + it("should accept valid customer", () => { + const result = customerSchema.parse(validCustomer); + expect(result.name).toBe("Max Mustermann"); + }); + + it("should require companyId", () => { + const invalid = { ...validCustomer, companyId: "" }; + expect(() => customerSchema.parse(invalid)).toThrow("Mandant erforderlich"); + }); + + it("should validate zip code format", () => { + const invalid = { ...validCustomer, zip: "abc" }; + expect(() => customerSchema.parse(invalid)).toThrow( + "PLZ darf nur Zahlen, Bindestriche und Leerzeichen enthalten" + ); + }); + }); +}); diff --git a/tests/lib/tax.test.ts b/tests/lib/tax.test.ts new file mode 100644 index 0000000..d20ab98 --- /dev/null +++ b/tests/lib/tax.test.ts @@ -0,0 +1,158 @@ +import { + TAX_RATES, + calcItemAmounts, + calcItemAmountsKleinunternehmer, + calcInvoiceTotals, + formatCurrency, + formatDate, +} from "@/lib/tax"; + +describe("tax.ts - German Tax Calculations", () => { + describe("TAX_RATES", () => { + it("should have correct tax rate values", () => { + expect(TAX_RATES).toEqual([ + { label: "19% MwSt. (Regelsteuersatz)", value: 19 }, + { label: "7% MwSt. (ermäßigt)", value: 7 }, + { label: "0% (steuerfrei / §13b UStG)", value: 0 }, + ]); + }); + }); + + describe("calcItemAmounts", () => { + it("should calculate correct amounts for 19% tax", () => { + const result = calcItemAmounts(2, 100, 19); + expect(result).toEqual({ + netAmount: 200, + taxAmount: 38, + grossAmount: 238, + }); + }); + + it("should calculate correct amounts for 7% tax", () => { + const result = calcItemAmounts(1, 50, 7); + expect(result).toEqual({ + netAmount: 50, + taxAmount: 3.5, + grossAmount: 53.5, + }); + }); + + it("should handle tax-free items (0%)", () => { + const result = calcItemAmounts(3, 33.33, 0); + expect(result).toEqual({ + netAmount: 99.99, + taxAmount: 0, + grossAmount: 99.99, + }); + }); + + it("should handle decimal quantities", () => { + const result = calcItemAmounts(1.5, 100, 19); + expect(result).toEqual({ + netAmount: 150, + taxAmount: 28.5, + grossAmount: 178.5, + }); + }); + + it("should round to 2 decimal places", () => { + const result = calcItemAmounts(1, 33.333, 19); + // 33.333 * 19% = 6.333..., rounded to 6.33 + expect(result.netAmount).toBe(33.33); + expect(result.taxAmount).toBe(6.33); + expect(result.grossAmount).toBe(39.66); + }); + }); + + describe("calcItemAmountsKleinunternehmer", () => { + it("should set tax to 0 for Kleinunternehmer", () => { + const result = calcItemAmountsKleinunternehmer(2, 100); + expect(result).toEqual({ + netAmount: 200, + taxAmount: 0, + grossAmount: 200, + }); + }); + + it("should treat gross as net for Kleinunternehmer", () => { + const result = calcItemAmountsKleinunternehmer(1, 50); + expect(result.netAmount).toBe(50); + expect(result.grossAmount).toBe(50); + }); + }); + + describe("calcInvoiceTotals", () => { + it("should sum up multiple invoice items", () => { + const items = [ + { netAmount: 100, taxAmount: 19, grossAmount: 119 }, + { netAmount: 50, taxAmount: 3.5, grossAmount: 53.5 }, + ]; + const result = calcInvoiceTotals(items); + expect(result).toEqual({ + netTotal: 150, + taxTotal: 22.5, + grossTotal: 172.5, + }); + }); + + it("should handle empty items array", () => { + const result = calcInvoiceTotals([]); + expect(result).toEqual({ + netTotal: 0, + taxTotal: 0, + grossTotal: 0, + }); + }); + + it("should round totals to 2 decimal places", () => { + const items = [ + { netAmount: 33.333, taxAmount: 6.333, grossAmount: 39.666 }, + { netAmount: 66.666, taxAmount: 12.666, grossAmount: 79.332 }, + ]; + const result = calcInvoiceTotals(items); + // calcInvoiceTotals uses Math.round which rounds 99.999 to 100 + expect(result.netTotal).toBe(100); + expect(result.taxTotal).toBe(19); + expect(result.grossTotal).toBe(119); + }); + + it("should handle Kleinunternehmer invoice (no tax)", () => { + const items = [ + { netAmount: 100, taxAmount: 0, grossAmount: 100 }, + { netAmount: 200, taxAmount: 0, grossAmount: 200 }, + ]; + const result = calcInvoiceTotals(items); + expect(result).toEqual({ + netTotal: 300, + taxTotal: 0, + grossTotal: 300, + }); + }); + }); + + describe("formatCurrency", () => { + it("should format number to EUR currency", () => { + // Note: Intl.NumberFormat uses non-breaking space (\u00A0) before € + expect(formatCurrency(1234.56)).toBe("1.234,56\u00A0€"); + }); + + it("should handle string input", () => { + expect(formatCurrency("999.99")).toBe("999,99\u00A0€"); + }); + + it("should format zero correctly", () => { + expect(formatCurrency(0)).toBe("0,00\u00A0€"); + }); + }); + + describe("formatDate", () => { + it("should format Date object to German format", () => { + const date = new Date("2026-05-08"); + expect(formatDate(date)).toBe("8.5.2026"); + }); + + it("should format date string to German format", () => { + expect(formatDate("2026-12-31")).toBe("31.12.2026"); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..5bcab22 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [react(), tsconfigPaths()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./vitest.setup.ts"], + include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["app/lib/**/*.ts", "app/lib/**/*.tsx"], + exclude: ["app/lib/**/*.server.ts"], + }, + }, +}); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..2891078 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; + +// Global test setup +beforeEach(() => { + // Clear all mocks before each test + vi.clearAllMocks(); +});