diff --git a/package-lock.json b/package-lock.json index 46cad90..78864e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,10 +24,13 @@ "@types/pg": "^8.18.0", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.1.2", "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.2" } }, "node_modules/@alloc/quick-lru": { @@ -179,7 +182,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -189,7 +192,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -223,7 +226,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -273,7 +276,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -283,6 +286,16 @@ "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/@chevrotain/cst-dts-gen": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", @@ -1318,6 +1331,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@prisma/adapter-pg": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.2.tgz", @@ -1527,6 +1550,287 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1863,6 +2167,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -1926,6 +2241,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "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", @@ -2558,6 +2880,169 @@ "win32" ] }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", + "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.2", + "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.2", + "vitest": "4.1.2" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/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/@vitest/coverage-v8/node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "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.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2791,6 +3276,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2798,6 +3293,25 @@ "dev": true, "license": "MIT" }, + "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/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3038,6 +3552,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", @@ -3670,6 +4194,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4183,6 +4714,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", @@ -4199,6 +4740,16 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "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/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -4384,6 +4935,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4590,6 +5156,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4741,6 +5314,13 @@ "node": ">=16.9.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-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", @@ -5268,6 +5848,45 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "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/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -5775,6 +6394,48 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "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", @@ -6171,6 +6832,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/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -6878,6 +7550,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7188,6 +7894,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7234,6 +7947,13 @@ "dev": true, "license": "MIT" }, + "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/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -7466,6 +8186,13 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "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.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -7523,6 +8250,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7549,6 +8286,27 @@ "typescript": ">=4.8.4" } }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -7857,6 +8615,475 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-tsconfig-paths": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.1.1.tgz", + "integrity": "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/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/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "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": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "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/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/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/vitest/node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7961,6 +9188,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", diff --git a/package.json b/package.json index 8224e59..f76e951 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,12 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run --config vitest.config.ts", + "test:watch": "vitest --config vitest.config.ts", + "test:setup": "bash scripts/setup-test-db.sh", + "test:integration": "vitest run --config vitest.integration.config.ts", + "test:all": "npm test && npm run test:integration" }, "dependencies": { "@prisma/adapter-pg": "^7.4.2", @@ -25,9 +30,12 @@ "@types/pg": "^8.18.0", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.1.2", "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.2" } } diff --git a/scripts/setup-test-db.sh b/scripts/setup-test-db.sh new file mode 100755 index 0000000..1471b32 --- /dev/null +++ b/scripts/setup-test-db.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Creates the personal_test database and writes .env.test +# Run once before integration tests: npm run test:setup + +set -e + +PG_CONTAINER=postgres-personal +PG_USER=personal +PG_PASS=personalpassword123 +TEST_DB=personal_test + +# Discover the container's bridge IP (accessible from the host on Linux) +PG_HOST=$(docker inspect "$PG_CONTAINER" \ + --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null | head -1) + +if [ -z "$PG_HOST" ]; then + echo "ERROR: Could not find container '$PG_CONTAINER'" + exit 1 +fi + +echo "Postgres container at $PG_HOST:5432" + +# Create test database (ignore error if it already exists) +docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres \ + -c "CREATE DATABASE $TEST_DB;" 2>/dev/null || true + +# Wipe and rebuild schema from production (schema only, no data) +echo "Copying schema from $PG_USER to $TEST_DB..." +docker exec "$PG_CONTAINER" pg_dump -U "$PG_USER" --schema-only "$PG_USER" \ + | docker exec -i "$PG_CONTAINER" psql -U "$PG_USER" -d "$TEST_DB" -q + +echo "Schema ready." + +# Write .env.test +cat > "$(dirname "$0")/../.env.test" << EOF +DATABASE_URL=postgresql://$PG_USER:$PG_PASS@$PG_HOST:5432/$TEST_DB +EOF + +echo ".env.test written — integration tests can now run with: npm run test:integration" diff --git a/src/__tests__/integration/helpers.ts b/src/__tests__/integration/helpers.ts new file mode 100644 index 0000000..6ef611a --- /dev/null +++ b/src/__tests__/integration/helpers.ts @@ -0,0 +1,83 @@ +import { Pool } from "pg"; +import { vi } from "vitest"; + +export function createPool() { + return new Pool({ connectionString: process.env.DATABASE_URL }); +} + +// Replace the app's Prisma-based queryRaw with a direct pg call so that +// tests don't depend on Prisma's singleton picking up the right DATABASE_URL. +// Must be called BEFORE dynamically importing any module that uses @/lib/db. +// Uses vi.doMock (not vi.mock) so it is NOT hoisted and CAN close over `p`. +export function mockDbWithPool(p: Pool) { + vi.resetModules(); // clear module cache so fresh imports pick up the mock + vi.doMock("@/lib/db", () => ({ + queryRaw: async (sql: string, params: unknown[] = []) => { + const result = await p.query(sql, params); + return result.rows; + }, + prisma: p, + })); +} + +/** Wipe all data tables and restart sequences between tests. */ +export async function resetDB(pool: Pool) { + await pool.query(` + TRUNCATE + split_payments, + transaction_splits, + transaction_tags, + transaction_overrides, + rule_apply_runs, + rules, + budgets, + account_owner_mappings, + transactions, + statements, + tags, + participants + RESTART IDENTITY CASCADE + `); +} + +/** Seed two participants and return their IDs. */ +export async function seedParticipants(pool: Pool, names: [string, string] = ["Alice", "Bob"]) { + const r1 = await pool.query( + `INSERT INTO participants (name) VALUES ($1) RETURNING id`, + [names[0]] + ); + const r2 = await pool.query( + `INSERT INTO participants (name) VALUES ($1) RETURNING id`, + [names[1]] + ); + return { ownerId: r1.rows[0].id as number, otherId: r2.rows[0].id as number }; +} + +/** Insert a manual transaction (no statement) and return its id. */ +export async function insertTransaction( + pool: Pool, + ownerId: number, + overrides: { + description?: string; + amount?: number; + category?: string; + transaction_type?: string; + transaction_date?: string; + merchant_normalized?: string; + } = {} +): Promise { + const r = await pool.query( + `INSERT INTO transactions + (owner_id, statement_id, transaction_date, description, amount, transaction_type, category, row_index) + VALUES ($1, NULL, $2, $3, $4, $5, $6, 0) RETURNING id`, + [ + ownerId, + overrides.transaction_date ?? "2024-06-15", + overrides.description ?? "Test transaction", + overrides.amount ?? 100, + overrides.transaction_type ?? "debit", + overrides.category ?? "other", + ] + ); + return r.rows[0].id as number; +} diff --git a/src/__tests__/integration/participants.test.ts b/src/__tests__/integration/participants.test.ts new file mode 100644 index 0000000..a571cdc --- /dev/null +++ b/src/__tests__/integration/participants.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, afterAll } from "vitest"; +import { createPool, resetDB } from "./helpers"; + +const pool = createPool(); + +beforeEach(async () => { + await resetDB(pool); +}); + +afterAll(async () => { + await pool.end(); +}); + +// This tests the name-substitution logic applied in /api/participants GET. +// The rule: the participant matching the current user's ID gets name "Me"; +// everyone else keeps their real name. +function substituteMe( + participants: { id: number; name: string }[], + currentUserId: number +) { + return participants.map((p) => + p.id === currentUserId ? { ...p, name: "Me" } : p + ); +} + +describe("participant Me substitution", () => { + it("replaces the current user's name with Me", async () => { + const r = await pool.query( + `INSERT INTO participants (name) VALUES ('Siddharth') RETURNING id` + ); + const userId = r.rows[0].id; + const participants = [ + { id: userId, name: "Siddharth" }, + { id: userId + 1, name: "Sonu" }, + ]; + + const result = substituteMe(participants, userId); + expect(result.find((p) => p.id === userId)?.name).toBe("Me"); + expect(result.find((p) => p.id === userId + 1)?.name).toBe("Sonu"); + }); + + it("leaves all names unchanged when currentUserId does not match", () => { + const participants = [ + { id: 1, name: "Siddharth" }, + { id: 2, name: "Sonu" }, + ]; + const result = substituteMe(participants, 999); + expect(result).toEqual(participants); + }); + + it("Sonu sees Me for herself and Siddharth for the primary user", async () => { + const r1 = await pool.query( + `INSERT INTO participants (name) VALUES ('Siddharth') RETURNING id` + ); + const r2 = await pool.query( + `INSERT INTO participants (name, email) VALUES ('Sonu', 'sonu@example.com') RETURNING id` + ); + const siddharthId = r1.rows[0].id; + const sonuId = r2.rows[0].id; + + const rawParticipants = [ + { id: siddharthId, name: "Siddharth" }, + { id: sonuId, name: "Sonu" }, + ]; + + // Siddharth's view + const siddharthView = substituteMe(rawParticipants, siddharthId); + expect(siddharthView.find((p) => p.id === siddharthId)?.name).toBe("Me"); + expect(siddharthView.find((p) => p.id === sonuId)?.name).toBe("Sonu"); + + // Sonu's view + const sonuView = substituteMe(rawParticipants, sonuId); + expect(sonuView.find((p) => p.id === siddharthId)?.name).toBe("Siddharth"); + expect(sonuView.find((p) => p.id === sonuId)?.name).toBe("Me"); + }); +}); diff --git a/src/__tests__/integration/queries.test.ts b/src/__tests__/integration/queries.test.ts new file mode 100644 index 0000000..0ee01ac --- /dev/null +++ b/src/__tests__/integration/queries.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; +import { createPool, mockDbWithPool, resetDB, seedParticipants, insertTransaction } from "./helpers"; + +// Create a pool and mock @/lib/db BEFORE any dynamic imports that use it. +// vi.doMock is NOT hoisted so it can close over the pool instance. +const pool = createPool(); +mockDbWithPool(pool); + +// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances +// use the test pool rather than Prisma's singleton. +const { getTransactions, getParticipantBalances } = await import("@/lib/queries"); + +beforeEach(async () => { + await resetDB(pool); +}); + +afterAll(async () => { + await pool.end(); + vi.restoreAllMocks(); +}); + +// ── getTransactions ─────────────────────────────────────────────────────────── + +describe("getTransactions — owner scoping", () => { + it("returns only the owner's transactions", async () => { + const { ownerId, otherId } = await seedParticipants(pool); + await insertTransaction(pool, ownerId, { description: "Alice groceries" }); + await insertTransaction(pool, otherId, { description: "Bob petrol" }); + + const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 }); + expect(data).toHaveLength(1); + expect(data[0].description).toBe("Alice groceries"); + }); + + it("includes transactions where owner is a split participant", async () => { + const { ownerId, otherId } = await seedParticipants(pool); + const txId = await insertTransaction(pool, otherId, { description: "Shared dinner" }); + + // Add Alice as a split participant + await pool.query( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`, + [txId, ownerId] + ); + + const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 }); + expect(data.some((t) => t.description === "Shared dinner")).toBe(true); + }); + + it("returns correct total count", async () => { + const { ownerId } = await seedParticipants(pool); + await insertTransaction(pool, ownerId, { description: "tx1" }); + await insertTransaction(pool, ownerId, { description: "tx2" }); + await insertTransaction(pool, ownerId, { description: "tx3" }); + + const { total } = await getTransactions(ownerId, { limit: 2, offset: 0 }); + expect(total).toBe(3); + }); +}); + +describe("getTransactions — date filters", () => { + it("filters by from date", async () => { + const { ownerId } = await seedParticipants(pool); + await insertTransaction(pool, ownerId, { description: "old tx", transaction_date: "2024-01-10" }); + await insertTransaction(pool, ownerId, { description: "new tx", transaction_date: "2024-03-01" }); + + const { data } = await getTransactions(ownerId, { from: "2024-02-01", limit: 50, offset: 0 }); + expect(data).toHaveLength(1); + expect(data[0].description).toBe("new tx"); + }); + + it("filters by to date", async () => { + const { ownerId } = await seedParticipants(pool); + await insertTransaction(pool, ownerId, { description: "old tx", transaction_date: "2024-01-10" }); + await insertTransaction(pool, ownerId, { description: "new tx", transaction_date: "2024-03-01" }); + + const { data } = await getTransactions(ownerId, { to: "2024-01-31", limit: 50, offset: 0 }); + expect(data).toHaveLength(1); + expect(data[0].description).toBe("old tx"); + }); +}); + +describe("getTransactions — category filter", () => { + it("filters by category", async () => { + const { ownerId } = await seedParticipants(pool); + await insertTransaction(pool, ownerId, { description: "Grocery run", category: "groceries" }); + await insertTransaction(pool, ownerId, { description: "Dinner out", category: "dining" }); + + const { data } = await getTransactions(ownerId, { categories: ["groceries"], limit: 50, offset: 0 }); + expect(data).toHaveLength(1); + expect(data[0].description).toBe("Grocery run"); + }); + + it("category override takes precedence over raw category", async () => { + const { ownerId } = await seedParticipants(pool); + const txId = await insertTransaction(pool, ownerId, { category: "dining" }); + await pool.query( + `INSERT INTO transaction_overrides (transaction_id, category_override) VALUES ($1, 'groceries')`, + [txId] + ); + + const { data: dining } = await getTransactions(ownerId, { categories: ["dining"], limit: 50, offset: 0 }); + const { data: groceries } = await getTransactions(ownerId, { categories: ["groceries"], limit: 50, offset: 0 }); + expect(dining).toHaveLength(0); // override hides original + expect(groceries).toHaveLength(1); // override exposes new category + }); +}); + +describe("getTransactions — search filter", () => { + it("searches description case-insensitively", async () => { + const { ownerId } = await seedParticipants(pool); + await insertTransaction(pool, ownerId, { description: "COLES WYNDHAM" }); + await insertTransaction(pool, ownerId, { description: "ALDI POINT COOK" }); + + const { data } = await getTransactions(ownerId, { search: "coles", limit: 50, offset: 0 }); + expect(data).toHaveLength(1); + expect(data[0].description).toBe("COLES WYNDHAM"); + }); +}); + +describe("getTransactions — amount filters", () => { + it("filters by amount_min", async () => { + const { ownerId } = await seedParticipants(pool); + await insertTransaction(pool, ownerId, { amount: 20 }); + await insertTransaction(pool, ownerId, { amount: 200 }); + + const { data } = await getTransactions(ownerId, { amount_min: 100, limit: 50, offset: 0 }); + expect(data).toHaveLength(1); + expect(Number(data[0].amount)).toBe(200); + }); + + it("filters by amount_max", async () => { + const { ownerId } = await seedParticipants(pool); + await insertTransaction(pool, ownerId, { amount: 20 }); + await insertTransaction(pool, ownerId, { amount: 200 }); + + const { data } = await getTransactions(ownerId, { amount_max: 50, limit: 50, offset: 0 }); + expect(data).toHaveLength(1); + expect(Number(data[0].amount)).toBe(20); + }); +}); + +describe("getTransactions — pagination", () => { + it("respects limit and offset", async () => { + const { ownerId } = await seedParticipants(pool); + for (let i = 0; i < 5; i++) { + await insertTransaction(pool, ownerId, { description: `tx-${i}`, transaction_date: `2024-0${i + 1}-01` }); + } + + const page1 = await getTransactions(ownerId, { limit: 2, offset: 0 }); + const page2 = await getTransactions(ownerId, { limit: 2, offset: 2 }); + expect(page1.data).toHaveLength(2); + expect(page2.data).toHaveLength(2); + expect(page1.data[0].description).not.toBe(page2.data[0].description); + expect(page1.total).toBe(5); + }); +}); + +describe("getTransactions — splits and tags attached", () => { + it("attaches empty arrays when no splits or tags", async () => { + const { ownerId } = await seedParticipants(pool); + await insertTransaction(pool, ownerId); + + const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 }); + expect(data[0].splits).toEqual([]); + expect(data[0].tags).toEqual([]); + }); + + it("attaches split participants", async () => { + const { ownerId, otherId } = await seedParticipants(pool, ["Alice", "Bob"]); + const txId = await insertTransaction(pool, ownerId); + await pool.query( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`, + [txId, otherId] + ); + + const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 }); + expect(data[0].splits).toHaveLength(1); + expect(data[0].splits[0].name).toBe("Bob"); + expect(Number(data[0].splits[0].share_percent)).toBe(50); + }); +}); + +// ── getParticipantBalances ──────────────────────────────────────────────────── + +describe("getParticipantBalances", () => { + it("shows zero balance when no splits", async () => { + const { ownerId, otherId } = await seedParticipants(pool); + void otherId; + const balances = await getParticipantBalances(ownerId); + expect(balances.every((b) => Number(b.total_owed) === 0)).toBe(true); + }); + + it("calculates positive balance when participant owes owner", async () => { + const { ownerId, otherId } = await seedParticipants(pool); + // Alice pays $100, Bob owes 50% + const txId = await insertTransaction(pool, ownerId, { amount: 100 }); + await pool.query( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`, + [txId, otherId] + ); + + const balances = await getParticipantBalances(ownerId); + const bobBalance = balances.find((b) => b.id === otherId); + expect(bobBalance).toBeDefined(); + expect(Number(bobBalance!.total_owed)).toBeCloseTo(50); + }); + + it("reduces balance after recording a payment", async () => { + const { ownerId, otherId } = await seedParticipants(pool); + const txId = await insertTransaction(pool, ownerId, { amount: 100 }); + await pool.query( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`, + [txId, otherId] + ); + // Bob pays Alice $30 + await pool.query( + `INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date) + VALUES ($1, $2, 30, '2024-06-20')`, + [otherId, ownerId] + ); + + const balances = await getParticipantBalances(ownerId); + const bobBalance = balances.find((b) => b.id === otherId); + expect(Number(bobBalance!.total_owed)).toBeCloseTo(20); + }); + + it("shows negative balance when owner owes participant", async () => { + const { ownerId, otherId } = await seedParticipants(pool); + // Bob pays $100 for a shared expense, Alice owes 50% + const txId = await insertTransaction(pool, otherId, { amount: 100 }); + await pool.query( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`, + [txId, ownerId] + ); + + const balances = await getParticipantBalances(ownerId); + const bobBalance = balances.find((b) => b.id === otherId); + expect(Number(bobBalance!.total_owed)).toBeCloseTo(-50); + }); + + it("unsettled_count reflects open splits", async () => { + const { ownerId, otherId } = await seedParticipants(pool); + const tx1 = await insertTransaction(pool, ownerId, { amount: 100 }); + const tx2 = await insertTransaction(pool, ownerId, { amount: 80 }); + await pool.query( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) + VALUES ($1, $2, 50), ($3, $4, 50)`, + [tx1, otherId, tx2, otherId] + ); + + const balances = await getParticipantBalances(ownerId); + const bobBalance = balances.find((b) => b.id === otherId); + expect(bobBalance!.unsettled_count).toBe(2); + }); +}); diff --git a/src/__tests__/unit/categories.test.ts b/src/__tests__/unit/categories.test.ts new file mode 100644 index 0000000..be6fd06 --- /dev/null +++ b/src/__tests__/unit/categories.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { CATEGORIES, formatCategory } from "@/lib/categories"; + +describe("formatCategory", () => { + it("capitalises single word", () => { + expect(formatCategory("groceries")).toBe("Groceries"); + }); + it("capitalises and spaces underscore-separated words", () => { + expect(formatCategory("home_goods")).toBe("Home Goods"); + }); + it("handles three-word categories", () => { + expect(formatCategory("home_maintenance")).toBe("Home Maintenance"); + }); + it("handles cash_advance", () => { + expect(formatCategory("cash_advance")).toBe("Cash Advance"); + }); + it("handles personal_care", () => { + expect(formatCategory("personal_care")).toBe("Personal Care"); + }); + it("handles single-word categories without underscores", () => { + expect(formatCategory("travel")).toBe("Travel"); + expect(formatCategory("fees")).toBe("Fees"); + expect(formatCategory("other")).toBe("Other"); + }); +}); + +describe("CATEGORIES", () => { + it("contains expected core categories", () => { + const cats = CATEGORIES as readonly string[]; + expect(cats).toContain("groceries"); + expect(cats).toContain("dining"); + expect(cats).toContain("transport"); + expect(cats).toContain("health"); + expect(cats).toContain("other"); + }); + it("has no duplicates", () => { + const cats = CATEGORIES as readonly string[]; + expect(new Set(cats).size).toBe(cats.length); + }); + it("all entries are lowercase with only letters and underscores", () => { + for (const cat of CATEGORIES) { + expect(cat).toMatch(/^[a-z_]+$/); + } + }); + it("formatCategory produces unique display names", () => { + const formatted = CATEGORIES.map(formatCategory); + expect(new Set(formatted).size).toBe(formatted.length); + }); +}); diff --git a/src/__tests__/unit/rules.test.ts b/src/__tests__/unit/rules.test.ts new file mode 100644 index 0000000..ed0c35c --- /dev/null +++ b/src/__tests__/unit/rules.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; +import { evaluateCondition, type Condition, type TxFields } from "@/lib/rules"; + +function tx(overrides: Partial = {}): TxFields { + return { + effective_category: "groceries", + effective_merchant: "Coles", + description: "COLES WYNDHAM VALE", + bank_name: "ANZ", + amount: 42.5, + transaction_type: "debit", + tags: [], + ...overrides, + }; +} + +function cond(field: Condition["field"], operator: Condition["operator"], value: string): Condition { + return { field, operator, value }; +} + +// ── String fields ───────────────────────────────────────────────────────────── + +describe("merchant_normalized", () => { + it("contains — matches substring", () => { + expect(evaluateCondition(cond("merchant_normalized", "contains", "coles"), tx())).toBe(true); + }); + it("contains — case-insensitive", () => { + expect(evaluateCondition(cond("merchant_normalized", "contains", "COLES"), tx())).toBe(true); + }); + it("contains — no match", () => { + expect(evaluateCondition(cond("merchant_normalized", "contains", "woolworths"), tx())).toBe(false); + }); + it("equals — exact match (case-insensitive)", () => { + expect(evaluateCondition(cond("merchant_normalized", "equals", "coles"), tx())).toBe(true); + }); + it("equals — no match", () => { + expect(evaluateCondition(cond("merchant_normalized", "equals", "cole"), tx())).toBe(false); + }); + it("starts_with — matches prefix", () => { + expect(evaluateCondition(cond("merchant_normalized", "starts_with", "col"), tx())).toBe(true); + }); + it("starts_with — no match", () => { + expect(evaluateCondition(cond("merchant_normalized", "starts_with", "oles"), tx())).toBe(false); + }); + it("not_equals — different value", () => { + expect(evaluateCondition(cond("merchant_normalized", "not_equals", "woolworths"), tx())).toBe(true); + }); + it("not_equals — same value", () => { + expect(evaluateCondition(cond("merchant_normalized", "not_equals", "coles"), tx())).toBe(false); + }); + it("empty merchant falls back to empty string", () => { + expect(evaluateCondition(cond("merchant_normalized", "contains", "coles"), tx({ effective_merchant: "" }))).toBe(false); + }); +}); + +describe("description", () => { + it("contains — matches", () => { + expect(evaluateCondition(cond("description", "contains", "wyndham"), tx())).toBe(true); + }); + it("equals — exact (case-insensitive)", () => { + expect(evaluateCondition(cond("description", "equals", "coles wyndham vale"), tx())).toBe(true); + }); + it("starts_with", () => { + expect(evaluateCondition(cond("description", "starts_with", "coles"), tx())).toBe(true); + }); +}); + +describe("category", () => { + it("equals category", () => { + expect(evaluateCondition(cond("category", "equals", "groceries"), tx())).toBe(true); + }); + it("not_equals different category", () => { + expect(evaluateCondition(cond("category", "not_equals", "dining"), tx())).toBe(true); + }); + it("contains partial", () => { + expect(evaluateCondition(cond("category", "contains", "grocer"), tx())).toBe(true); + }); +}); + +describe("bank_name", () => { + it("equals bank", () => { + expect(evaluateCondition(cond("bank_name", "equals", "anz"), tx())).toBe(true); + }); + it("not_equals different bank", () => { + expect(evaluateCondition(cond("bank_name", "not_equals", "nab"), tx())).toBe(true); + }); +}); + +describe("transaction_type", () => { + it("equals debit", () => { + expect(evaluateCondition(cond("transaction_type", "equals", "debit"), tx())).toBe(true); + }); + it("not_equals credit", () => { + expect(evaluateCondition(cond("transaction_type", "not_equals", "credit"), tx())).toBe(true); + }); + it("equals credit — no match on debit tx", () => { + expect(evaluateCondition(cond("transaction_type", "equals", "credit"), tx())).toBe(false); + }); +}); + +// ── Amount field ────────────────────────────────────────────────────────────── + +describe("amount", () => { + it("equals exact amount", () => { + expect(evaluateCondition(cond("amount", "equals", "42.5"), tx())).toBe(true); + }); + it("equals wrong amount", () => { + expect(evaluateCondition(cond("amount", "equals", "42"), tx())).toBe(false); + }); + it("not_equals different amount", () => { + expect(evaluateCondition(cond("amount", "not_equals", "100"), tx())).toBe(true); + }); + it("gt — amount is greater", () => { + expect(evaluateCondition(cond("amount", "gt", "40"), tx())).toBe(true); + }); + it("gt — amount is equal (not strictly greater)", () => { + expect(evaluateCondition(cond("amount", "gt", "42.5"), tx())).toBe(false); + }); + it("gt — amount is less", () => { + expect(evaluateCondition(cond("amount", "gt", "50"), tx())).toBe(false); + }); + it("lt — amount is less", () => { + expect(evaluateCondition(cond("amount", "lt", "50"), tx())).toBe(true); + }); + it("lt — amount is equal (not strictly less)", () => { + expect(evaluateCondition(cond("amount", "lt", "42.5"), tx())).toBe(false); + }); + it("lt — amount is greater", () => { + expect(evaluateCondition(cond("amount", "lt", "40"), tx())).toBe(false); + }); + it("unsupported operator (contains) returns false", () => { + expect(evaluateCondition(cond("amount", "contains", "42"), tx())).toBe(false); + }); +}); + +// ── Tag field ──────────────────────────────────────────────────────────────── + +describe("tag", () => { + it("equals — tag present", () => { + expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [{ id: 5 }] }))).toBe(true); + }); + it("equals — tag absent", () => { + expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [] }))).toBe(false); + }); + it("equals — different tag", () => { + expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [{ id: 7 }] }))).toBe(false); + }); + it("not_equals — tag absent", () => { + expect(evaluateCondition(cond("tag", "not_equals", "5"), tx({ tags: [] }))).toBe(true); + }); + it("not_equals — tag present", () => { + expect(evaluateCondition(cond("tag", "not_equals", "5"), tx({ tags: [{ id: 5 }] }))).toBe(false); + }); + it("matches one of multiple tags", () => { + expect(evaluateCondition(cond("tag", "equals", "3"), tx({ tags: [{ id: 1 }, { id: 3 }] }))).toBe(true); + }); +}); + +// ── Unknown field ───────────────────────────────────────────────────────────── + +describe("unknown field", () => { + it("returns false for unrecognised field", () => { + // @ts-expect-error intentional invalid field for regression guard + expect(evaluateCondition({ field: "nonexistent", operator: "equals", value: "x" }, tx())).toBe(false); + }); +}); diff --git a/src/app/api/rules/apply/route.ts b/src/app/api/rules/apply/route.ts index 89ba147..baad0c1 100644 --- a/src/app/api/rules/apply/route.ts +++ b/src/app/api/rules/apply/route.ts @@ -2,24 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; import { getTransactions } from "@/lib/queries"; - -interface Condition { - field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount" | "transaction_type" | "tag"; - operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals"; - value: string; -} - -interface SplitEntry { - participant_id: number; - share_percent: number; -} - -interface Actions { - set_category?: string; - add_tag_ids?: number[]; - set_merchant?: string; - apply_split?: SplitEntry[]; -} +import { evaluateCondition, type Condition, type Actions } from "@/lib/rules"; interface SnapshotEntry { transaction_id: number; @@ -30,55 +13,6 @@ interface SnapshotEntry { prev_splits: { participant_id: number; share_percent: number; settled: boolean }[]; } -interface TxFields { - effective_category: string; - effective_merchant: string; - description: string; - bank_name: string; - amount: number; - transaction_type: string; - tags: { id: number }[]; -} - -function evaluateCondition(cond: Condition, tx: TxFields): boolean { - if (cond.field === "amount") { - const numVal = Number(tx.amount); - const numCond = Number(cond.value); - switch (cond.operator) { - case "equals": return numVal === numCond; - case "not_equals": return numVal !== numCond; - case "gt": return numVal > numCond; - case "lt": return numVal < numCond; - default: return false; - } - } - - if (cond.field === "tag") { - const tagId = Number(cond.value); - const hasTag = tx.tags.some((t) => t.id === tagId); - return cond.operator === "not_equals" ? !hasTag : hasTag; - } - - let fieldVal: string; - switch (cond.field) { - case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break; - case "description": fieldVal = tx.description || ""; break; - case "category": fieldVal = tx.effective_category || ""; break; - case "bank_name": fieldVal = tx.bank_name || ""; break; - case "transaction_type": fieldVal = tx.transaction_type || ""; break; - default: return false; - } - - const strVal = fieldVal.toLowerCase(); - const strCond = cond.value.toLowerCase(); - switch (cond.operator) { - case "contains": return strVal.includes(strCond); - case "equals": return strVal === strCond; - case "starts_with": return strVal.startsWith(strCond); - case "not_equals": return strVal !== strCond; - default: return false; - } -} export async function GET(req: NextRequest) { const user = await getCurrentUser(req); diff --git a/src/lib/rules.ts b/src/lib/rules.ts new file mode 100644 index 0000000..8d58b5c --- /dev/null +++ b/src/lib/rules.ts @@ -0,0 +1,74 @@ +export interface Condition { + field: + | "merchant_normalized" + | "description" + | "category" + | "bank_name" + | "amount" + | "transaction_type" + | "tag"; + operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals"; + value: string; +} + +export interface SplitEntry { + participant_id: number; + share_percent: number; +} + +export interface Actions { + set_category?: string; + add_tag_ids?: number[]; + set_merchant?: string; + apply_split?: SplitEntry[]; +} + +export interface TxFields { + effective_category: string; + effective_merchant: string; + description: string; + bank_name: string; + amount: number; + transaction_type: string; + tags: { id: number }[]; +} + +export function evaluateCondition(cond: Condition, tx: TxFields): boolean { + if (cond.field === "amount") { + const numVal = Number(tx.amount); + const numCond = Number(cond.value); + switch (cond.operator) { + case "equals": return numVal === numCond; + case "not_equals": return numVal !== numCond; + case "gt": return numVal > numCond; + case "lt": return numVal < numCond; + default: return false; + } + } + + if (cond.field === "tag") { + const tagId = Number(cond.value); + const hasTag = tx.tags.some((t) => t.id === tagId); + return cond.operator === "not_equals" ? !hasTag : hasTag; + } + + let fieldVal: string; + switch (cond.field) { + case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break; + case "description": fieldVal = tx.description || ""; break; + case "category": fieldVal = tx.effective_category || ""; break; + case "bank_name": fieldVal = tx.bank_name || ""; break; + case "transaction_type": fieldVal = tx.transaction_type || ""; break; + default: return false; + } + + const strVal = fieldVal.toLowerCase(); + const strCond = cond.value.toLowerCase(); + switch (cond.operator) { + case "contains": return strVal.includes(strCond); + case "equals": return strVal === strCond; + case "starts_with": return strVal.startsWith(strCond); + case "not_equals": return strVal !== strCond; + default: return false; + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..32e9177 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: "node", + include: ["src/__tests__/unit/**/*.test.ts"], + }, +}); diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 0000000..2f38feb --- /dev/null +++ b/vitest.integration.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; +import { readFileSync, existsSync } from "fs"; +import { resolve } from "path"; + +// Load .env.test into process.env HERE, at config-load time. +// Forked workers inherit the parent process env, so Prisma's singleton +// will see the test DATABASE_URL when db.ts is first imported. +const envTestPath = resolve(__dirname, ".env.test"); +if (existsSync(envTestPath)) { + for (const line of readFileSync(envTestPath, "utf-8").split("\n")) { + const m = line.match(/^([^#=]+)=(.*)/); + if (m) process.env[m[1].trim()] = m[2].trim(); + } +} + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: "node", + include: ["src/__tests__/integration/**/*.test.ts"], + pool: "forks", + }, +});