From 7cf8056b5016fa0d99fcdbe9172ce211a5c97dd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 09:17:25 +0000 Subject: [PATCH 1/8] test: add vitest test infrastructure and npm scripts --- package-lock.json | 980 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 9 +- vitest.config.ts | 16 + vitest.setup.ts | 4 + 4 files changed, 1005 insertions(+), 4 deletions(-) create mode 100644 vitest.config.ts create mode 100644 vitest.setup.ts diff --git a/package-lock.json b/package-lock.json index 9813ea3..584a013 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,10 +37,12 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.1.9", "eslint": "^9", "eslint-config-next": "15.5.19", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.9" } }, "node_modules/@alloc/quick-lru": { @@ -569,6 +571,16 @@ } } }, + "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/@clerk/backend": { "version": "3.8.4", "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.8.4.tgz", @@ -1999,6 +2011,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", @@ -2032,6 +2054,293 @@ } } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2070,6 +2379,13 @@ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "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==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2446,6 +2762,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/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.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3300,6 +3634,150 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3602,6 +4080,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": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -3621,6 +4109,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "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", @@ -3928,6 +4435,16 @@ "node": ">=10.0.0" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4760,6 +5277,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", + "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -5260,6 +5784,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5326,6 +5860,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -5656,7 +6200,22 @@ "universalify": "^2.0.0" }, "engines": { - "node": ">=14.14" + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/function-bind": { @@ -6003,6 +6562,13 @@ "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -6740,6 +7306,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", @@ -7341,6 +7946,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8341,6 +8974,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8693,6 +9340,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -9259,6 +9913,40 @@ "node": ">= 0.8.15" } }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -9732,6 +10420,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", @@ -9775,6 +10470,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/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -9804,6 +10506,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -10230,6 +10939,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -10278,6 +11004,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10671,6 +11407,229 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", + "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10775,6 +11734,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index ea421dc..f669a2b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -38,9 +41,11 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.1.9", "eslint": "^9", "eslint-config-next": "15.5.19", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.9" } } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..966437e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['**/*.test.ts'], + exclude: ['node_modules/**', '.next/**'], + setupFiles: ['./vitest.setup.ts'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + }, + }, +}); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..d1604e6 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,4 @@ +// Dummy values for env vars that third-party SDKs validate at construction +// time (e.g. `new Resend(...)`), so importing modules under test doesn't +// crash in an environment where the real secret isn't configured. +process.env.RESEND_API_KEY ??= 're_test_dummy_key'; From 5d8ec8d3af8cdad138bb83fca5304a040b0e9101 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 09:17:34 +0000 Subject: [PATCH 2/8] fix: build provider API clients only after key-presence filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constructing all 5 OpenAI clients up front made getProviders() throw whenever any single provider key was unset, since the SDK validates apiKey synchronously — one missing key crashed the whole audit request instead of being skipped as intended. --- lib/ai/providers.ts | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/lib/ai/providers.ts b/lib/ai/providers.ts index 1342740..6875f80 100644 --- a/lib/ai/providers.ts +++ b/lib/ai/providers.ts @@ -23,47 +23,53 @@ function makeClient(baseURL: string, apiKey: string): OpenAI { * Add/remove entries here as keys change. */ export function getProviders(): Provider[] { + // Filter by key presence BEFORE constructing OpenAI clients — the SDK throws + // synchronously on an empty apiKey, so building all 5 clients up front would + // crash this function (and every caller) whenever any single key is unset. return [ // Groq — only working reliable free vision model. Both keys use the same // model (llama-4-scout is Groq's ONLY vision model) to double free quota. { name: 'groq-1', - client: makeClient('https://api.groq.com/openai/v1', process.env.GROQ_KEY_1 ?? ''), + baseURL: 'https://api.groq.com/openai/v1', + apiKey: process.env.GROQ_KEY_1 ?? '', model: 'meta-llama/llama-4-scout-17b-16e-instruct', - supportsVision: true, }, { name: 'groq-2', - client: makeClient('https://api.groq.com/openai/v1', process.env.GROQ_KEY_2 ?? ''), + baseURL: 'https://api.groq.com/openai/v1', + apiKey: process.env.GROQ_KEY_2 ?? '', model: 'meta-llama/llama-4-scout-17b-16e-instruct', - supportsVision: true, }, // OpenRouter free vision models — verified free + image-capable (Nov 2026). // llama-4-*:free went paid; these are the current actually-free vision models. { name: 'openrouter-gemma', - client: makeClient('https://openrouter.ai/api/v1', process.env.OPENROUTER_KEY_1 ?? ''), + baseURL: 'https://openrouter.ai/api/v1', + apiKey: process.env.OPENROUTER_KEY_1 ?? '', model: 'google/gemma-4-26b-a4b-it:free', - supportsVision: true, }, { name: 'openrouter-nemotron', - client: makeClient('https://openrouter.ai/api/v1', process.env.OPENROUTER_KEY_2 ?? ''), + baseURL: 'https://openrouter.ai/api/v1', + apiKey: process.env.OPENROUTER_KEY_2 ?? '', model: 'nvidia/nemotron-nano-12b-v2-vl:free', - supportsVision: true, }, // Last resort — OpenRouter auto free router (picks any available free model) { name: 'openrouter-auto', - client: makeClient('https://openrouter.ai/api/v1', process.env.OPENROUTER_KEY_1 ?? ''), + baseURL: 'https://openrouter.ai/api/v1', + apiKey: process.env.OPENROUTER_KEY_1 ?? '', model: 'openrouter/free', - supportsVision: true, }, - ].filter((p) => { - // Skip providers with missing keys at runtime - const key = p.client.apiKey; - return key && key.length > 10; - }); + ] + .filter((p) => p.apiKey && p.apiKey.length > 10) + .map((p) => ({ + name: p.name, + client: makeClient(p.baseURL, p.apiKey), + model: p.model, + supportsVision: true, + })); } /** True when worth trying next provider (rate limit, quota, server error, model not found, network). */ From 1ced4956dd388ec87222035d892ce343a9b2ae39 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 09:17:40 +0000 Subject: [PATCH 3/8] test: cover AI provider fallback chain and getProviders key filtering --- lib/ai/providers.test.ts | 133 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 lib/ai/providers.test.ts diff --git a/lib/ai/providers.test.ts b/lib/ai/providers.test.ts new file mode 100644 index 0000000..416109d --- /dev/null +++ b/lib/ai/providers.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import OpenAI from 'openai'; +import { isRetryableError, withFallback, getProviders, type Provider } from './providers'; + +function apiError(status: number): InstanceType { + return Object.assign(Object.create(OpenAI.APIError.prototype), { status }); +} + +const ALL_KEY_VARS = ['GROQ_KEY_1', 'GROQ_KEY_2', 'OPENROUTER_KEY_1', 'OPENROUTER_KEY_2']; + +beforeEach(() => { + for (const key of ALL_KEY_VARS) vi.stubEnv(key, ''); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('isRetryableError', () => { + it('retries on 429 (rate limit)', () => { + expect(isRetryableError(apiError(429))).toBe(true); + }); + + it('retries on 403 (quota exhausted)', () => { + expect(isRetryableError(apiError(403))).toBe(true); + }); + + it('retries on 404 (model not found)', () => { + expect(isRetryableError(apiError(404))).toBe(true); + }); + + it('retries on 5xx server errors', () => { + expect(isRetryableError(apiError(500))).toBe(true); + expect(isRetryableError(apiError(503))).toBe(true); + }); + + it('does not retry on 400 (bad request)', () => { + expect(isRetryableError(apiError(400))).toBe(false); + }); + + it('does not retry on 401 (auth error)', () => { + expect(isRetryableError(apiError(401))).toBe(false); + }); + + it('retries on non-APIError (network/timeout/DNS failures)', () => { + expect(isRetryableError(new Error('ECONNRESET'))).toBe(true); + expect(isRetryableError('some string')).toBe(true); + expect(isRetryableError(undefined)).toBe(true); + }); +}); + +describe('getProviders', () => { + it('never throws when every API key is missing (regression: empty apiKey used to crash the OpenAI client constructor)', () => { + expect(() => getProviders()).not.toThrow(); + expect(getProviders()).toEqual([]); + }); + + it('skips providers with a short/placeholder key but keeps ones with a real key', () => { + vi.stubEnv('GROQ_KEY_1', 'short'); + vi.stubEnv('GROQ_KEY_2', 'a-real-looking-groq-key-1234567890'); + + const providers = getProviders(); + + expect(providers.map((p) => p.name)).toEqual(['groq-2']); + }); + + it('returns providers in priority order (groq before openrouter)', () => { + vi.stubEnv('GROQ_KEY_1', 'a-real-looking-groq-key-1234567890'); + vi.stubEnv('OPENROUTER_KEY_1', 'a-real-looking-openrouter-key-1234567890'); + + const providers = getProviders(); + + expect(providers.map((p) => p.name)).toEqual(['groq-1', 'openrouter-gemma', 'openrouter-auto']); + }); +}); + +describe('withFallback', () => { + beforeEach(() => { + vi.stubEnv('GROQ_KEY_1', 'a-real-looking-groq-key-1234567890'); + vi.stubEnv('GROQ_KEY_2', 'another-real-looking-groq-key-1234567890'); + }); + + it('returns the result and provider name on first-provider success', async () => { + const fn = vi.fn(async (p: Provider) => `ok-from-${p.name}`); + + const { result, providerUsed } = await withFallback(fn); + + expect(result).toBe('ok-from-groq-1'); + expect(providerUsed).toBe('groq-1'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('falls back to the next provider on a retryable error', async () => { + const fn = vi.fn(async (p: Provider) => { + if (p.name === 'groq-1') throw apiError(429); + return `ok-from-${p.name}`; + }); + + const { result, providerUsed } = await withFallback(fn); + + expect(result).toBe('ok-from-groq-2'); + expect(providerUsed).toBe('groq-2'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('fails fast on a non-retryable error without trying later providers', async () => { + const fn = vi.fn(async (p: Provider) => { + if (p.name === 'groq-1') throw apiError(401); + return `ok-from-${p.name}`; + }); + + await expect(withFallback(fn)).rejects.toMatchObject({ status: 401 }); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('throws the last error when every provider fails', async () => { + const fn = vi.fn(async (p: Provider) => { + throw apiError(p.name === 'groq-1' ? 429 : 500); + }); + + await expect(withFallback(fn)).rejects.toMatchObject({ status: 500 }); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('throws undefined when there are no providers available at all', async () => { + vi.stubEnv('GROQ_KEY_1', ''); + vi.stubEnv('GROQ_KEY_2', ''); + const fn = vi.fn(async () => 'unreachable'); + + await expect(withFallback(fn)).rejects.toBeUndefined(); + expect(fn).not.toHaveBeenCalled(); + }); +}); From b50357029a8b15597c8984af296920784c74ea66 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 09:17:40 +0000 Subject: [PATCH 4/8] test: cover free-tier rate limiter window and reset behavior --- lib/rate-limit.test.ts | 78 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 lib/rate-limit.test.ts diff --git a/lib/rate-limit.test.ts b/lib/rate-limit.test.ts new file mode 100644 index 0000000..3563b2a --- /dev/null +++ b/lib/rate-limit.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { checkRateLimit, getClientIp } from './rate-limit'; + +describe('checkRateLimit', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('allows requests up to the free limit', () => { + const ip = `ip-${Math.random()}`; + for (let i = 0; i < 5; i++) { + const res = checkRateLimit(ip); + expect(res.allowed).toBe(true); + } + }); + + it('blocks the 6th request within the same 24h window', () => { + const ip = `ip-${Math.random()}`; + for (let i = 0; i < 5; i++) checkRateLimit(ip); + const res = checkRateLimit(ip); + expect(res.allowed).toBe(false); + expect(res.remaining).toBe(0); + }); + + it('decrements remaining count on each allowed request', () => { + const ip = `ip-${Math.random()}`; + expect(checkRateLimit(ip).remaining).toBe(4); + expect(checkRateLimit(ip).remaining).toBe(3); + expect(checkRateLimit(ip).remaining).toBe(2); + }); + + it('resets the count after the 24h window elapses', () => { + const ip = `ip-${Math.random()}`; + for (let i = 0; i < 5; i++) checkRateLimit(ip); + expect(checkRateLimit(ip).allowed).toBe(false); + + vi.advanceTimersByTime(24 * 60 * 60 * 1000 + 1); + + const res = checkRateLimit(ip); + expect(res.allowed).toBe(true); + expect(res.remaining).toBe(4); + }); + + it('tracks separate IPs independently', () => { + const ipA = `ip-a-${Math.random()}`; + const ipB = `ip-b-${Math.random()}`; + for (let i = 0; i < 5; i++) checkRateLimit(ipA); + + expect(checkRateLimit(ipA).allowed).toBe(false); + expect(checkRateLimit(ipB).allowed).toBe(true); + }); +}); + +describe('getClientIp', () => { + it('prefers x-forwarded-for and takes the first address', () => { + const req = new Request('https://example.com', { + headers: { 'x-forwarded-for': '1.2.3.4, 5.6.7.8' }, + }); + expect(getClientIp(req)).toBe('1.2.3.4'); + }); + + it('falls back to x-real-ip when x-forwarded-for is absent', () => { + const req = new Request('https://example.com', { + headers: { 'x-real-ip': '9.9.9.9' }, + }); + expect(getClientIp(req)).toBe('9.9.9.9'); + }); + + it('falls back to "unknown" when no IP headers are present', () => { + const req = new Request('https://example.com'); + expect(getClientIp(req)).toBe('unknown'); + }); +}); From 07193b94c5a810286d735f066d71f723a38d74a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 09:17:40 +0000 Subject: [PATCH 5/8] test: cover audit score clamping and malformed-JSON fallback --- lib/ai/analyze.test.ts | 173 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 lib/ai/analyze.test.ts diff --git a/lib/ai/analyze.test.ts b/lib/ai/analyze.test.ts new file mode 100644 index 0000000..b7bfa2f --- /dev/null +++ b/lib/ai/analyze.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const createMock = vi.fn(); + +vi.mock('./providers', () => ({ + withFallback: async (fn: (provider: unknown) => Promise) => { + const provider = { + name: 'test-provider', + model: 'test-model', + supportsVision: true, + client: { chat: { completions: { create: createMock } } }, + }; + const result = await fn(provider); + return { result, providerUsed: provider.name }; + }, +})); + +vi.mock('../db', () => ({ + getSetting: vi.fn(async () => null), +})); + +import { runAudit } from './analyze'; + +function mockResponse(content: string) { + createMock.mockResolvedValueOnce({ choices: [{ message: { content } }] }); +} + +beforeEach(() => { + createMock.mockReset(); +}); + +describe('runAudit', () => { + it('clamps an out-of-range score into 0-100', async () => { + mockResponse(JSON.stringify({ + dimension: 'visual_design', + score: 150, + summary: 'summary', + findings: [], + })); + + const result = await runAudit({ + imageBase64: 'abc', + mimeType: 'image/png', + dimensions: ['visual_design'], + }); + + expect(result.dimensions[0].score).toBe(100); + expect(result.overallScore).toBe(100); + }); + + it('clamps a negative score to 0', async () => { + mockResponse(JSON.stringify({ + dimension: 'copywriting', + score: -20, + summary: 'summary', + findings: [], + })); + + const result = await runAudit({ + imageBase64: 'abc', + mimeType: 'image/png', + dimensions: ['copywriting'], + }); + + expect(result.dimensions[0].score).toBe(0); + }); + + it('rounds a fractional score', async () => { + mockResponse(JSON.stringify({ + dimension: 'cta', + score: 72.6, + summary: 'summary', + findings: [], + })); + + const result = await runAudit({ + imageBase64: 'abc', + mimeType: 'image/png', + dimensions: ['cta'], + }); + + expect(result.dimensions[0].score).toBe(73); + }); + + it('strips markdown code fences before parsing JSON', async () => { + mockResponse('```json\n' + JSON.stringify({ + dimension: 'seo', + score: 60, + summary: 'summary', + findings: [], + }) + '\n```'); + + const result = await runAudit({ + imageBase64: 'abc', + mimeType: 'image/png', + dimensions: ['seo'], + }); + + expect(result.dimensions[0].score).toBe(60); + }); + + it('falls back to a safe default result when the model returns invalid JSON', async () => { + mockResponse('this is not json at all'); + + const result = await runAudit({ + imageBase64: 'abc', + mimeType: 'image/png', + dimensions: ['accessibility'], + }); + + expect(result.dimensions[0]).toMatchObject({ + dimension: 'accessibility', + score: 50, + }); + expect(result.dimensions[0].findings).toHaveLength(1); + }); + + it('falls back to a safe default when the model returns empty content', async () => { + mockResponse(''); + + const result = await runAudit({ + imageBase64: 'abc', + mimeType: 'image/png', + dimensions: ['performance'], + }); + + expect(result.dimensions[0].score).toBe(50); + }); + + it('averages scores across multiple dimensions for the overall score', async () => { + mockResponse(JSON.stringify({ dimension: 'visual_design', score: 80, summary: '', findings: [] })); + mockResponse(JSON.stringify({ dimension: 'copywriting', score: 40, summary: '', findings: [] })); + mockResponse(JSON.stringify({ dimension: 'cta', score: 60, summary: '', findings: [] })); + + const result = await runAudit({ + imageBase64: 'abc', + mimeType: 'image/png', + dimensions: ['visual_design', 'copywriting', 'cta'], + }); + + expect(result.overallScore).toBe(60); + }); + + it('returns overallScore 0 when no dimensions are requested', async () => { + const result = await runAudit({ + imageBase64: 'abc', + mimeType: 'image/png', + dimensions: [], + }); + + expect(result.overallScore).toBe(0); + expect(result.dimensions).toEqual([]); + expect(createMock).not.toHaveBeenCalled(); + }); + + it('invokes onDimensionComplete with each dimension result and provider name', async () => { + mockResponse(JSON.stringify({ dimension: 'mobile_experience', score: 55, summary: '', findings: [] })); + const onDimensionComplete = vi.fn(); + + await runAudit({ + imageBase64: 'abc', + mimeType: 'image/png', + dimensions: ['mobile_experience'], + onDimensionComplete, + }); + + expect(onDimensionComplete).toHaveBeenCalledTimes(1); + expect(onDimensionComplete).toHaveBeenCalledWith( + expect.objectContaining({ dimension: 'mobile_experience', score: 55 }), + 'test-provider', + ); + }); +}); From 44494bd3221c69722b8e7389414c7da612b89294 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 09:17:49 +0000 Subject: [PATCH 6/8] refactor: export scoreColor/scoreVerdict for direct testing --- lib/email.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/email.ts b/lib/email.ts index 1d6ca08..b0b41a1 100644 --- a/lib/email.ts +++ b/lib/email.ts @@ -11,11 +11,11 @@ const FROM = process.env.RESEND_FROM_EMAIL ?? 'RoastLab ' const LOGO_SVG = ``; -function scoreColor(s: number) { +export function scoreColor(s: number) { return s >= 70 ? '#32D74B' : s >= 45 ? '#FFD60A' : '#FF2D55'; } -function scoreVerdict(s: number) { +export function scoreVerdict(s: number) { if (s >= 75) return 'Actually fire 🔥'; if (s >= 60) return 'Mid but salvageable 😐'; if (s >= 40) return 'bestie WHAT IS THIS 💀'; From 33d9ac7af1302c6fe3a4141eadf99b9bc063f181 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 09:17:49 +0000 Subject: [PATCH 7/8] test: cover email score-color and verdict thresholds --- lib/email.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 lib/email.test.ts diff --git a/lib/email.test.ts b/lib/email.test.ts new file mode 100644 index 0000000..f94a645 --- /dev/null +++ b/lib/email.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { scoreColor, scoreVerdict } from './email'; + +describe('scoreColor', () => { + it('is green at and above 70', () => { + expect(scoreColor(70)).toBe('#32D74B'); + expect(scoreColor(100)).toBe('#32D74B'); + }); + + it('is yellow between 45 and 69', () => { + expect(scoreColor(45)).toBe('#FFD60A'); + expect(scoreColor(69)).toBe('#FFD60A'); + }); + + it('is red below 45', () => { + expect(scoreColor(44)).toBe('#FF2D55'); + expect(scoreColor(0)).toBe('#FF2D55'); + }); +}); + +describe('scoreVerdict', () => { + it('returns the top verdict at and above 75', () => { + expect(scoreVerdict(75)).toBe('Actually fire 🔥'); + expect(scoreVerdict(100)).toBe('Actually fire 🔥'); + }); + + it('returns the "mid" verdict between 60 and 74', () => { + expect(scoreVerdict(60)).toBe('Mid but salvageable 😐'); + expect(scoreVerdict(74)).toBe('Mid but salvageable 😐'); + }); + + it('returns the "what is this" verdict between 40 and 59', () => { + expect(scoreVerdict(40)).toBe('bestie WHAT IS THIS 💀'); + expect(scoreVerdict(59)).toBe('bestie WHAT IS THIS 💀'); + }); + + it('returns the "disaster" verdict between 20 and 39', () => { + expect(scoreVerdict(20)).toBe("it's giving disaster 🚨"); + expect(scoreVerdict(39)).toBe("it's giving disaster 🚨"); + }); + + it('returns the emergency verdict below 20', () => { + expect(scoreVerdict(19)).toBe('Call 911 immediately ☠️'); + expect(scoreVerdict(0)).toBe('Call 911 immediately ☠️'); + }); +}); From 5f00c9bfb7c7badb590251d5a88069a58c4b7355 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 09:17:49 +0000 Subject: [PATCH 8/8] test: cover admin upgrade-self authorization gate --- app/api/admin/upgrade-self/route.test.ts | 76 ++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 app/api/admin/upgrade-self/route.test.ts diff --git a/app/api/admin/upgrade-self/route.test.ts b/app/api/admin/upgrade-self/route.test.ts new file mode 100644 index 0000000..54996e0 --- /dev/null +++ b/app/api/admin/upgrade-self/route.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const authMock = vi.fn(); +const getUserMock = vi.fn(); +const updateUserMetadataMock = vi.fn(); + +vi.mock('@clerk/nextjs/server', () => ({ + auth: () => authMock(), + clerkClient: async () => ({ + users: { + getUser: getUserMock, + updateUserMetadata: updateUserMetadataMock, + }, + }), +})); + +import { GET } from './route'; + +beforeEach(() => { + authMock.mockReset(); + getUserMock.mockReset(); + updateUserMetadataMock.mockReset(); +}); + +describe('GET /api/admin/upgrade-self', () => { + it('returns 401 when not signed in', async () => { + authMock.mockResolvedValue({ userId: null }); + + const res = await GET(); + + expect(res.status).toBe(401); + expect(getUserMock).not.toHaveBeenCalled(); + }); + + it('returns 403 for a signed-in user who is not an admin', async () => { + authMock.mockResolvedValue({ userId: 'user_123' }); + getUserMock.mockResolvedValue({ emailAddresses: [{ emailAddress: 'not-an-admin@example.com' }] }); + + const res = await GET(); + const body = await res.json(); + + expect(res.status).toBe(403); + expect(body).toEqual({ error: 'Forbidden' }); + expect(updateUserMetadataMock).not.toHaveBeenCalled(); + }); + + it('upgrades an admin user to the full plan', async () => { + authMock.mockResolvedValue({ userId: 'user_admin' }); + getUserMock.mockResolvedValue({ emailAddresses: [{ emailAddress: 'ritesh@gratiantechnologies.com' }] }); + updateUserMetadataMock.mockResolvedValue(undefined); + + const res = await GET(); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body).toEqual({ + ok: true, + userId: 'user_admin', + email: 'ritesh@gratiantechnologies.com', + plan: 'full', + }); + expect(updateUserMetadataMock).toHaveBeenCalledWith('user_admin', { + publicMetadata: { plan: 'full' }, + }); + }); + + it('treats a missing email address as non-admin (403), not a crash', async () => { + authMock.mockResolvedValue({ userId: 'user_no_email' }); + getUserMock.mockResolvedValue({ emailAddresses: [] }); + + const res = await GET(); + + expect(res.status).toBe(403); + expect(updateUserMetadataMock).not.toHaveBeenCalled(); + }); +});