diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b1460ec --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,33 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 26 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test diff --git a/docs/development.md b/docs/development.md index c5a9fd6..d5a881a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,19 +27,23 @@ Open `http://localhost:3000` and log in with the credentials from `SEED_ADMIN_*` ## Commands -| Command | Description | -| ------------------- | ---------------------------------------------------------------- | -| `pnpm dev` | Start the dev server with hot reload | -| `pnpm build` | Production build | -| `pnpm start` | Start the production server (requires a prior build) | -| `pnpm lint` | Run ESLint | -| `pnpm format` | Auto-format all files with Prettier | -| `pnpm format:check` | Check formatting without writing (used in CI) | -| `pnpm db:generate` | Generate migrations from schema changes | -| `pnpm db:migrate` | Apply all pending migrations | -| `pnpm db:seed` | Seed the 6 teams + admin user (idempotent) | -| `pnpm db:reset` | Drop and recreate the local database (**blocked in production**) | -| `pnpm db:studio` | Open Drizzle Studio - visual database browser (dev only) | +| Command | Description | +| -------------------- | ---------------------------------------------------------------- | +| `pnpm dev` | Start the dev server with hot reload | +| `pnpm build` | Production build | +| `pnpm start` | Start the production server (requires a prior build) | +| `pnpm lint` | Run ESLint | +| `pnpm format` | Auto-format all files with Prettier | +| `pnpm format:check` | Check formatting without writing (used in CI) | +| `pnpm db:generate` | Generate migrations from schema changes | +| `pnpm db:migrate` | Apply all pending migrations | +| `pnpm db:seed` | Seed the 6 teams + admin user (idempotent) | +| `pnpm db:reset` | Drop and recreate the local database (**blocked in production**) | +| `pnpm db:studio` | Open Drizzle Studio - visual database browser (dev only) | +| `pnpm test` | Run the unit test suite once | +| `pnpm test:watch` | Re-run tests on save (use while developing) | +| `pnpm test:ui` | Open the Vitest browser UI | +| `pnpm test:coverage` | Generate a coverage report | ## Environment Variables diff --git a/package.json b/package.json index 4737c20..62f2f21 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,10 @@ "format:check": "prettier --check .", "typecheck": "tsc --noEmit", "check": "pnpm lint && pnpm format:check && pnpm typecheck", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:seed": "tsx scripts/seed.ts", @@ -63,6 +67,8 @@ "@types/node": "^25", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.1.9", + "@vitest/ui": "^4.1.9", "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.9", @@ -74,6 +80,8 @@ "shadcn": "^4.11.0", "tailwindcss": "^4", "tsx": "^4.22.4", - "typescript": "^6" + "typescript": "^6", + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.9" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e96105e..6d0f169 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 1.2.5(@types/react@19.2.15)(react@19.2.7) better-auth: specifier: ^1.6.18 - version: 1.6.18(better-sqlite3@12.10.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.1)(kysely@0.28.17))(next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.6.18(better-sqlite3@12.10.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.1)(kysely@0.28.17))(next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) better-sqlite3: specifier: ^12.10.1 version: 12.10.1 @@ -99,6 +99,12 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.15) + '@vitest/coverage-v8': + specifier: ^4.1.9 + version: 4.1.9(vitest@4.1.9) + '@vitest/ui': + specifier: ^4.1.9 + version: 4.1.9(vitest@4.1.9) drizzle-kit: specifier: ^0.31.10 version: 0.31.10 @@ -135,6 +141,12 @@ importers: typescript: specifier: ^6 version: 6.0.3 + vite-tsconfig-paths: + specifier: ^6.1.1 + version: 6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@25.9.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -302,6 +314,10 @@ packages: '@types/react': optional: true + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@better-auth/core@1.6.18': resolution: {integrity: sha512-mlPZBKHY6gZdJtuY6LiuKjN2r8AWu0LCss7CEnI0xMbE9D7Sw6WofwPPwiMm+0Hi0QBKIaZGYRmR7/WasbOs+Q==} peerDependencies: @@ -1309,6 +1325,12 @@ packages: resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} engines: {node: '>=14'} + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@radix-ui/react-compose-refs@1.1.3': resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} peerDependencies: @@ -1327,6 +1349,104 @@ packages: '@types/react': optional: true + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1458,6 +1578,12 @@ packages: '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -1663,6 +1789,49 @@ packages: cpu: [x64] os: [win32] + '@vitest/coverage-v8@4.1.9': + resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} + peerDependencies: + '@vitest/browser': 4.1.9 + vitest: 4.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/ui@4.1.9': + resolution: {integrity: sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==} + peerDependencies: + vitest: 4.1.9 + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1761,6 +1930,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -1768,6 +1941,9 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1932,6 +2108,10 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2326,6 +2506,9 @@ packages: resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -2498,6 +2681,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2529,6 +2715,10 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} @@ -2578,6 +2768,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -2724,6 +2917,9 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2768,6 +2964,9 @@ packages: resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} engines: {node: '>=16.9.0'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -3011,6 +3210,18 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -3026,6 +3237,9 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3218,6 +3432,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3287,6 +3508,10 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3406,6 +3631,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -3482,6 +3711,9 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3708,6 +3940,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} @@ -3810,6 +4047,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -3823,6 +4063,10 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -3854,6 +4098,9 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} @@ -3861,6 +4108,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -3986,6 +4236,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.2.3: resolution: {integrity: sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==} engines: {node: '>=18'} @@ -3998,6 +4251,14 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4006,6 +4267,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -4015,6 +4280,17 @@ packages: ts-morph@26.0.0: resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + deprecated: unmaintained + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -4127,6 +4403,95 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-tsconfig-paths@6.1.1: + resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} + peerDependencies: + vite: '*' + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + 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 + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + 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 + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -4157,6 +4522,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4440,6 +4810,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.15 + '@bcoe/v8-coverage@1.0.2': {} + '@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.1 @@ -5174,6 +5546,10 @@ snapshots: '@opentelemetry/semantic-conventions@1.41.1': {} + '@oxc-project/types@0.133.0': {} + + '@polka/url@1.0.0-next.29': {} + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.15)(react@19.2.7)': dependencies: react: 19.2.7 @@ -5187,6 +5563,57 @@ snapshots: optionalDependencies: '@types/react': 19.2.15 + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rtsao/scc@1.1.0': {} '@sec-ant/readable-stream@0.4.1': {} @@ -5293,6 +5720,13 @@ snapshots: dependencies: '@types/node': 20.19.41 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -5478,6 +5912,72 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.9 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@25.9.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/ui@4.1.9(vitest@4.1.9)': + dependencies: + '@vitest/utils': 4.1.9 + fflate: 0.8.3 + flatted: 3.4.2 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@25.9.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -5598,12 +6098,20 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} ast-types@0.16.1: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} available-typed-arrays@1.0.7: @@ -5622,7 +6130,7 @@ snapshots: baseline-browser-mapping@2.10.32: {} - better-auth@1.6.18(better-sqlite3@12.10.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.1)(kysely@0.28.17))(next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + better-auth@1.6.18(better-sqlite3@12.10.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.1)(kysely@0.28.17))(next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9): dependencies: '@better-auth/core': 1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.1)(kysely@0.28.17)) @@ -5648,6 +6156,7 @@ snapshots: next: 16.2.9(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + vitest: 4.1.9(@types/node@25.9.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -5745,6 +6254,8 @@ snapshots: caniuse-lite@1.0.30001793: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -6073,6 +6584,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -6190,7 +6703,7 @@ snapshots: '@next/eslint-plugin-next': 16.2.9 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) @@ -6217,7 +6730,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -6232,14 +6745,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -6254,7 +6767,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -6394,6 +6907,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} etag@1.8.1: {} @@ -6435,6 +6952,8 @@ snapshots: expand-template@2.0.3: {} + expect-type@1.3.0: {} + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 @@ -6512,6 +7031,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -6663,6 +7184,8 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + globrex@0.1.2: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -6697,6 +7220,8 @@ snapshots: hono@4.12.23: {} + html-escaper@2.0.2: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -6907,6 +7432,19 @@ snapshots: isexe@3.1.5: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -6922,6 +7460,8 @@ snapshots: jose@6.2.3: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -7087,6 +7627,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.1 + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} @@ -7134,6 +7684,8 @@ snapshots: mkdirp-classic@0.5.3: {} + mrmime@2.0.1: {} + ms@2.1.3: {} nanoid@3.3.12: {} @@ -7251,6 +7803,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + obug@2.1.3: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -7338,6 +7892,8 @@ snapshots: path-to-regexp@8.4.2: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -7521,6 +8077,27 @@ snapshots: rfdc@1.4.1: {} + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + rou3@0.7.12: {} router@2.2.0: @@ -7726,6 +8303,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -7738,6 +8317,12 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sisteransi@1.0.5: {} slice-ansi@7.1.2: @@ -7766,6 +8351,8 @@ snapshots: stable-hash@0.0.5: {} + stackback@0.0.2: {} + standardwebhooks@1.0.0: dependencies: '@stablelib/base64': 1.0.1 @@ -7773,6 +8360,8 @@ snapshots: statuses@2.0.2: {} + std-env@4.1.0: {} + stdin-discarder@0.2.2: {} stop-iteration-iterator@1.1.0: @@ -7909,6 +8498,8 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + tinyexec@1.2.3: {} tinyexec@1.2.4: {} @@ -7918,12 +8509,21 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + totalist@3.0.1: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -7933,6 +8533,10 @@ snapshots: '@ts-morph/common': 0.27.0 code-block-writer: 13.0.3 + tsconfck@3.1.6(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -8086,6 +8690,60 @@ snapshots: vary@1.1.2: {} + vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@6.0.3) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + - typescript + + vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.1 + esbuild: 0.28.0 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.22.4 + yaml: 2.9.0 + + vitest@4.1.9(@types/node@25.9.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@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.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.1 + '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) + '@vitest/ui': 4.1.9(vitest@4.1.9) + transitivePeerDependencies: + - msw + web-streams-polyfill@3.3.3: {} which-boxed-primitive@1.1.1: @@ -8137,6 +8795,11 @@ snapshots: dependencies: isexe: 3.1.5 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@10.0.0: diff --git a/src/lib/api-key.test.ts b/src/lib/api-key.test.ts new file mode 100644 index 0000000..1e05fcb --- /dev/null +++ b/src/lib/api-key.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { hashApiKey, generateApiKey } from "./api-key"; + +describe("hashApiKey", () => { + it("returns a 64-character hex string (SHA-256)", () => { + const hash = hashApiKey("some-key"); + expect(hash).toHaveLength(64); + expect(hash).toMatch(/^[0-9a-f]+$/); + }); + + it("is deterministic — same input always produces the same hash", () => { + expect(hashApiKey("test")).toBe(hashApiKey("test")); + expect(hashApiKey("tf_abc123")).toBe(hashApiKey("tf_abc123")); + }); + + it("produces different hashes for different inputs", () => { + expect(hashApiKey("key-a")).not.toBe(hashApiKey("key-b")); + }); +}); + +describe("generateApiKey", () => { + it("returns a key starting with 'tf_'", () => { + const { raw } = generateApiKey(); + expect(raw).toMatch(/^tf_/); + }); + + it("returns a prefix that is the first 8 characters of the raw key", () => { + const { raw, prefix } = generateApiKey(); + expect(prefix).toBe(raw.slice(0, 8)); + expect(prefix).toHaveLength(8); + }); + + it("returns a hash that matches hashApiKey(raw)", () => { + const { raw, hash } = generateApiKey(); + expect(hash).toBe(hashApiKey(raw)); + }); + + it("generates unique keys on each call", () => { + const a = generateApiKey(); + const b = generateApiKey(); + expect(a.raw).not.toBe(b.raw); + expect(a.hash).not.toBe(b.hash); + }); +}); diff --git a/src/lib/finance.test.ts b/src/lib/finance.test.ts new file mode 100644 index 0000000..d066395 --- /dev/null +++ b/src/lib/finance.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { orderTotalCents } from "./finance"; + +describe("orderTotalCents", () => { + it("multiplies quantity by unit cost in cents", () => { + expect(orderTotalCents(2, 5000)).toBe(10000); + expect(orderTotalCents(1, 9999)).toBe(9999); + expect(orderTotalCents(10, 100)).toBe(1000); + }); + + it("returns 0 when quantity is 0", () => { + expect(orderTotalCents(0, 5000)).toBe(0); + }); + + it("returns 0 when unit cost is 0", () => { + expect(orderTotalCents(5, 0)).toBe(0); + }); + + it("handles large quantities and costs without overflow", () => { + // 9999 units × $9999.99 ceiling — should not produce NaN or Infinity + const result = orderTotalCents(9999, 999999); + expect(Number.isFinite(result)).toBe(true); + expect(result).toBe(9999 * 999999); + }); +}); diff --git a/src/lib/order-export.test.ts b/src/lib/order-export.test.ts new file mode 100644 index 0000000..41211c0 --- /dev/null +++ b/src/lib/order-export.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from "vitest"; +import { + stfOrderCalculations, + formatStfOrderRow, + formatGiftOrderRow, + formatOrderForExcel, + formatApprovedStfOrders, + formatApprovedGiftOrders, + STF_PRICE_FLUX, + STF_TAX_RATE, + STF_SHIPPING_RATE, + type OrderExportRow, +} from "./order-export"; + +const BASE_STF: OrderExportRow = { + itemName: "Motor Controller", + fundType: "STF", + stfBucketName: "Drivetrain", + quantity: 2, + unitCostCents: 5000, // $50.00 + vendor: "Amazon", + link: "https://example.com/motor", + notes: null, + partNumber: "MT-001", + createdAt: new Date("2024-06-15"), + status: "approved", +}; + +const BASE_GIFT: OrderExportRow = { + itemName: "Bolts", + fundType: "Gift", + stfBucketName: null, + quantity: 10, + unitCostCents: 299, // $2.99 + vendor: "Home Depot", + link: "https://example.com/bolts", + notes: "M4 x 20mm", + partNumber: "HD-BOLT-M4", + createdAt: new Date("2024-06-15"), + status: "approved", +}; + +describe("stfOrderCalculations", () => { + it("applies flux, tax, and shipping multipliers correctly", () => { + // qty=2, unitCostCents=5000 → unitCost=$50 + const calc = stfOrderCalculations(2, 5000); + expect(calc.unitCost).toBe(50); + expect(calc.unitCostFlux).toBeCloseTo(50 * STF_PRICE_FLUX); + expect(calc.preTaxTotal).toBeCloseTo(2 * 50 * STF_PRICE_FLUX); + expect(calc.tax).toBeCloseTo(calc.preTaxTotal * STF_TAX_RATE); + expect(calc.shipping).toBeCloseTo(calc.preTaxTotal * STF_SHIPPING_RATE); + expect(calc.total).toBeCloseTo(calc.preTaxTotal + calc.tax + calc.shipping); + }); + + it("returns zero for all fields when quantity is 0", () => { + const calc = stfOrderCalculations(0, 5000); + expect(calc.preTaxTotal).toBe(0); + expect(calc.tax).toBe(0); + expect(calc.shipping).toBe(0); + expect(calc.total).toBe(0); + }); + + it("returns zero for all fields when unit cost is 0", () => { + const calc = stfOrderCalculations(5, 0); + expect(calc.unitCost).toBe(0); + expect(calc.total).toBe(0); + }); + + it("calculates total as preTaxTotal + tax + shipping", () => { + const calc = stfOrderCalculations(3, 1000); + expect(calc.total).toBeCloseTo(calc.preTaxTotal + calc.tax + calc.shipping, 10); + }); +}); + +describe("formatStfOrderRow", () => { + it("produces a tab-separated row", () => { + const row = formatStfOrderRow(BASE_STF); + const cols = row.split("\t"); + expect(cols.length).toBeGreaterThan(1); + }); + + it("includes the vendor and item name", () => { + const row = formatStfOrderRow(BASE_STF); + expect(row).toContain("Amazon"); + expect(row).toContain("Motor Controller"); + }); + + it("includes the bucket name", () => { + const row = formatStfOrderRow(BASE_STF); + expect(row).toContain("Drivetrain"); + }); + + it("uses empty string when stfBucketName is null", () => { + const row = formatStfOrderRow({ ...BASE_STF, stfBucketName: null }); + // First column should be empty, so row starts with a tab + expect(row.startsWith("\t")).toBe(true); + }); + + it("prepends header row when includeHeader is true", () => { + const output = formatStfOrderRow(BASE_STF, true); + const [header] = output.split("\n"); + expect(header).toContain("Vendor"); + expect(header).toContain("TOTAL"); + }); +}); + +describe("formatGiftOrderRow", () => { + it("produces a tab-separated row", () => { + const row = formatGiftOrderRow(BASE_GIFT); + expect(row.split("\t").length).toBeGreaterThan(1); + }); + + it("includes the vendor, item name, and notes", () => { + const row = formatGiftOrderRow(BASE_GIFT); + expect(row).toContain("Home Depot"); + expect(row).toContain("Bolts"); + expect(row).toContain("M4 x 20mm"); + }); + + it("uses empty string when notes is null", () => { + const row = formatGiftOrderRow({ ...BASE_GIFT, notes: null }); + // The last column (notes) should be empty — row ends with a tab + expect(row.endsWith("\t")).toBe(true); + }); + + it("prepends header row when includeHeader is true", () => { + const output = formatGiftOrderRow(BASE_GIFT, true); + const [header] = output.split("\n"); + expect(header).toContain("Vendor"); + expect(header).toContain("Item Name"); + }); +}); + +describe("formatOrderForExcel", () => { + it("returns null for non-approved orders", () => { + expect(formatOrderForExcel({ ...BASE_STF, status: "pending" })).toBeNull(); + expect(formatOrderForExcel({ ...BASE_STF, status: "denied" })).toBeNull(); + }); + + it("returns an STF row for an approved STF order", () => { + const result = formatOrderForExcel(BASE_STF); + expect(result).not.toBeNull(); + expect(result).toContain("Drivetrain"); + }); + + it("returns a Gift row for an approved Gift order", () => { + const result = formatOrderForExcel(BASE_GIFT); + expect(result).not.toBeNull(); + expect(result).toContain("Home Depot"); + }); +}); + +describe("formatApprovedStfOrders", () => { + it("returns empty string when there are no approved STF orders", () => { + expect(formatApprovedStfOrders([])).toBe(""); + expect(formatApprovedStfOrders([{ ...BASE_STF, status: "pending" }])).toBe(""); + }); + + it("returns one row per approved STF order joined by newline", () => { + const orders = [BASE_STF, { ...BASE_STF, itemName: "Wheel" }]; + const result = formatApprovedStfOrders(orders); + expect(result.split("\n")).toHaveLength(2); + }); + + it("excludes Gift orders", () => { + const result = formatApprovedStfOrders([BASE_GIFT]); + expect(result).toBe(""); + }); +}); + +describe("formatApprovedGiftOrders", () => { + it("returns empty string when there are no approved Gift orders", () => { + expect(formatApprovedGiftOrders([])).toBe(""); + expect(formatApprovedGiftOrders([{ ...BASE_GIFT, status: "denied" }])).toBe(""); + }); + + it("returns one row per approved Gift order", () => { + const orders = [BASE_GIFT, { ...BASE_GIFT, itemName: "Screws" }]; + const result = formatApprovedGiftOrders(orders); + expect(result.split("\n")).toHaveLength(2); + }); + + it("excludes STF orders", () => { + const result = formatApprovedGiftOrders([BASE_STF]); + expect(result).toBe(""); + }); +}); diff --git a/src/lib/rate-limit.test.ts b/src/lib/rate-limit.test.ts new file mode 100644 index 0000000..c021f48 --- /dev/null +++ b/src/lib/rate-limit.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { rateLimit, clientIp } from "./rate-limit"; + +// Each test uses a unique key so module-level LRU cache doesn't bleed between tests. +let keyCounter = 0; +function uniqueKey() { + return `test-key-${++keyCounter}`; +} + +describe("rateLimit", () => { + it("allows the first request", () => { + const result = rateLimit(uniqueKey()); + expect(result.allowed).toBe(true); + expect(result.retryAfter).toBe(0); + }); + + it("allows up to 30 requests on a fresh key", () => { + const key = uniqueKey(); + for (let i = 0; i < 30; i++) { + expect(rateLimit(key).allowed).toBe(true); + } + }); + + it("blocks the 31st request and returns a positive retryAfter", () => { + const key = uniqueKey(); + for (let i = 0; i < 30; i++) rateLimit(key); + const result = rateLimit(key); + expect(result.allowed).toBe(false); + expect(result.retryAfter).toBeGreaterThan(0); + }); + + it("tracks buckets independently per key", () => { + const keyA = uniqueKey(); + const keyB = uniqueKey(); + for (let i = 0; i < 30; i++) rateLimit(keyA); + // Exhausting keyA does not affect keyB + expect(rateLimit(keyA).allowed).toBe(false); + expect(rateLimit(keyB).allowed).toBe(true); + }); +}); + +describe("clientIp", () => { + it("reads from x-forwarded-for", () => { + const req = new Request("http://localhost", { + headers: { "x-forwarded-for": "1.2.3.4" }, + }); + expect(clientIp(req)).toBe("1.2.3.4"); + }); + + it("uses the first IP when x-forwarded-for contains a chain", () => { + const req = new Request("http://localhost", { + headers: { "x-forwarded-for": "1.2.3.4, 5.6.7.8, 9.10.11.12" }, + }); + expect(clientIp(req)).toBe("1.2.3.4"); + }); + + it("falls back to cf-connecting-ip when x-forwarded-for is absent", () => { + const req = new Request("http://localhost", { + headers: { "cf-connecting-ip": "203.0.113.1" }, + }); + expect(clientIp(req)).toBe("203.0.113.1"); + }); + + it("returns unknown when no IP headers are present", () => { + const req = new Request("http://localhost"); + expect(clientIp(req)).toBe("unknown"); + }); +}); diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 0000000..bbd6147 --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { formatPriceCents, formatDate } from "./utils"; + +describe("formatPriceCents", () => { + it("formats typical cents as USD", () => { + expect(formatPriceCents(1050)).toBe("$10.50"); + expect(formatPriceCents(100)).toBe("$1.00"); + expect(formatPriceCents(1)).toBe("$0.01"); + }); + + it("formats zero", () => { + expect(formatPriceCents(0)).toBe("$0.00"); + }); + + it("formats large amounts with commas", () => { + expect(formatPriceCents(100000)).toBe("$1,000.00"); + expect(formatPriceCents(1000000)).toBe("$10,000.00"); + }); + + it("returns dash for null", () => { + expect(formatPriceCents(null)).toBe("-"); + }); + + it("returns dash for undefined", () => { + expect(formatPriceCents(undefined)).toBe("-"); + }); +}); + +describe("formatDate", () => { + it("returns dash for null", () => { + expect(formatDate(null)).toBe("-"); + }); + + it("returns dash for undefined", () => { + expect(formatDate(undefined)).toBe("-"); + }); + + it("formats a Date object and includes the year", () => { + const date = new Date("2024-06-15T12:00:00Z"); + const result = formatDate(date); + expect(result).toContain("2024"); + }); + + it("accepts a unix timestamp in milliseconds", () => { + // Use a mid-day UTC time so timezone offsets don't push the date into the adjacent year + const ts = new Date("2025-06-15T12:00:00Z").getTime(); + const result = formatDate(ts); + expect(result).toContain("2025"); + }); + + it("produces the same output for a Date and its equivalent timestamp", () => { + const date = new Date("2025-03-01T10:00:00Z"); + expect(formatDate(date)).toBe(formatDate(date.getTime())); + }); +}); diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts new file mode 100644 index 0000000..133d457 --- /dev/null +++ b/src/lib/validation.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from "vitest"; +import { + orderInputSchema, + giftFundAdjustSchema, + vaultEntrySchema, + whitelistRequestSchema, + stfBucketInputSchema, + updateUserSchema, +} from "./validation"; + +describe("orderInputSchema", () => { + const validStf = { + fundType: "STF", + stfBucketId: 1, + vendor: "Amazon", + link: "https://example.com/item", + itemName: "Motor", + partNumber: "MT-001", + quantity: 2, + unitCost: 50, + }; + + const validGift = { + fundType: "Gift", + vendor: "Home Depot", + link: "https://example.com/bolt", + itemName: "Bolts", + quantity: 10, + unitCost: 2.99, + notes: "M4 x 20mm", + }; + + it("accepts a valid STF order", () => { + expect(() => orderInputSchema.parse(validStf)).not.toThrow(); + }); + + it("accepts a valid Gift order", () => { + expect(() => orderInputSchema.parse(validGift)).not.toThrow(); + }); + + it("rejects STF order missing stfBucketId", () => { + const result = orderInputSchema.safeParse({ ...validStf, stfBucketId: undefined }); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path.join(".")); + expect(paths).toContain("stfBucketId"); + } + }); + + it("rejects STF order missing partNumber", () => { + const result = orderInputSchema.safeParse({ ...validStf, partNumber: undefined }); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path.join(".")); + expect(paths).toContain("partNumber"); + } + }); + + it("rejects Gift order missing notes", () => { + const result = orderInputSchema.safeParse({ ...validGift, notes: undefined }); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path.join(".")); + expect(paths).toContain("notes"); + } + }); + + it("rejects quantity less than 1", () => { + const result = orderInputSchema.safeParse({ ...validStf, quantity: 0 }); + expect(result.success).toBe(false); + }); + + it("rejects unitCost of 0", () => { + const result = orderInputSchema.safeParse({ ...validStf, unitCost: 0 }); + expect(result.success).toBe(false); + }); + + it("rejects an invalid URL for link", () => { + const result = orderInputSchema.safeParse({ ...validStf, link: "not-a-url" }); + expect(result.success).toBe(false); + }); +}); + +describe("giftFundAdjustSchema", () => { + it("accepts a valid positive value", () => { + expect(() => giftFundAdjustSchema.parse({ newValue: 500 })).not.toThrow(); + }); + + it("accepts zero", () => { + expect(() => giftFundAdjustSchema.parse({ newValue: 0 })).not.toThrow(); + }); + + it("accepts an optional note", () => { + expect(() => giftFundAdjustSchema.parse({ newValue: 100, note: "Top-up" })).not.toThrow(); + }); + + it("rejects a negative value", () => { + const result = giftFundAdjustSchema.safeParse({ newValue: -1 }); + expect(result.success).toBe(false); + }); + + it("rejects a value above the maximum", () => { + const result = giftFundAdjustSchema.safeParse({ newValue: 10_000_001 }); + expect(result.success).toBe(false); + }); +}); + +describe("vaultEntrySchema (discriminated union)", () => { + it("accepts a valid login entry", () => { + expect(() => + vaultEntrySchema.parse({ + type: "login", + name: "GitHub", + username: "robot", + secret: "hunter2", + }) + ).not.toThrow(); + }); + + it("accepts a valid api_key entry", () => { + expect(() => + vaultEntrySchema.parse({ + type: "api_key", + name: "Stripe Key", + secret: "sk_live_abc", + }) + ).not.toThrow(); + }); + + it("rejects a login entry missing username", () => { + const result = vaultEntrySchema.safeParse({ + type: "login", + name: "GitHub", + secret: "hunter2", + }); + expect(result.success).toBe(false); + }); + + it("rejects an entry missing secret", () => { + const result = vaultEntrySchema.safeParse({ + type: "api_key", + name: "Stripe Key", + secret: "", + }); + expect(result.success).toBe(false); + }); + + it("rejects an unknown type", () => { + const result = vaultEntrySchema.safeParse({ + type: "ssh_key", + name: "Server", + secret: "pem-data", + }); + expect(result.success).toBe(false); + }); +}); + +describe("whitelistRequestSchema", () => { + it("accepts a valid Minecraft username", () => { + expect(() => whitelistRequestSchema.parse({ username: "Steve_123" })).not.toThrow(); + }); + + it("rejects usernames shorter than 3 characters", () => { + const result = whitelistRequestSchema.safeParse({ username: "ab" }); + expect(result.success).toBe(false); + }); + + it("rejects usernames longer than 16 characters", () => { + const result = whitelistRequestSchema.safeParse({ username: "a".repeat(17) }); + expect(result.success).toBe(false); + }); + + it("rejects usernames with spaces or special characters", () => { + expect(whitelistRequestSchema.safeParse({ username: "has space" }).success).toBe(false); + expect(whitelistRequestSchema.safeParse({ username: "has-dash" }).success).toBe(false); + expect(whitelistRequestSchema.safeParse({ username: "has.dot" }).success).toBe(false); + }); + + it("accepts underscores and mixed case", () => { + expect(() => whitelistRequestSchema.parse({ username: "Cool_Player" })).not.toThrow(); + }); +}); + +describe("stfBucketInputSchema", () => { + it("accepts valid name and balance", () => { + expect(() => + stfBucketInputSchema.parse({ name: "Drivetrain", startingBalance: 1000 }) + ).not.toThrow(); + }); + + it("rejects empty name", () => { + const result = stfBucketInputSchema.safeParse({ name: "", startingBalance: 500 }); + expect(result.success).toBe(false); + }); + + it("rejects negative balance", () => { + const result = stfBucketInputSchema.safeParse({ name: "Arm", startingBalance: -1 }); + expect(result.success).toBe(false); + }); +}); + +describe("updateUserSchema", () => { + it("accepts updating a single field", () => { + expect(() => updateUserSchema.parse({ role: "admin" })).not.toThrow(); + expect(() => updateUserSchema.parse({ isActive: false })).not.toThrow(); + }); + + it("rejects an update with no fields set", () => { + const result = updateUserSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it("rejects invalid role values", () => { + const result = updateUserSchema.safeParse({ role: "superuser" }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/lib/vault-crypto.test.ts b/src/lib/vault-crypto.test.ts new file mode 100644 index 0000000..80eed2f --- /dev/null +++ b/src/lib/vault-crypto.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeAll } from "vitest"; + +// Set the key before importing the module so getVaultKey() uses it from env +// and never touches the filesystem. 32 bytes = 64 hex chars. +const TEST_KEY = "a".repeat(64); +beforeAll(() => { + process.env.VAULT_ENCRYPTION_KEY = TEST_KEY; +}); + +// Dynamic import so the module loads after the env var is set above. +// Vitest hoists static imports before beforeAll, so we use dynamic import here. +const { encryptSecret, decryptSecret } = await import("./vault-crypto"); + +describe("encryptSecret / decryptSecret", () => { + it("roundtrip: decrypting an encrypted value returns the original plaintext", () => { + const original = "super-secret-password"; + const payload = encryptSecret(original); + expect(decryptSecret(payload)).toBe(original); + }); + + it("handles a short single-character secret", () => { + const payload = encryptSecret("x"); + expect(decryptSecret(payload)).toBe("x"); + }); + + it("handles unicode and special characters", () => { + const original = "pässwörд 🔐