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