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();
+});