diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml
new file mode 100644
index 0000000..4c02201
--- /dev/null
+++ b/.github/workflows/migrate.yml
@@ -0,0 +1,47 @@
+name: Run DB Migrations
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+jobs:
+ migrate:
+ name: Deploy migrations
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ 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: Wait for Vercel deployment
+ uses: patrickedqvist/wait-for-vercel-preview@v1.3.2
+ id: wait-for-vercel
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ max_timeout: 300
+ env:
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+
+ - name: Run migrations (production)
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+ run: npm run db:migrate:deploy
+ env:
+ DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
+
+ - name: Run migrations (preview)
+ if: github.event_name == 'pull_request'
+ run: npm run db:migrate:deploy
+ env:
+ DATABASE_URL: ${{ secrets.PREVIEW_DATABASE_URL }}
diff --git a/package-lock.json b/package-lock.json
index 168941a..cd1f114 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,18 +29,39 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@vitejs/plugin-react": "^5.1.4",
+ "@vitest/coverage-v8": "^4.0.18",
"dotenv": "^17.3.1",
"eslint": "^9",
"eslint-config-next": "16.1.6",
+ "jsdom": "^28.1.0",
"tailwindcss": "^4",
"tsx": "^4.21.0",
- "typescript": "^5"
+ "typescript": "^5",
+ "vitest": "^4.0.18"
}
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.31",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
+ "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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",
@@ -54,6 +75,64 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
+ "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.1.1",
+ "@csstools/css-color-parser": "^4.0.2",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.2.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "6.8.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
+ "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.1.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.6"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@auth/core": {
"version": "0.41.1",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz",
@@ -227,11 +306,21 @@
"@babel/core": "^7.0.0"
}
},
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"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"
@@ -241,7 +330,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"
@@ -275,7 +364,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"
@@ -287,6 +376,48 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -325,7 +456,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",
@@ -335,6 +466,29 @@
"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/@chevrotain/cst-dts-gen": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz",
@@ -368,6 +522,138 @@
"integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==",
"license": "Apache-2.0"
},
+ "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.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
+ "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
+ "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.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
+ "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
+ "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.1.1"
+ },
+ "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.0.28",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz",
+ "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0"
+ },
+ "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/@electric-sql/pglite": {
"version": "0.3.15",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz",
@@ -1014,6 +1300,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz",
+ "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==",
+ "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/@hono/node-server": {
"version": "1.19.9",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
@@ -1994,72 +2298,31 @@
"react-dom": "^18.0.0 || ^19.0.0"
}
},
- "node_modules/@rtsao/scc": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
- "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
"dev": true,
"license": "MIT"
},
- "node_modules/@standard-schema/spec": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
- "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
- "license": "MIT"
- },
- "node_modules/@swc/helpers": {
- "version": "0.5.15",
- "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
- "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.8.0"
- }
- },
- "node_modules/@tailwindcss/node": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
- "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/remapping": "^2.3.4",
- "enhanced-resolve": "^5.18.3",
- "jiti": "^2.6.1",
- "lightningcss": "1.30.2",
- "magic-string": "^0.30.21",
- "source-map-js": "^1.2.1",
- "tailwindcss": "4.1.18"
- }
- },
- "node_modules/@tailwindcss/oxide": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
- "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+ "cpu": [
+ "arm"
+ ],
"dev": true,
"license": "MIT",
- "engines": {
- "node": ">= 10"
- },
- "optionalDependencies": {
- "@tailwindcss/oxide-android-arm64": "4.1.18",
- "@tailwindcss/oxide-darwin-arm64": "4.1.18",
- "@tailwindcss/oxide-darwin-x64": "4.1.18",
- "@tailwindcss/oxide-freebsd-x64": "4.1.18",
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
- "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
- "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
- "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
- "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
- "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
- "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
- "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
- }
+ "optional": true,
+ "os": [
+ "android"
+ ]
},
- "node_modules/@tailwindcss/oxide-android-arm64": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
- "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -2068,9 +2331,407 @@
"optional": true,
"os": [
"android"
- ],
- "engines": {
- "node": ">= 10"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rtsao/scc": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
+ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
+ "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "enhanced-resolve": "^5.18.3",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.30.2",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
+ "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-x64": "4.1.18",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.18",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
+ "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
@@ -2287,6 +2948,107 @@
"tailwindcss": "4.1.18"
}
},
+ "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/dom/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",
+ "peer": true,
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "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/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -2298,6 +3060,70 @@
"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",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "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/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -2307,6 +3133,13 @@
"@types/ms": "*"
}
},
+ "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",
@@ -2989,6 +3822,181 @@
}
}
},
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.1.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
+ "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.18.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
+ "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.0.18",
+ "ast-v8-to-istanbul": "^0.3.10",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.1",
+ "obug": "^2.1.1",
+ "std-env": "^3.10.0",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.0.18",
+ "vitest": "4.0.18"
+ },
+ "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/expect": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
+ "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "chai": "^6.2.1",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
+ "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.0.18",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
+ "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
+ "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.0.18",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
+ "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
+ "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
+ "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -3012,6 +4020,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -3029,6 +4047,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",
@@ -3222,6 +4251,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",
@@ -3229,6 +4268,25 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz",
+ "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==",
+ "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",
@@ -3310,6 +4368,16 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -3498,6 +4566,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "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",
@@ -3693,6 +4771,53 @@
"node": ">= 8"
}
},
+ "node_modules/css-tree": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
+ "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.12.2",
+ "source-map-js": "^1.0.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.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/cssstyle": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz",
+ "integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.0.0",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.28",
+ "css-tree": "^3.1.0",
+ "lru-cache": "^11.2.6"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cssstyle/node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -3706,6 +4831,20 @@
"dev": true,
"license": "BSD-2-Clause"
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -3777,6 +4916,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/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -3908,6 +5054,14 @@
"node": ">=0.10.0"
}
},
+ "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/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
@@ -4112,6 +5266,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -4661,6 +5822,16 @@
"url": "https://opencollective.com/unified"
}
},
+ "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",
@@ -4671,6 +5842,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
@@ -5383,6 +6564,26 @@
"node": ">=16.9.0"
}
},
+ "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/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -5403,12 +6604,40 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/http-status-codes": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
"license": "MIT"
},
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -5462,6 +6691,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/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -5809,6 +7048,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "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-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
@@ -5973,6 +7219,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",
@@ -6029,6 +7314,60 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "28.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
+ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.31",
+ "@asamuzakjp/dom-selector": "^6.8.1",
+ "@bramus/specificity": "^2.4.2",
+ "@exodus/bytes": "^1.11.0",
+ "cssstyle": "^6.0.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "parse5": "^8.0.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.0",
+ "undici": "^7.21.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/parse5": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
+ "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -6498,6 +7837,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",
@@ -6508,6 +7858,35 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-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/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -6810,6 +8189,13 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
+ "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -7397,6 +8783,16 @@
"node": ">=8.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.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -7804,6 +9200,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",
@@ -8214,6 +9621,44 @@
"node": ">= 0.8.0"
}
},
+ "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": "7.4.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.4.0.tgz",
@@ -8398,6 +9843,16 @@
"react": ">=18"
}
},
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -8411,6 +9866,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/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -8561,8 +10030,18 @@
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
"integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==",
"license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/remeda"
+ "funding": {
+ "url": "https://github.com/sponsors/remeda"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
}
},
"node_modules/resolve": {
@@ -8626,6 +10105,51 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rollup": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "fsevents": "~2.3.2"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -8711,6 +10235,19 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "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",
@@ -8936,6 +10473,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",
@@ -8992,6 +10536,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",
@@ -9149,6 +10700,19 @@
"node": ">=4"
}
},
+ "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",
@@ -9229,6 +10793,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -9258,6 +10829,13 @@
"url": "https://opencollective.com/webpack"
}
},
+ "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",
@@ -9315,6 +10893,36 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
+ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.0.23",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
+ "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.0.23"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.0.23",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz",
+ "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -9328,6 +10936,32 @@
"node": ">=8.0"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
+ "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -9561,6 +11195,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/undici": {
+ "version": "7.22.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
+ "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
+ "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",
@@ -9787,6 +11431,216 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "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",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.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
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "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/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "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==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
+ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.0.18",
+ "@vitest/mocker": "4.0.18",
+ "@vitest/pretty-format": "4.0.18",
+ "@vitest/runner": "4.0.18",
+ "@vitest/snapshot": "4.0.18",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "es-module-lexer": "^1.7.0",
+ "expect-type": "^1.2.2",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^3.10.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3",
+ "vite": "^6.0.0 || ^7.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.0.18",
+ "@vitest/browser-preview": "4.0.18",
+ "@vitest/browser-webdriverio": "4.0.18",
+ "@vitest/ui": "4.0.18",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "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/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
@@ -9797,6 +11651,41 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -9901,6 +11790,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",
@@ -9911,6 +11817,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/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/package.json b/package.json
index 24847f3..173eee7 100644
--- a/package.json
+++ b/package.json
@@ -4,13 +4,18 @@
"private": true,
"scripts": {
"dev": "next dev",
- "build": "npx prisma migrate deploy && npx prisma generate && next build",
+ "build": "npx prisma generate && next build",
+ "db:migrate:deploy": "npx prisma migrate deploy",
"start": "next start",
"lint": "eslint",
"db:migrate": "npx prisma migrate dev",
"db:push": "npx prisma db push",
"db:seed": "npx tsx prisma/seed.ts",
"db:studio": "npx prisma studio",
+ "test": "vitest",
+ "test:ui": "vitest --ui",
+ "test:run": "vitest run",
+ "test:coverage": "vitest run --coverage",
"docker:up": "docker compose up --build",
"docker:down": "docker compose down",
"docker:migrate": "docker compose exec app npx prisma migrate dev",
@@ -42,15 +47,22 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@vitejs/plugin-react": "^5.1.4",
+ "@vitest/coverage-v8": "^4.0.18",
"dotenv": "^17.3.1",
"eslint": "^9",
"eslint-config-next": "16.1.6",
+ "jsdom": "^28.1.0",
"tailwindcss": "^4",
"tsx": "^4.21.0",
- "typescript": "^5"
+ "typescript": "^5",
+ "vitest": "^4.0.18"
}
}
diff --git a/src/app/api/admin/categories/[id]/route.test.ts b/src/app/api/admin/categories/[id]/route.test.ts
new file mode 100644
index 0000000..dc60351
--- /dev/null
+++ b/src/app/api/admin/categories/[id]/route.test.ts
@@ -0,0 +1,67 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+vi.mock("@/lib/utils", () => ({
+ slugify: (text: string) => text.toLowerCase().replace(/\s+/g, "-"),
+}));
+
+const { PUT, DELETE } = await import("./route");
+
+const makeParams = (id: string) => ({ params: Promise.resolve({ id }) });
+
+describe("PUT /api/admin/categories/[id]", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("updates a category", async () => {
+ const updated = { id: "cat-1", name: "New Name", slug: "new-name" };
+ mockPrisma.category.update.mockResolvedValue(updated);
+
+ const req = new Request("http://localhost", {
+ method: "PUT",
+ body: JSON.stringify({ name: "New Name" }),
+ });
+ const res = await PUT(req, makeParams("cat-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual(updated);
+ expect(mockPrisma.category.update).toHaveBeenCalledWith({
+ where: { id: "cat-1" },
+ data: { name: "New Name", slug: "new-name", description: null, displayOrder: 0 },
+ });
+ });
+});
+
+describe("DELETE /api/admin/categories/[id]", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("deletes a category with no resources", async () => {
+ mockPrisma.resource.count.mockResolvedValue(0);
+ mockPrisma.category.delete.mockResolvedValue({ id: "cat-1" });
+
+ const req = new Request("http://localhost", { method: "DELETE" });
+ const res = await DELETE(req, makeParams("cat-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual({ success: true });
+ expect(mockPrisma.category.delete).toHaveBeenCalledWith({ where: { id: "cat-1" } });
+ });
+
+ it("returns 400 when category has resources", async () => {
+ mockPrisma.resource.count.mockResolvedValue(3);
+
+ const req = new Request("http://localhost", { method: "DELETE" });
+ const res = await DELETE(req, makeParams("cat-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(400);
+ expect(data.error).toMatch(/3/);
+ expect(mockPrisma.category.delete).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/admin/categories/list/route.test.ts b/src/app/api/admin/categories/list/route.test.ts
new file mode 100644
index 0000000..efafb5a
--- /dev/null
+++ b/src/app/api/admin/categories/list/route.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+
+const { GET } = await import("./route");
+
+describe("GET /api/admin/categories/list", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns categories ordered by displayOrder", async () => {
+ const categories = [
+ { id: "cat-1", name: "Alpha", displayOrder: 1, _count: { resources: 3 } },
+ { id: "cat-2", name: "Beta", displayOrder: 2, _count: { resources: 0 } },
+ ];
+ mockPrisma.category.findMany.mockResolvedValue(categories);
+
+ const res = await GET();
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual(categories);
+ expect(mockPrisma.category.findMany).toHaveBeenCalledWith({
+ orderBy: { displayOrder: "asc" },
+ include: { _count: { select: { resources: true } } },
+ });
+ });
+
+ it("returns empty array when no categories exist", async () => {
+ mockPrisma.category.findMany.mockResolvedValue([]);
+
+ const res = await GET();
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual([]);
+ });
+});
diff --git a/src/app/api/admin/categories/route.test.ts b/src/app/api/admin/categories/route.test.ts
new file mode 100644
index 0000000..d706f78
--- /dev/null
+++ b/src/app/api/admin/categories/route.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+vi.mock("@/lib/utils", () => ({
+ slugify: (text: string) => text.toLowerCase().replace(/\s+/g, "-"),
+}));
+
+const { POST } = await import("./route");
+
+describe("POST /api/admin/categories", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("creates a category and auto-generates slug from name", async () => {
+ const created = {
+ id: "cat-1",
+ name: "Platform Tools",
+ slug: "platform-tools",
+ description: null,
+ displayOrder: 0,
+ };
+ mockPrisma.category.create.mockResolvedValue(created);
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({ name: "Platform Tools" }),
+ });
+ const res = await POST(req);
+ const data = await res.json();
+
+ expect(res.status).toBe(201);
+ expect(data).toEqual(created);
+ expect(mockPrisma.category.create).toHaveBeenCalledWith({
+ data: {
+ name: "Platform Tools",
+ slug: "platform-tools",
+ description: null,
+ displayOrder: 0,
+ },
+ });
+ });
+
+ it("stores description when provided", async () => {
+ mockPrisma.category.create.mockResolvedValue({ id: "cat-2" });
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({ name: "Tools", description: "Useful tools" }),
+ });
+ await POST(req);
+
+ expect(mockPrisma.category.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({ description: "Useful tools" }),
+ });
+ });
+
+ it("uses provided displayOrder", async () => {
+ mockPrisma.category.create.mockResolvedValue({ id: "cat-3" });
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({ name: "Z Category", displayOrder: 5 }),
+ });
+ await POST(req);
+
+ expect(mockPrisma.category.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({ displayOrder: 5 }),
+ });
+ });
+});
diff --git a/src/app/api/admin/comments/[id]/route.test.ts b/src/app/api/admin/comments/[id]/route.test.ts
new file mode 100644
index 0000000..23b3245
--- /dev/null
+++ b/src/app/api/admin/comments/[id]/route.test.ts
@@ -0,0 +1,26 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+
+const { DELETE } = await import("./route");
+
+const makeParams = (id: string) => ({ params: Promise.resolve({ id }) });
+
+describe("DELETE /api/admin/comments/[id]", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("deletes any comment and returns success", async () => {
+ mockPrisma.comment.delete.mockResolvedValue({ id: "c-1" });
+
+ const req = new Request("http://localhost", { method: "DELETE" });
+ const res = await DELETE(req, makeParams("c-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual({ success: true });
+ expect(mockPrisma.comment.delete).toHaveBeenCalledWith({ where: { id: "c-1" } });
+ });
+});
diff --git a/src/app/api/admin/comments/list/route.test.ts b/src/app/api/admin/comments/list/route.test.ts
new file mode 100644
index 0000000..dbf0239
--- /dev/null
+++ b/src/app/api/admin/comments/list/route.test.ts
@@ -0,0 +1,70 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+
+const { GET } = await import("./route");
+
+describe("GET /api/admin/comments/list", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns all comments ordered by createdAt desc", async () => {
+ const mockComments = [
+ {
+ id: "c-1",
+ body: "Great resource!",
+ createdAt: new Date("2024-02-01"),
+ user: { name: "Alice", email: "alice@example.com" },
+ resource: {
+ title: "Platform Engineering Guide",
+ slug: "platform-engineering-guide",
+ category: { slug: "guides" },
+ },
+ },
+ {
+ id: "c-2",
+ body: "Very helpful.",
+ createdAt: new Date("2024-01-15"),
+ user: { name: "Bob", email: "bob@example.com" },
+ resource: {
+ title: "Team Topologies",
+ slug: "team-topologies",
+ category: { slug: "books" },
+ },
+ },
+ ];
+
+ mockPrisma.comment.findMany.mockResolvedValue(mockComments);
+
+ const res = await GET();
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual(JSON.parse(JSON.stringify(mockComments)));
+ expect(mockPrisma.comment.findMany).toHaveBeenCalledWith({
+ orderBy: { createdAt: "desc" },
+ include: {
+ user: { select: { name: true, email: true } },
+ resource: {
+ select: {
+ title: true,
+ slug: true,
+ category: { select: { slug: true } },
+ },
+ },
+ },
+ });
+ });
+
+ it("returns an empty array when there are no comments", async () => {
+ mockPrisma.comment.findMany.mockResolvedValue([]);
+
+ const res = await GET();
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual([]);
+ });
+});
diff --git a/src/app/api/admin/resources/[id]/route.test.ts b/src/app/api/admin/resources/[id]/route.test.ts
new file mode 100644
index 0000000..bd6d4ea
--- /dev/null
+++ b/src/app/api/admin/resources/[id]/route.test.ts
@@ -0,0 +1,88 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+vi.mock("@/lib/utils", () => ({
+ slugify: (text: string) => text.toLowerCase().replace(/\s+/g, "-"),
+}));
+
+const { PUT, DELETE } = await import("./route");
+
+const makeParams = (id: string) => ({ params: Promise.resolve({ id }) });
+
+describe("PUT /api/admin/resources/[id]", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("updates a resource and returns it", async () => {
+ const updated = {
+ id: "res-1",
+ title: "Updated Title",
+ slug: "updated-title",
+ category: {},
+ tags: [],
+ authors: [],
+ };
+ mockPrisma.resource.update.mockResolvedValue(updated);
+
+ const req = new Request("http://localhost", {
+ method: "PUT",
+ body: JSON.stringify({ title: "Updated Title", description: "D", categoryId: "c1" }),
+ });
+ const res = await PUT(req, makeParams("res-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual(updated);
+ expect(mockPrisma.resource.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: "res-1" },
+ data: expect.objectContaining({ title: "Updated Title", slug: "updated-title" }),
+ })
+ );
+ });
+
+ it("resets tags and authors to the provided sets", async () => {
+ mockPrisma.resource.update.mockResolvedValue({ id: "res-1" });
+
+ const req = new Request("http://localhost", {
+ method: "PUT",
+ body: JSON.stringify({
+ title: "T",
+ description: "D",
+ categoryId: "c1",
+ tagIds: ["tag-1"],
+ authorIds: ["author-1"],
+ }),
+ });
+ await PUT(req, makeParams("res-1"));
+
+ expect(mockPrisma.resource.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ tags: { set: [{ id: "tag-1" }] },
+ authors: { set: [{ id: "author-1" }] },
+ }),
+ })
+ );
+ });
+});
+
+describe("DELETE /api/admin/resources/[id]", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("deletes a resource and returns success", async () => {
+ mockPrisma.resource.delete.mockResolvedValue({ id: "res-1" });
+
+ const req = new Request("http://localhost", { method: "DELETE" });
+ const res = await DELETE(req, makeParams("res-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual({ success: true });
+ expect(mockPrisma.resource.delete).toHaveBeenCalledWith({ where: { id: "res-1" } });
+ });
+});
diff --git a/src/app/api/admin/resources/route.test.ts b/src/app/api/admin/resources/route.test.ts
new file mode 100644
index 0000000..786daef
--- /dev/null
+++ b/src/app/api/admin/resources/route.test.ts
@@ -0,0 +1,114 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+vi.mock("@/lib/utils", () => ({
+ slugify: (text: string) => text.toLowerCase().replace(/\s+/g, "-"),
+}));
+
+const { POST } = await import("./route");
+
+describe("POST /api/admin/resources", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("creates a resource with required fields", async () => {
+ const created = {
+ id: "res-1",
+ title: "My Resource",
+ slug: "my-resource",
+ description: "A desc",
+ body: "",
+ status: "DRAFT",
+ category: { id: "cat-1", name: "Tools" },
+ tags: [],
+ authors: [],
+ };
+ mockPrisma.resource.create.mockResolvedValue(created);
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({
+ title: "My Resource",
+ description: "A desc",
+ categoryId: "cat-1",
+ }),
+ });
+ const res = await POST(req);
+ const data = await res.json();
+
+ expect(res.status).toBe(201);
+ expect(data).toEqual(created);
+ expect(mockPrisma.resource.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ title: "My Resource",
+ slug: "my-resource",
+ categoryId: "cat-1",
+ status: "DRAFT",
+ }),
+ })
+ );
+ });
+
+ it("defaults status to DRAFT when not provided", async () => {
+ mockPrisma.resource.create.mockResolvedValue({ id: "res-1" });
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({ title: "T", description: "D", categoryId: "c1" }),
+ });
+ await POST(req);
+
+ expect(mockPrisma.resource.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ status: "DRAFT" }),
+ })
+ );
+ });
+
+ it("uses provided status when given", async () => {
+ mockPrisma.resource.create.mockResolvedValue({ id: "res-1" });
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({
+ title: "T",
+ description: "D",
+ categoryId: "c1",
+ status: "PUBLISHED",
+ }),
+ });
+ await POST(req);
+
+ expect(mockPrisma.resource.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ status: "PUBLISHED" }),
+ })
+ );
+ });
+
+ it("connects tags when tagIds are provided", async () => {
+ mockPrisma.resource.create.mockResolvedValue({ id: "res-1" });
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({
+ title: "T",
+ description: "D",
+ categoryId: "c1",
+ tagIds: ["tag-1", "tag-2"],
+ }),
+ });
+ await POST(req);
+
+ expect(mockPrisma.resource.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ tags: { connect: [{ id: "tag-1" }, { id: "tag-2" }] },
+ }),
+ })
+ );
+ });
+});
diff --git a/src/app/api/admin/submissions/[id]/review/route.test.ts b/src/app/api/admin/submissions/[id]/review/route.test.ts
new file mode 100644
index 0000000..7552d57
--- /dev/null
+++ b/src/app/api/admin/submissions/[id]/review/route.test.ts
@@ -0,0 +1,105 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+import { mockAuth, createMockSession } from "@/test/mocks/auth";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+vi.mock("@/lib/auth", () => ({ auth: mockAuth }));
+vi.mock("@/lib/utils", () => ({
+ slugify: (text: string) => text.toLowerCase().replace(/\s+/g, "-"),
+}));
+
+const { PUT } = await import("./route");
+
+const makeParams = (id: string) => ({ params: Promise.resolve({ id }) });
+
+describe("PUT /api/admin/submissions/[id]/review", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("approves a submission and creates a draft resource when categoryId provided", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+ const updatedSubmission = {
+ id: "sub-1",
+ title: "Great Tool",
+ description: "A great tool",
+ body: null,
+ externalUrl: null,
+ status: "APPROVED",
+ };
+ mockPrisma.submission.update.mockResolvedValue(updatedSubmission);
+ mockPrisma.resource.create.mockResolvedValue({ id: "res-new" });
+
+ const req = new Request("http://localhost", {
+ method: "PUT",
+ body: JSON.stringify({ action: "approve", categoryId: "cat-1" }),
+ });
+ const res = await PUT(req, makeParams("sub-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual(updatedSubmission);
+ expect(mockPrisma.submission.update).toHaveBeenCalledWith({
+ where: { id: "sub-1" },
+ data: expect.objectContaining({ status: "APPROVED" }),
+ });
+ expect(mockPrisma.resource.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ title: "Great Tool",
+ slug: "great-tool",
+ status: "DRAFT",
+ categoryId: "cat-1",
+ }),
+ })
+ );
+ });
+
+ it("approves a submission but does not create resource when no categoryId", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+ mockPrisma.submission.update.mockResolvedValue({ id: "sub-1", status: "APPROVED" });
+
+ const req = new Request("http://localhost", {
+ method: "PUT",
+ body: JSON.stringify({ action: "approve" }),
+ });
+ await PUT(req, makeParams("sub-1"));
+
+ expect(mockPrisma.resource.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects a submission", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+ const updated = { id: "sub-1", status: "REJECTED", reviewNote: "Not relevant" };
+ mockPrisma.submission.update.mockResolvedValue(updated);
+
+ const req = new Request("http://localhost", {
+ method: "PUT",
+ body: JSON.stringify({ action: "reject", reviewNote: "Not relevant" }),
+ });
+ const res = await PUT(req, makeParams("sub-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual(updated);
+ expect(mockPrisma.submission.update).toHaveBeenCalledWith({
+ where: { id: "sub-1" },
+ data: expect.objectContaining({ status: "REJECTED", reviewNote: "Not relevant" }),
+ });
+ expect(mockPrisma.resource.create).not.toHaveBeenCalled();
+ });
+
+ it("returns 400 for an invalid action", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+
+ const req = new Request("http://localhost", {
+ method: "PUT",
+ body: JSON.stringify({ action: "publish" }),
+ });
+ const res = await PUT(req, makeParams("sub-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(400);
+ expect(data).toEqual({ error: "Invalid action" });
+ });
+});
diff --git a/src/app/api/admin/submissions/list/route.test.ts b/src/app/api/admin/submissions/list/route.test.ts
new file mode 100644
index 0000000..d1f5f2b
--- /dev/null
+++ b/src/app/api/admin/submissions/list/route.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+
+const { GET } = await import("./route");
+
+describe("GET /api/admin/submissions/list", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns all submissions with submitter and reviewer info", async () => {
+ const submissions = [
+ {
+ id: "sub-1",
+ title: "Cool Tool",
+ status: "PENDING",
+ submittedBy: { name: "Alice", email: "alice@example.com" },
+ reviewedBy: null,
+ },
+ ];
+ mockPrisma.submission.findMany.mockResolvedValue(submissions);
+
+ const res = await GET();
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual(submissions);
+ expect(mockPrisma.submission.findMany).toHaveBeenCalledWith({
+ orderBy: [{ status: "asc" }, { createdAt: "desc" }],
+ include: {
+ submittedBy: { select: { name: true, email: true } },
+ reviewedBy: { select: { name: true } },
+ },
+ });
+ });
+});
diff --git a/src/app/api/admin/tags/[id]/route.test.ts b/src/app/api/admin/tags/[id]/route.test.ts
new file mode 100644
index 0000000..74e7dd4
--- /dev/null
+++ b/src/app/api/admin/tags/[id]/route.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+vi.mock("@/lib/utils", () => ({
+ slugify: (text: string) => text.toLowerCase().replace(/\s+/g, "-"),
+}));
+
+const { PUT, DELETE } = await import("./route");
+
+const makeParams = (id: string) => ({ params: Promise.resolve({ id }) });
+
+describe("PUT /api/admin/tags/[id]", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("updates a tag", async () => {
+ const updated = { id: "tag-1", name: "Docker", slug: "docker" };
+ mockPrisma.tag.update.mockResolvedValue(updated);
+
+ const req = new Request("http://localhost", {
+ method: "PUT",
+ body: JSON.stringify({ name: "Docker" }),
+ });
+ const res = await PUT(req, makeParams("tag-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual(updated);
+ expect(mockPrisma.tag.update).toHaveBeenCalledWith({
+ where: { id: "tag-1" },
+ data: { name: "Docker", slug: "docker" },
+ });
+ });
+});
+
+describe("DELETE /api/admin/tags/[id]", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("deletes a tag and returns success", async () => {
+ mockPrisma.tag.delete.mockResolvedValue({ id: "tag-1" });
+
+ const req = new Request("http://localhost", { method: "DELETE" });
+ const res = await DELETE(req, makeParams("tag-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual({ success: true });
+ expect(mockPrisma.tag.delete).toHaveBeenCalledWith({ where: { id: "tag-1" } });
+ });
+});
diff --git a/src/app/api/admin/tags/list/route.test.ts b/src/app/api/admin/tags/list/route.test.ts
new file mode 100644
index 0000000..3ad009f
--- /dev/null
+++ b/src/app/api/admin/tags/list/route.test.ts
@@ -0,0 +1,30 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+
+const { GET } = await import("./route");
+
+describe("GET /api/admin/tags/list", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns tags ordered by name", async () => {
+ const tags = [
+ { id: "tag-1", name: "Docker", slug: "docker", _count: { resources: 5 } },
+ { id: "tag-2", name: "Kubernetes", slug: "kubernetes", _count: { resources: 8 } },
+ ];
+ mockPrisma.tag.findMany.mockResolvedValue(tags);
+
+ const res = await GET();
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual(tags);
+ expect(mockPrisma.tag.findMany).toHaveBeenCalledWith({
+ orderBy: { name: "asc" },
+ include: { _count: { select: { resources: true } } },
+ });
+ });
+});
diff --git a/src/app/api/admin/tags/route.test.ts b/src/app/api/admin/tags/route.test.ts
new file mode 100644
index 0000000..7d25737
--- /dev/null
+++ b/src/app/api/admin/tags/route.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+vi.mock("@/lib/utils", () => ({
+ slugify: (text: string) => text.toLowerCase().replace(/\s+/g, "-"),
+}));
+
+const { POST } = await import("./route");
+
+describe("POST /api/admin/tags", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("creates a tag and auto-generates slug", async () => {
+ const created = { id: "tag-1", name: "Kubernetes", slug: "kubernetes" };
+ mockPrisma.tag.create.mockResolvedValue(created);
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({ name: "Kubernetes" }),
+ });
+ const res = await POST(req);
+ const data = await res.json();
+
+ expect(res.status).toBe(201);
+ expect(data).toEqual(created);
+ expect(mockPrisma.tag.create).toHaveBeenCalledWith({
+ data: { name: "Kubernetes", slug: "kubernetes" },
+ });
+ });
+});
diff --git a/src/app/api/resources/[id]/comments/[commentId]/route.test.ts b/src/app/api/resources/[id]/comments/[commentId]/route.test.ts
new file mode 100644
index 0000000..2e3178c
--- /dev/null
+++ b/src/app/api/resources/[id]/comments/[commentId]/route.test.ts
@@ -0,0 +1,102 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+import { mockAuth, createMockSession, createAdminSession } from "@/test/mocks/auth";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+vi.mock("@/lib/auth", () => ({ auth: mockAuth }));
+
+const { DELETE } = await import("./route");
+
+const makeContext = (id: string, commentId: string) => ({
+ params: Promise.resolve({ id, commentId }),
+});
+
+describe("DELETE /api/resources/[id]/comments/[commentId]", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("allows comment owner to delete their comment", async () => {
+ const session = createMockSession();
+ mockAuth.mockResolvedValue(session);
+ mockPrisma.comment.findUnique.mockResolvedValue({
+ id: "c-1",
+ userId: "test-user-id",
+ body: "test",
+ });
+ mockPrisma.comment.delete.mockResolvedValue({ id: "c-1" });
+
+ const res = await DELETE(
+ new Request("http://localhost", { method: "DELETE" }),
+ makeContext("res-1", "c-1")
+ );
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual({ success: true });
+ expect(mockPrisma.comment.delete).toHaveBeenCalledWith({ where: { id: "c-1" } });
+ });
+
+ it("allows admin to delete any comment", async () => {
+ mockAuth.mockResolvedValue(createAdminSession());
+ mockPrisma.comment.findUnique.mockResolvedValue({
+ id: "c-1",
+ userId: "other-user-id",
+ body: "test",
+ });
+ mockPrisma.comment.delete.mockResolvedValue({ id: "c-1" });
+
+ const res = await DELETE(
+ new Request("http://localhost", { method: "DELETE" }),
+ makeContext("res-1", "c-1")
+ );
+
+ expect(res.status).toBe(200);
+ expect(mockPrisma.comment.delete).toHaveBeenCalled();
+ });
+
+ it("returns 403 when user is not owner and not admin", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+ mockPrisma.comment.findUnique.mockResolvedValue({
+ id: "c-1",
+ userId: "other-user-id",
+ body: "test",
+ });
+
+ const res = await DELETE(
+ new Request("http://localhost", { method: "DELETE" }),
+ makeContext("res-1", "c-1")
+ );
+ const data = await res.json();
+
+ expect(res.status).toBe(403);
+ expect(data).toEqual({ error: "Forbidden" });
+ expect(mockPrisma.comment.delete).not.toHaveBeenCalled();
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ mockAuth.mockResolvedValue(null);
+
+ const res = await DELETE(
+ new Request("http://localhost", { method: "DELETE" }),
+ makeContext("res-1", "c-1")
+ );
+
+ expect(res.status).toBe(401);
+ expect(mockPrisma.comment.delete).not.toHaveBeenCalled();
+ });
+
+ it("returns 404 when comment does not exist", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+ mockPrisma.comment.findUnique.mockResolvedValue(null);
+
+ const res = await DELETE(
+ new Request("http://localhost", { method: "DELETE" }),
+ makeContext("res-1", "no-such-comment")
+ );
+ const data = await res.json();
+
+ expect(res.status).toBe(404);
+ expect(data).toEqual({ error: "Comment not found" });
+ });
+});
diff --git a/src/app/api/resources/[id]/comments/route.test.ts b/src/app/api/resources/[id]/comments/route.test.ts
new file mode 100644
index 0000000..17102af
--- /dev/null
+++ b/src/app/api/resources/[id]/comments/route.test.ts
@@ -0,0 +1,140 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+import { mockAuth, createMockSession } from "@/test/mocks/auth";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+vi.mock("@/lib/auth", () => ({ auth: mockAuth }));
+
+const { GET, POST } = await import("./route");
+
+const makeContext = (id: string) => ({
+ params: Promise.resolve({ id }),
+});
+
+describe("GET /api/resources/[id]/comments", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns comments for a resource", async () => {
+ const mockComments = [
+ {
+ id: "comment-1",
+ body: "Great resource!",
+ createdAt: new Date().toISOString(),
+ user: { id: "user-1", name: "Alice", image: null },
+ },
+ ];
+ mockPrisma.comment.findMany.mockResolvedValue(mockComments);
+
+ const res = await GET(new Request("http://localhost"), makeContext("res-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual(mockComments);
+ expect(mockPrisma.comment.findMany).toHaveBeenCalledWith({
+ where: { resourceId: "res-1" },
+ orderBy: { createdAt: "desc" },
+ include: { user: { select: { id: true, name: true, image: true } } },
+ });
+ });
+
+ it("returns empty array when no comments", async () => {
+ mockPrisma.comment.findMany.mockResolvedValue([]);
+
+ const res = await GET(new Request("http://localhost"), makeContext("res-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual([]);
+ });
+});
+
+describe("POST /api/resources/[id]/comments", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("creates a comment when authenticated and resource exists", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+ mockPrisma.resource.findUnique.mockResolvedValue({ id: "res-1" });
+ const created = {
+ id: "c-new",
+ body: "Nice article",
+ createdAt: new Date().toISOString(),
+ user: { id: "test-user-id", name: "Test User", image: null },
+ };
+ mockPrisma.comment.create.mockResolvedValue(created);
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({ body: "Nice article" }),
+ });
+ const res = await POST(req, makeContext("res-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(201);
+ expect(data).toEqual(created);
+ expect(mockPrisma.comment.create).toHaveBeenCalledWith({
+ data: { body: "Nice article", userId: "test-user-id", resourceId: "res-1" },
+ include: { user: { select: { id: true, name: true, image: true } } },
+ });
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ mockAuth.mockResolvedValue(null);
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({ body: "Hello" }),
+ });
+ const res = await POST(req, makeContext("res-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(401);
+ expect(data).toEqual({ error: "Unauthorized" });
+ expect(mockPrisma.comment.create).not.toHaveBeenCalled();
+ });
+
+ it("returns 400 when body is missing", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({ body: "" }),
+ });
+ const res = await POST(req, makeContext("res-1"));
+ const data = await res.json();
+
+ expect(res.status).toBe(400);
+ expect(data).toHaveProperty("error");
+ expect(mockPrisma.comment.create).not.toHaveBeenCalled();
+ });
+
+ it("returns 400 when body is whitespace only", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({ body: " " }),
+ });
+ const res = await POST(req, makeContext("res-1"));
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 404 when resource does not exist", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+ mockPrisma.resource.findUnique.mockResolvedValue(null);
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({ body: "A comment" }),
+ });
+ const res = await POST(req, makeContext("non-existent"));
+ const data = await res.json();
+
+ expect(res.status).toBe(404);
+ expect(data).toEqual({ error: "Resource not found" });
+ });
+});
diff --git a/src/app/api/resources/[id]/like/route.test.ts b/src/app/api/resources/[id]/like/route.test.ts
new file mode 100644
index 0000000..d365274
--- /dev/null
+++ b/src/app/api/resources/[id]/like/route.test.ts
@@ -0,0 +1,106 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+import { mockAuth, createMockSession } from "@/test/mocks/auth";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+vi.mock("@/lib/auth", () => ({ auth: mockAuth }));
+
+const { POST } = await import("./route");
+
+const makeContext = (id: string) => ({
+ params: Promise.resolve({ id }),
+});
+
+describe("POST /api/resources/[id]/like", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("creates a like when user has not liked the resource yet", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+ mockPrisma.resource.findUnique.mockResolvedValue({ id: "res-1" });
+ mockPrisma.like.findUnique.mockResolvedValue(null);
+ mockPrisma.like.create.mockResolvedValue({ id: "like-1" });
+ mockPrisma.like.count.mockResolvedValue(5);
+
+ const res = await POST(
+ new Request("http://localhost", { method: "POST" }),
+ makeContext("res-1")
+ );
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual({ liked: true, count: 5 });
+ expect(mockPrisma.like.create).toHaveBeenCalledWith({
+ data: { userId: "test-user-id", resourceId: "res-1" },
+ });
+ });
+
+ it("removes a like when user has already liked the resource", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+ mockPrisma.resource.findUnique.mockResolvedValue({ id: "res-1" });
+ mockPrisma.like.findUnique.mockResolvedValue({ id: "like-1" });
+ mockPrisma.like.delete.mockResolvedValue({ id: "like-1" });
+ mockPrisma.like.count.mockResolvedValue(3);
+
+ const res = await POST(
+ new Request("http://localhost", { method: "POST" }),
+ makeContext("res-1")
+ );
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data).toEqual({ liked: false, count: 3 });
+ expect(mockPrisma.like.delete).toHaveBeenCalledWith({
+ where: { id: "like-1" },
+ });
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ mockAuth.mockResolvedValue(null);
+
+ const res = await POST(
+ new Request("http://localhost", { method: "POST" }),
+ makeContext("res-1")
+ );
+ const data = await res.json();
+
+ expect(res.status).toBe(401);
+ expect(data).toEqual({ error: "Unauthorized" });
+ expect(mockPrisma.like.create).not.toHaveBeenCalled();
+ });
+
+ it("returns 404 when resource does not exist", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+ mockPrisma.resource.findUnique.mockResolvedValue(null);
+
+ const res = await POST(
+ new Request("http://localhost", { method: "POST" }),
+ makeContext("non-existent")
+ );
+ const data = await res.json();
+
+ expect(res.status).toBe(404);
+ expect(data).toEqual({ error: "Resource not found" });
+ expect(mockPrisma.like.create).not.toHaveBeenCalled();
+ });
+
+ it("checks like using composite key for the current user and resource", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+ mockPrisma.resource.findUnique.mockResolvedValue({ id: "res-1" });
+ mockPrisma.like.findUnique.mockResolvedValue(null);
+ mockPrisma.like.create.mockResolvedValue({ id: "like-new" });
+ mockPrisma.like.count.mockResolvedValue(1);
+
+ await POST(
+ new Request("http://localhost", { method: "POST" }),
+ makeContext("res-1")
+ );
+
+ expect(mockPrisma.like.findUnique).toHaveBeenCalledWith({
+ where: {
+ userId_resourceId: { userId: "test-user-id", resourceId: "res-1" },
+ },
+ });
+ });
+});
diff --git a/src/app/api/submissions/route.test.ts b/src/app/api/submissions/route.test.ts
new file mode 100644
index 0000000..d72f614
--- /dev/null
+++ b/src/app/api/submissions/route.test.ts
@@ -0,0 +1,110 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { mockPrisma } from "@/test/mocks/prisma";
+import { mockAuth, createMockSession } from "@/test/mocks/auth";
+
+vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma }));
+vi.mock("@/lib/auth", () => ({ auth: mockAuth }));
+
+const { POST } = await import("./route");
+
+describe("POST /api/submissions", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("creates a submission when authenticated with required fields", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+ const created = {
+ id: "sub-1",
+ title: "My Tool",
+ description: "A helpful tool",
+ type: "Tool",
+ body: null,
+ externalUrl: null,
+ submittedById: "test-user-id",
+ };
+ mockPrisma.submission.create.mockResolvedValue(created);
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({ title: "My Tool", description: "A helpful tool", type: "Tool" }),
+ });
+ const res = await POST(req);
+ const data = await res.json();
+
+ expect(res.status).toBe(201);
+ expect(data).toEqual(created);
+ expect(mockPrisma.submission.create).toHaveBeenCalledWith({
+ data: {
+ title: "My Tool",
+ description: "A helpful tool",
+ body: null,
+ type: "Tool",
+ externalUrl: null,
+ submittedById: "test-user-id",
+ },
+ });
+ });
+
+ it("stores optional body and externalUrl when provided", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+ mockPrisma.submission.create.mockResolvedValue({ id: "sub-2" });
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({
+ title: "My Article",
+ description: "Desc",
+ type: "Article",
+ body: "## Content",
+ externalUrl: "https://example.com",
+ }),
+ });
+ await POST(req);
+
+ expect(mockPrisma.submission.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ body: "## Content",
+ externalUrl: "https://example.com",
+ }),
+ });
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ mockAuth.mockResolvedValue(null);
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({ title: "T", description: "D", type: "Tool" }),
+ });
+ const res = await POST(req);
+
+ expect(res.status).toBe(401);
+ expect(mockPrisma.submission.create).not.toHaveBeenCalled();
+ });
+
+ it("returns 400 when title is missing", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({ description: "D", type: "Tool" }),
+ });
+ const res = await POST(req);
+
+ expect(res.status).toBe(400);
+ expect(mockPrisma.submission.create).not.toHaveBeenCalled();
+ });
+
+ it("returns 400 when type is missing", async () => {
+ mockAuth.mockResolvedValue(createMockSession());
+
+ const req = new Request("http://localhost", {
+ method: "POST",
+ body: JSON.stringify({ title: "T", description: "D" }),
+ });
+ const res = await POST(req);
+
+ expect(res.status).toBe(400);
+ });
+});
diff --git a/src/components/comments/CommentSection.test.tsx b/src/components/comments/CommentSection.test.tsx
new file mode 100644
index 0000000..870a7c2
--- /dev/null
+++ b/src/components/comments/CommentSection.test.tsx
@@ -0,0 +1,377 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { render, screen, waitFor } from "@/test/helpers/render";
+import userEvent from "@testing-library/user-event";
+import CommentSection from "./CommentSection";
+
+const mockComment = {
+ id: "c-1",
+ body: "This is a great resource!",
+ createdAt: new Date("2024-03-15").toISOString(),
+ user: { id: "user-1", name: "Alice", image: null },
+};
+
+describe("CommentSection", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ global.fetch = vi.fn();
+ });
+
+ it("shows a loading state initially", () => {
+ vi.mocked(global.fetch).mockReturnValueOnce(new Promise(() => {}));
+
+ render(
+
+ );
+
+ expect(screen.getByText("Loading comments...")).toBeInTheDocument();
+ });
+
+ it("fetches and displays comments on mount", async () => {
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [mockComment],
+ } as Response);
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("This is a great resource!")).toBeInTheDocument();
+ expect(screen.getByText("Alice")).toBeInTheDocument();
+ });
+ });
+
+ it("fetches comments from the correct API endpoint", async () => {
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [],
+ } as Response);
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalledWith("/api/resources/res-42/comments");
+ });
+ });
+
+ it("shows an empty state message when there are no comments", async () => {
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [],
+ } as Response);
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/No comments yet/)).toBeInTheDocument();
+ });
+ });
+
+ it("shows the comment form when the user is authenticated", async () => {
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [],
+ } as Response);
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText("Add a comment...")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Post Comment" })).toBeInTheDocument();
+ });
+ });
+
+ it("shows a sign-in prompt instead of the form when unauthenticated", async () => {
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [],
+ } as Response);
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole("link", { name: "Sign in" })).toBeInTheDocument();
+ expect(screen.queryByPlaceholderText("Add a comment...")).not.toBeInTheDocument();
+ });
+ });
+
+ it("submits a new comment and prepends it to the list", async () => {
+ const user = userEvent.setup();
+ const newComment = {
+ id: "c-new",
+ body: "My new comment",
+ createdAt: new Date().toISOString(),
+ user: { id: "user-1", name: "Bob", image: null },
+ };
+
+ vi.mocked(global.fetch)
+ .mockResolvedValueOnce({ ok: true, json: async () => [] } as Response)
+ .mockResolvedValueOnce({ ok: true, json: async () => newComment } as Response);
+
+ render(
+
+ );
+
+ await waitFor(() =>
+ expect(screen.getByPlaceholderText("Add a comment...")).toBeInTheDocument()
+ );
+
+ await user.type(screen.getByPlaceholderText("Add a comment..."), "My new comment");
+ await user.click(screen.getByRole("button", { name: "Post Comment" }));
+
+ await waitFor(() => {
+ expect(screen.getByText("My new comment")).toBeInTheDocument();
+ });
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ "/api/resources/res-1/comments",
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ body: "My new comment" }),
+ })
+ );
+ });
+
+ it("clears the textarea after successful submission", async () => {
+ const user = userEvent.setup();
+
+ vi.mocked(global.fetch)
+ .mockResolvedValueOnce({ ok: true, json: async () => [] } as Response)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ id: "c-new",
+ body: "Hello",
+ createdAt: new Date().toISOString(),
+ user: { id: "user-1", name: "Bob", image: null },
+ }),
+ } as Response);
+
+ render(
+
+ );
+
+ await waitFor(() =>
+ expect(screen.getByPlaceholderText("Add a comment...")).toBeInTheDocument()
+ );
+
+ const textarea = screen.getByPlaceholderText("Add a comment...");
+ await user.type(textarea, "Hello");
+ await user.click(screen.getByRole("button", { name: "Post Comment" }));
+
+ await waitFor(() => {
+ expect(textarea).toHaveValue("");
+ });
+ });
+
+ it("shows the Delete button for a comment the current user owns", async () => {
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [mockComment],
+ } as Response);
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
+ });
+ });
+
+ it("shows the Delete button for admin on any comment", async () => {
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [mockComment],
+ } as Response);
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
+ });
+ });
+
+ it("hides the Delete button for non-owner, non-admin users", async () => {
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [mockComment],
+ } as Response);
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
+ });
+ });
+
+ it("removes a comment from the list after successful deletion", async () => {
+ const user = userEvent.setup();
+ vi.stubGlobal("confirm", vi.fn(() => true));
+
+ vi.mocked(global.fetch)
+ .mockResolvedValueOnce({ ok: true, json: async () => [mockComment] } as Response)
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ success: true }) } as Response);
+
+ render(
+
+ );
+
+ await waitFor(() =>
+ expect(screen.getByText("This is a great resource!")).toBeInTheDocument()
+ );
+
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ await waitFor(() => {
+ expect(screen.queryByText("This is a great resource!")).not.toBeInTheDocument();
+ });
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ "/api/resources/res-1/comments/c-1",
+ expect.objectContaining({ method: "DELETE" })
+ );
+ });
+
+ it("keeps the comment in the list when deletion is cancelled", async () => {
+ const user = userEvent.setup();
+ vi.stubGlobal("confirm", vi.fn(() => false));
+
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [mockComment],
+ } as Response);
+
+ render(
+
+ );
+
+ await waitFor(() =>
+ expect(screen.getByText("This is a great resource!")).toBeInTheDocument()
+ );
+
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ expect(screen.getByText("This is a great resource!")).toBeInTheDocument();
+ // Only one fetch call (the initial GET), no DELETE was fired
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("displays the user avatar initial when no image is provided", async () => {
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [mockComment],
+ } as Response);
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("A")).toBeInTheDocument(); // First char of "Alice"
+ });
+ });
+
+ it("updates the comments heading with the current count", async () => {
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [mockComment],
+ } as Response);
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Comments (1)")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/resources/LikeButton.test.tsx b/src/components/resources/LikeButton.test.tsx
new file mode 100644
index 0000000..6126d5c
--- /dev/null
+++ b/src/components/resources/LikeButton.test.tsx
@@ -0,0 +1,178 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { render, screen, waitFor } from "@/test/helpers/render";
+import userEvent from "@testing-library/user-event";
+import LikeButton from "./LikeButton";
+
+describe("LikeButton", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ global.fetch = vi.fn();
+ });
+
+ it("renders the initial like count", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("5")).toBeInTheDocument();
+ expect(screen.getByRole("button")).toBeInTheDocument();
+ });
+
+ it("renders the unliked heart symbol when not liked", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("♡")).toBeInTheDocument();
+ });
+
+ it("renders the liked heart symbol when liked", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("♥")).toBeInTheDocument();
+ });
+
+ it("applies optimistic like and updates count from server response", async () => {
+ const user = userEvent.setup();
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ liked: true, count: 6 }),
+ } as Response);
+
+ render(
+
+ );
+
+ await user.click(screen.getByRole("button"));
+
+ await waitFor(() => {
+ expect(screen.getByText("6")).toBeInTheDocument();
+ });
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ "/api/resources/res-1/like",
+ expect.objectContaining({ method: "POST" })
+ );
+ });
+
+ it("reverts the optimistic update when the server responds with an error", async () => {
+ const user = userEvent.setup();
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+ ok: false,
+ json: async () => ({ error: "Internal Server Error" }),
+ } as Response);
+
+ render(
+
+ );
+
+ await user.click(screen.getByRole("button"));
+
+ await waitFor(() => {
+ expect(screen.getByText("5")).toBeInTheDocument();
+ });
+ });
+
+ it("reverts the optimistic update on a network error", async () => {
+ const user = userEvent.setup();
+ vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Network error"));
+
+ render(
+
+ );
+
+ await user.click(screen.getByRole("button"));
+
+ await waitFor(() => {
+ expect(screen.getByText("5")).toBeInTheDocument();
+ });
+ });
+
+ it("redirects to sign-in page when unauthenticated user clicks the button", async () => {
+ const user = userEvent.setup();
+ const assignSpy = vi.fn();
+ Object.defineProperty(window, "location", {
+ value: { href: "/" },
+ writable: true,
+ });
+
+ render(
+
+ );
+
+ await user.click(screen.getByRole("button"));
+
+ expect(global.fetch).not.toHaveBeenCalled();
+ expect(window.location.href).toBe("/auth/signin");
+
+ // restore
+ Object.defineProperty(window, "location", {
+ value: { href: "/" },
+ writable: true,
+ });
+ assignSpy.mockRestore?.();
+ });
+
+ it("shows the correct tooltip for an unauthenticated user", () => {
+ render(
+
+ );
+
+ expect(screen.getByRole("button")).toHaveAttribute("title", "Sign in to like");
+ });
+
+ it("shows the 'Unlike' tooltip when the resource is already liked", () => {
+ render(
+
+ );
+
+ expect(screen.getByRole("button")).toHaveAttribute("title", "Unlike");
+ });
+});
diff --git a/src/components/resources/ResourceCard.test.tsx b/src/components/resources/ResourceCard.test.tsx
new file mode 100644
index 0000000..0cef85c
--- /dev/null
+++ b/src/components/resources/ResourceCard.test.tsx
@@ -0,0 +1,95 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@/test/helpers/render";
+import ResourceCard from "./ResourceCard";
+
+const defaultProps = {
+ title: "Platform Engineering Guide",
+ description: "A comprehensive guide to platform engineering.",
+ tags: [
+ { id: "t1", name: "Kubernetes" },
+ { id: "t2", name: "DevOps" },
+ ],
+ targetAudience: ["Beginner", "Intermediate"],
+ slug: "platform-engineering-guide",
+ categorySlug: "guides",
+ status: "PUBLISHED",
+ likeCount: 10,
+ commentCount: 3,
+};
+
+describe("ResourceCard", () => {
+ it("renders the resource title", () => {
+ render();
+ expect(screen.getByText("Platform Engineering Guide")).toBeInTheDocument();
+ });
+
+ it("renders the resource description", () => {
+ render();
+ expect(
+ screen.getByText("A comprehensive guide to platform engineering.")
+ ).toBeInTheDocument();
+ });
+
+ it("renders all tags", () => {
+ render();
+ expect(screen.getByText("Kubernetes")).toBeInTheDocument();
+ expect(screen.getByText("DevOps")).toBeInTheDocument();
+ });
+
+ it("renders the target audience", () => {
+ render();
+ expect(screen.getByText(/Beginner, Intermediate/)).toBeInTheDocument();
+ });
+
+ it("renders like count with correct pluralisation", () => {
+ render();
+ expect(screen.getByText(/10 likes/)).toBeInTheDocument();
+ });
+
+ it("renders singular 'like' when count is 1", () => {
+ render();
+ expect(screen.getByText(/1 like[^s]/)).toBeInTheDocument();
+ });
+
+ it("renders comment count with correct pluralisation", () => {
+ render();
+ expect(screen.getByText(/3 comments/)).toBeInTheDocument();
+ });
+
+ it("renders singular 'comment' when count is 1", () => {
+ render();
+ expect(screen.getByText(/1 comment[^s]/)).toBeInTheDocument();
+ });
+
+ it("hides engagement stats when both counts are 0", () => {
+ render();
+ expect(screen.queryByText(/like/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/comment/)).not.toBeInTheDocument();
+ });
+
+ it("wraps the card in a Link when status is not COMING_SOON", () => {
+ render();
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("href", "/guides/platform-engineering-guide");
+ });
+
+ it("does not render a link when status is COMING_SOON", () => {
+ render();
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ });
+
+ it("shows 'Coming Soon' badge when status is COMING_SOON", () => {
+ render();
+ expect(screen.getByText("Coming Soon")).toBeInTheDocument();
+ });
+
+ it("does not show 'Coming Soon' badge when status is PUBLISHED", () => {
+ render();
+ expect(screen.queryByText("Coming Soon")).not.toBeInTheDocument();
+ });
+
+ it("does not render target audience section when array is empty", () => {
+ render();
+ expect(screen.queryByText(/For:/)).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/resources/SubmissionForm.test.tsx b/src/components/resources/SubmissionForm.test.tsx
new file mode 100644
index 0000000..22a9229
--- /dev/null
+++ b/src/components/resources/SubmissionForm.test.tsx
@@ -0,0 +1,216 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@/test/helpers/render";
+import userEvent from "@testing-library/user-event";
+import SubmissionForm from "./SubmissionForm";
+
+describe("SubmissionForm", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ global.fetch = vi.fn();
+ });
+
+ describe("when unauthenticated", () => {
+ it("shows sign in prompt instead of form", () => {
+ render();
+ expect(screen.getByText(/sign in/i)).toBeInTheDocument();
+ expect(screen.queryByRole("form")).not.toBeInTheDocument();
+ });
+
+ it("renders a sign in link", () => {
+ render();
+ const link = screen.getByRole("link", { name: /sign in/i });
+ expect(link).toHaveAttribute("href", "/auth/signin");
+ });
+ });
+
+ describe("when authenticated", () => {
+ it("renders all form fields", () => {
+ render();
+ expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/type/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/short description/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/detailed content/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/external url/i)).toBeInTheDocument();
+ });
+
+ it("renders the submit button", () => {
+ render();
+ expect(
+ screen.getByRole("button", { name: /submit resource for review/i })
+ ).toBeInTheDocument();
+ });
+
+ it("shows validation error when required fields are missing", async () => {
+ render();
+ fireEvent.submit(screen.getByRole("button", { name: /submit resource/i }).closest("form")!);
+ await waitFor(() => {
+ expect(
+ screen.getByText(/title, type, and description are required/i)
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("does not call fetch when required fields are missing", async () => {
+ render();
+ fireEvent.submit(screen.getByRole("button", { name: /submit resource/i }).closest("form")!);
+ await waitFor(() => {
+ expect(global.fetch).not.toHaveBeenCalled();
+ });
+ });
+
+ it("submits the form and shows success message on ok response", async () => {
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ });
+
+ render();
+
+ await userEvent.type(screen.getByLabelText(/title/i), "My Resource");
+ await userEvent.selectOptions(screen.getByLabelText(/type/i), "Article");
+ await userEvent.type(
+ screen.getByLabelText(/short description/i),
+ "A great resource."
+ );
+
+ fireEvent.submit(screen.getByRole("button", { name: /submit resource/i }).closest("form")!);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/thank you for your submission/i)
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("posts to /api/submissions with correct payload", async () => {
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ });
+
+ render();
+
+ await userEvent.type(screen.getByLabelText(/title/i), "My Resource");
+ await userEvent.selectOptions(screen.getByLabelText(/type/i), "Tool");
+ await userEvent.type(
+ screen.getByLabelText(/short description/i),
+ "A useful tool."
+ );
+
+ fireEvent.submit(screen.getByRole("button", { name: /submit resource/i }).closest("form")!);
+
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalledWith(
+ "/api/submissions",
+ expect.objectContaining({
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ title: "My Resource",
+ type: "Tool",
+ description: "A useful tool.",
+ body: null,
+ externalUrl: null,
+ }),
+ })
+ );
+ });
+ });
+
+ it("shows error message from server on failed response", async () => {
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: false,
+ json: async () => ({ error: "Submission failed." }),
+ });
+
+ render();
+
+ await userEvent.type(screen.getByLabelText(/title/i), "My Resource");
+ await userEvent.selectOptions(screen.getByLabelText(/type/i), "Article");
+ await userEvent.type(
+ screen.getByLabelText(/short description/i),
+ "A great resource."
+ );
+
+ fireEvent.submit(screen.getByRole("button", { name: /submit resource/i }).closest("form")!);
+
+ await waitFor(() => {
+ expect(screen.getByText("Submission failed.")).toBeInTheDocument();
+ });
+ });
+
+ it("shows network error message on fetch failure", async () => {
+ (global.fetch as ReturnType).mockRejectedValueOnce(
+ new Error("Network error")
+ );
+
+ render();
+
+ await userEvent.type(screen.getByLabelText(/title/i), "My Resource");
+ await userEvent.selectOptions(screen.getByLabelText(/type/i), "Article");
+ await userEvent.type(
+ screen.getByLabelText(/short description/i),
+ "A great resource."
+ );
+
+ fireEvent.submit(screen.getByRole("button", { name: /submit resource/i }).closest("form")!);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/network error. please try again/i)
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("disables the submit button while submitting", async () => {
+ let resolveFetch: () => void;
+ (global.fetch as ReturnType).mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveFetch = () => resolve({ ok: true });
+ })
+ );
+
+ render();
+
+ await userEvent.type(screen.getByLabelText(/title/i), "My Resource");
+ await userEvent.selectOptions(screen.getByLabelText(/type/i), "Article");
+ await userEvent.type(
+ screen.getByLabelText(/short description/i),
+ "A great resource."
+ );
+
+ fireEvent.submit(screen.getByRole("button", { name: /submit resource/i }).closest("form")!);
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /submitting/i })).toBeDisabled();
+ });
+
+ resolveFetch!();
+ });
+
+ it("allows submitting another resource after success", async () => {
+ (global.fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ });
+
+ render();
+
+ await userEvent.type(screen.getByLabelText(/title/i), "My Resource");
+ await userEvent.selectOptions(screen.getByLabelText(/type/i), "Article");
+ await userEvent.type(
+ screen.getByLabelText(/short description/i),
+ "A great resource."
+ );
+
+ fireEvent.submit(screen.getByRole("button", { name: /submit resource/i }).closest("form")!);
+
+ await waitFor(() => {
+ expect(screen.getByText(/submit another resource/i)).toBeInTheDocument();
+ });
+
+ await userEvent.click(screen.getByText(/submit another resource/i));
+
+ expect(
+ screen.getByRole("button", { name: /submit resource for review/i })
+ ).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts
new file mode 100644
index 0000000..a5f065c
--- /dev/null
+++ b/src/lib/utils.test.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect } from "vitest";
+import { slugify, formatDate } from "./utils";
+
+describe("slugify", () => {
+ it("converts text to lowercase", () => {
+ expect(slugify("Hello World")).toBe("hello-world");
+ });
+
+ it("replaces spaces with hyphens", () => {
+ expect(slugify("multiple spaces")).toBe("multiple-spaces");
+ });
+
+ it("replaces underscores with hyphens", () => {
+ expect(slugify("hello_world")).toBe("hello-world");
+ });
+
+ it("removes special characters", () => {
+ expect(slugify("hello@world!")).toBe("helloworld");
+ });
+
+ it("removes leading and trailing hyphens after trimming", () => {
+ expect(slugify(" hello world ")).toBe("hello-world");
+ });
+
+ it("collapses multiple hyphens into one", () => {
+ expect(slugify("hello---world")).toBe("hello-world");
+ });
+
+ it("handles empty string", () => {
+ expect(slugify("")).toBe("");
+ });
+
+ it("handles string with only special characters", () => {
+ expect(slugify("!!!")).toBe("");
+ });
+
+ it("handles real-world resource title", () => {
+ expect(slugify("React.js & TypeScript: A Guide!")).toBe(
+ "reactjs-typescript-a-guide"
+ );
+ });
+
+ it("handles already-lowercase hyphenated string unchanged", () => {
+ expect(slugify("platform-engineering")).toBe("platform-engineering");
+ });
+});
+
+describe("formatDate", () => {
+ it("formats a date in en-US long format", () => {
+ // Use UTC to avoid timezone-dependent failures
+ const date = new Date("2024-03-15T12:00:00Z");
+ const result = formatDate(date);
+ expect(result).toMatch(/March/);
+ expect(result).toMatch(/2024/);
+ });
+
+ it("formats January correctly", () => {
+ const date = new Date("2024-01-01T12:00:00Z");
+ const result = formatDate(date);
+ expect(result).toMatch(/January/);
+ expect(result).toMatch(/2024/);
+ });
+
+ it("formats December correctly", () => {
+ const date = new Date("2024-12-31T12:00:00Z");
+ const result = formatDate(date);
+ expect(result).toMatch(/December/);
+ expect(result).toMatch(/2024/);
+ });
+
+ it("returns a non-empty string", () => {
+ const result = formatDate(new Date());
+ expect(result.length).toBeGreaterThan(0);
+ });
+});
diff --git a/src/test/helpers/render.tsx b/src/test/helpers/render.tsx
new file mode 100644
index 0000000..cf4e42e
--- /dev/null
+++ b/src/test/helpers/render.tsx
@@ -0,0 +1,10 @@
+import { ReactElement } from "react";
+import { render, RenderOptions } from "@testing-library/react";
+
+const customRender = (
+ ui: ReactElement,
+ options?: Omit
+) => render(ui, { ...options });
+
+export * from "@testing-library/react";
+export { customRender as render };
diff --git a/src/test/mocks/auth.ts b/src/test/mocks/auth.ts
new file mode 100644
index 0000000..79d3f57
--- /dev/null
+++ b/src/test/mocks/auth.ts
@@ -0,0 +1,40 @@
+import { vi } from "vitest";
+
+export interface MockUser {
+ id: string;
+ email: string;
+ name: string;
+ image?: string | null;
+ role: string;
+}
+
+export interface MockSession {
+ user: MockUser;
+ expires: string;
+}
+
+export const createMockSession = (overrides?: Partial): MockSession => ({
+ user: {
+ id: "test-user-id",
+ email: "test@example.com",
+ name: "Test User",
+ image: null,
+ role: "VISITOR",
+ ...overrides?.user,
+ },
+ expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
+ ...overrides,
+});
+
+export const createAdminSession = (): MockSession =>
+ createMockSession({
+ user: {
+ id: "admin-user-id",
+ email: "admin@example.com",
+ name: "Admin User",
+ image: null,
+ role: "ADMIN",
+ },
+ });
+
+export const mockAuth = vi.fn();
diff --git a/src/test/mocks/prisma.ts b/src/test/mocks/prisma.ts
new file mode 100644
index 0000000..c9e9eec
--- /dev/null
+++ b/src/test/mocks/prisma.ts
@@ -0,0 +1,56 @@
+import { vi } from "vitest";
+
+export const mockPrisma = {
+ resource: {
+ findUnique: vi.fn(),
+ findMany: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ count: vi.fn(),
+ },
+ category: {
+ findUnique: vi.fn(),
+ findMany: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ count: vi.fn(),
+ },
+ tag: {
+ findUnique: vi.fn(),
+ findMany: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ },
+ comment: {
+ findUnique: vi.fn(),
+ findMany: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ count: vi.fn(),
+ },
+ like: {
+ findUnique: vi.fn(),
+ findMany: vi.fn(),
+ create: vi.fn(),
+ delete: vi.fn(),
+ count: vi.fn(),
+ },
+ submission: {
+ findUnique: vi.fn(),
+ findMany: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ },
+ user: {
+ findUnique: vi.fn(),
+ findMany: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ },
+ $transaction: vi.fn(),
+};
diff --git a/src/test/setup.ts b/src/test/setup.ts
new file mode 100644
index 0000000..6e7a089
--- /dev/null
+++ b/src/test/setup.ts
@@ -0,0 +1,31 @@
+import "@testing-library/jest-dom";
+import { cleanup } from "@testing-library/react";
+import { afterEach, vi } from "vitest";
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ back: vi.fn(),
+ }),
+ usePathname: () => "/",
+ useSearchParams: () => new URLSearchParams(),
+ redirect: vi.fn(),
+}));
+
+vi.mock("next/headers", () => ({
+ cookies: vi.fn(() => ({
+ get: vi.fn(),
+ set: vi.fn(),
+ delete: vi.fn(),
+ })),
+ headers: vi.fn(() => ({
+ get: vi.fn(),
+ })),
+}));
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..f2de5ac
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,29 @@
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+import path from "path";
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./src/test/setup.ts"],
+ include: ["src/**/*.{test,spec}.{ts,tsx}"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "json", "html"],
+ exclude: [
+ "node_modules/",
+ "src/test/",
+ "src/generated/",
+ "**/*.d.ts",
+ "**/*.config.*",
+ ],
+ },
+ },
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+});