diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2085006..1270bb8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,17 +7,26 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Install Dependencies - run: npm install -g pnpm && pnpm install + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 - - name: Copy .env.example files - shell: bash - run: find . -type f -name ".env.example" -exec sh -c 'cp "$1" "${1%.*}"' _ {} \; + with: + node-version: 22 + cache: pnpm + + - name: Install Dependencies + run: pnpm install --frozen-lockfile - name: Typecheck run: pnpm typecheck - name: Check run: pnpm check + + - name: Test + run: pnpm test diff --git a/.husky/pre-commit b/.husky/pre-commit index f26fea3..2afd34c 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,18 @@ #!/bin/sh -pnpm check && pnpm typecheck +run_step() { + label="$1" + shift + output=$("$@" 2>&1) + status=$? + if [ $status -ne 0 ]; then + echo "✗ $label failed" + echo "$output" + exit $status + fi + echo "✓ $label done" +} + +run_step "check" pnpm check +run_step "typecheck" pnpm typecheck +run_step "test" pnpm test:unit diff --git a/package.json b/package.json index e189dcd..0df418d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,11 @@ "preview": "next build && next start", "start": "next start", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:unit": "vitest run --exclude tests/integration/**", + "test:integration": "vitest run tests/integration", "prepare": "husky" }, "dependencies": { @@ -53,12 +58,14 @@ "@types/node": "^20.14.10", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^6.0.3", "husky": "^9.1.7", "oxfmt": "^0.54.0", "oxlint": "^1.69.0", "postcss": "^8.5.3", "tailwindcss": "^4.0.15", - "typescript": "^5.8.2" + "typescript": "^5.8.2", + "vitest": "^4.1.10" }, "packageManager": "pnpm@11.3.0", "ct3aMetadata": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcc3444..608d275 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: '@types/react-dom': specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(vite@8.1.4(@types/node@20.19.42)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)) husky: specifier: ^9.1.7 version: 9.1.7 @@ -126,6 +129,9 @@ importers: typescript: specifier: ^5.8.2 version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@20.19.42)(vite@8.1.4(@types/node@20.19.42)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)) packages: @@ -306,9 +312,18 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/runtime@1.11.0': resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -965,6 +980,12 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@next/env@15.5.19': resolution: {integrity: sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==} @@ -1044,6 +1065,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@oxfmt/binding-android-arm-eabi@0.54.0': resolution: {integrity: sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1288,6 +1312,104 @@ packages: cpu: [x64] os: [win32] + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -1326,6 +1448,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1482,12 +1607,21 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@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/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1526,6 +1660,48 @@ packages: '@ungap/structured-clone@1.3.2': resolution: {integrity: sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==} + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + 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.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1560,6 +1736,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -1635,6 +1815,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -1949,6 +2133,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -1983,6 +2170,9 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -2007,6 +2197,10 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} @@ -2693,6 +2887,10 @@ packages: resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} engines: {node: '>= 10'} + 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'} @@ -2781,6 +2979,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==} @@ -2792,6 +2993,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -2808,6 +3013,10 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.19: + resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} + engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -2922,6 +3131,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -3001,6 +3215,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} 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==} @@ -3037,10 +3254,16 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -3126,10 +3349,25 @@ 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.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@2.1.0: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.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'} @@ -3229,6 +3467,90 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@8.1.4: + resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + 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 + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + 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.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + 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'} @@ -3243,6 +3565,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 + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3507,11 +3834,27 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.0': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -3905,6 +4248,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + '@next/env@15.5.19': {} '@next/swc-darwin-arm64@15.5.19': @@ -3951,6 +4301,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@oxc-project/types@0.139.0': {} + '@oxfmt/binding-android-arm-eabi@0.54.0': optional: true @@ -4065,6 +4417,57 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.69.0': optional: true + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@sec-ant/readable-stream@0.4.1': {} '@shikijs/core@4.3.1': @@ -4109,6 +4512,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -4224,14 +4629,26 @@ snapshots: minimatch: 10.2.5 path-browserify: 1.0.1 + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@types/better-sqlite3@7.6.13': dependencies: '@types/node': 20.19.42 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.9 @@ -4268,6 +4685,52 @@ snapshots: '@ungap/structured-clone@1.3.2': {} + '@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@20.19.42)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.4(@types/node@20.19.42)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@20.19.42)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.4(@types/node@20.19.42)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -4294,6 +4757,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -4378,6 +4843,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@5.6.2: {} character-entities-html4@2.1.0: {} @@ -4552,6 +5019,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -4647,6 +5116,10 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + etag@1.8.1: {} eventsource-parser@3.1.0: {} @@ -4684,6 +5157,8 @@ snapshots: expand-template@2.0.3: {} + expect-type@1.4.0: {} + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 @@ -5430,6 +5905,8 @@ snapshots: object-treeify@1.1.33: {} + obug@2.1.3: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -5554,12 +6031,16 @@ snapshots: path-to-regexp@8.4.2: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} picomatch@4.0.4: {} + picomatch@4.0.5: {} + pkce-challenge@5.0.1: {} postcss-selector-parser@7.1.2: @@ -5579,6 +6060,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.19: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + powershell-utils@0.1.0: {} prebuild-install@7.1.3: @@ -5705,6 +6192,27 @@ snapshots: reusify@1.1.0: {} + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + router@2.2.0: dependencies: debug: 4.4.3 @@ -5878,6 +6386,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: {} @@ -5908,8 +6418,12 @@ snapshots: space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.2.0: {} + stdin-discarder@0.2.2: {} string-width@7.2.0: @@ -5991,8 +6505,19 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinypool@2.1.0: {} + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -6091,6 +6616,47 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite@8.1.4(@types/node@20.19.42)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.19 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.42 + esbuild: 0.28.0 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.22.4 + + vitest@4.1.10(@types/node@20.19.42)(vite@8.1.4(@types/node@20.19.42)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@20.19.42)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.4(@types/node@20.19.42)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.42 + transitivePeerDependencies: + - msw + web-streams-polyfill@3.3.3: {} which@2.0.2: @@ -6101,6 +6667,11 @@ snapshots: dependencies: isexe: 3.1.5 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrappy@1.0.2: {} wsl-utils@0.3.1: diff --git a/src/lib/endpoint-data-store.test.ts b/src/lib/endpoint-data-store.test.ts new file mode 100644 index 0000000..5c5267e --- /dev/null +++ b/src/lib/endpoint-data-store.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { + getDataDir, + getData, + generateAndSaveData, + invalidateEndpointData, + deleteProjectData, +} from "./endpoint-data-store"; +import { isolatePlatformEnv } from "./test-helpers/isolate-platform"; + +describe("getDataDir", () => { + const platformEnv = isolatePlatformEnv(); + + afterEach(() => { + platformEnv.restore(); + }); + + it("uses XDG_DATA_HOME on linux when set", () => { + platformEnv.setPlatform("linux"); + process.env.XDG_DATA_HOME = "/custom/xdg"; + expect(getDataDir()).toBe(path.join("/custom/xdg", "voidend")); + }); + + it("falls back to ~/.local/share on linux when XDG_DATA_HOME is unset", () => { + platformEnv.setPlatform("linux"); + delete process.env.XDG_DATA_HOME; + expect(getDataDir()).toBe(path.join(os.homedir(), ".local/share", "voidend")); + }); + + it("uses Library/Application Support on darwin", () => { + platformEnv.setPlatform("darwin"); + expect(getDataDir()).toBe(path.join(os.homedir(), "Library/Application Support/voidend")); + }); + + it("uses APPDATA on win32 when set", () => { + platformEnv.setPlatform("win32"); + process.env.APPDATA = "C:\\Users\\test\\AppData\\Roaming"; + expect(getDataDir()).toBe(path.join("C:\\Users\\test\\AppData\\Roaming", "voidend")); + }); + + it("falls back to homedir on win32 when APPDATA is unset", () => { + platformEnv.setPlatform("win32"); + delete process.env.APPDATA; + expect(getDataDir()).toBe(path.join(os.homedir(), "voidend")); + }); + + it("falls back to ~/.voidend on unknown platforms", () => { + platformEnv.setPlatform("freebsd"); + expect(getDataDir()).toBe(path.join(os.homedir(), ".voidend")); + }); +}); + +describe("endpoint-data-store (file operations)", () => { + let tempDir: string; + const platformEnv = isolatePlatformEnv(); + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "voidend-test-")); + platformEnv.setPlatform("linux"); + process.env.XDG_DATA_HOME = tempDir; + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + platformEnv.restore(); + }); + + describe("getData", () => { + it("returns null when no data file exists", () => { + expect(getData("proj1", "endpoint1")).toBe(null); + }); + + it("returns parsed JSON when data file exists", () => { + generateAndSaveData("proj1", "endpoint1", { name: "static" }, 1); + expect(getData("proj1", "endpoint1")).toEqual({ name: "static" }); + }); + }); + + describe("generateAndSaveData", () => { + it("creates the project directory if it does not exist", () => { + generateAndSaveData("proj1", "endpoint1", { name: "static" }, 1); + const dir = path.join(tempDir, "voidend", "proj1"); + expect(fs.existsSync(dir)).toBe(true); + }); + + it("writes resolved single-object data when count is 1", () => { + const result = generateAndSaveData("proj1", "endpoint1", { name: "static" }, 1); + expect(result).toEqual({ name: "static" }); + expect(getData("proj1", "endpoint1")).toEqual({ name: "static" }); + }); + + it("writes resolved array data when count > 1", () => { + const result = generateAndSaveData( + "proj1", + "endpoint1", + { name: "static" }, + 3, + ) as unknown[]; + expect(result).toHaveLength(3); + expect(getData("proj1", "endpoint1")).toEqual(result); + }); + + it("overwrites existing data on repeated calls", () => { + generateAndSaveData("proj1", "endpoint1", { name: "first" }, 1); + generateAndSaveData("proj1", "endpoint1", { name: "second" }, 1); + expect(getData("proj1", "endpoint1")).toEqual({ name: "second" }); + }); + + it("keeps data for different endpoints within the same project isolated", () => { + generateAndSaveData("proj1", "endpointA", { name: "a" }, 1); + generateAndSaveData("proj1", "endpointB", { name: "b" }, 1); + expect(getData("proj1", "endpointA")).toEqual({ name: "a" }); + expect(getData("proj1", "endpointB")).toEqual({ name: "b" }); + }); + + it("keeps data for different projects isolated", () => { + generateAndSaveData("proj1", "endpoint1", { name: "p1" }, 1); + generateAndSaveData("proj2", "endpoint1", { name: "p2" }, 1); + expect(getData("proj1", "endpoint1")).toEqual({ name: "p1" }); + expect(getData("proj2", "endpoint1")).toEqual({ name: "p2" }); + }); + }); + + describe("invalidateEndpointData", () => { + it("removes an existing data file", () => { + generateAndSaveData("proj1", "endpoint1", { name: "static" }, 1); + invalidateEndpointData("proj1", "endpoint1"); + expect(getData("proj1", "endpoint1")).toBe(null); + }); + + it("does nothing (no throw) when the file does not exist", () => { + expect(() => invalidateEndpointData("proj1", "nonexistent")).not.toThrow(); + }); + + it("does not affect other endpoints in the same project", () => { + generateAndSaveData("proj1", "endpointA", { name: "a" }, 1); + generateAndSaveData("proj1", "endpointB", { name: "b" }, 1); + invalidateEndpointData("proj1", "endpointA"); + expect(getData("proj1", "endpointA")).toBe(null); + expect(getData("proj1", "endpointB")).toEqual({ name: "b" }); + }); + }); + + describe("deleteProjectData", () => { + it("removes the entire project directory", () => { + generateAndSaveData("proj1", "endpoint1", { name: "static" }, 1); + deleteProjectData("proj1"); + const dir = path.join(tempDir, "voidend", "proj1"); + expect(fs.existsSync(dir)).toBe(false); + }); + + it("removes all endpoints within the project", () => { + generateAndSaveData("proj1", "endpointA", { name: "a" }, 1); + generateAndSaveData("proj1", "endpointB", { name: "b" }, 1); + deleteProjectData("proj1"); + expect(getData("proj1", "endpointA")).toBe(null); + expect(getData("proj1", "endpointB")).toBe(null); + }); + + it("does nothing (no throw) when the project directory does not exist", () => { + expect(() => deleteProjectData("nonexistent")).not.toThrow(); + }); + + it("does not affect other projects", () => { + generateAndSaveData("proj1", "endpoint1", { name: "p1" }, 1); + generateAndSaveData("proj2", "endpoint1", { name: "p2" }, 1); + deleteProjectData("proj1"); + expect(getData("proj1", "endpoint1")).toBe(null); + expect(getData("proj2", "endpoint1")).toEqual({ name: "p2" }); + }); + }); +}); diff --git a/src/lib/mock-path.test.ts b/src/lib/mock-path.test.ts new file mode 100644 index 0000000..1817beb --- /dev/null +++ b/src/lib/mock-path.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { + cleanSegment, + joinPath, + normalizeBasePath, + stripBasePath, + matchPath, + buildMockPath, + buildMockUrl, + getMockOrigin, +} from "./mock-path"; + +describe("cleanSegment", () => { + it("strips leading slashes", () => { + expect(cleanSegment("///users")).toBe("users"); + }); + + it("strips trailing slashes", () => { + expect(cleanSegment("users///")).toBe("users"); + }); + + it("strips both leading and trailing slashes", () => { + expect(cleanSegment("/users/")).toBe("users"); + }); + + it("returns empty string unchanged", () => { + expect(cleanSegment("")).toBe(""); + }); + + it("returns a segment with no slashes unchanged", () => { + expect(cleanSegment("users")).toBe("users"); + }); + + it("does not touch internal slashes", () => { + expect(cleanSegment("/users/123/")).toBe("users/123"); + }); +}); + +describe("joinPath", () => { + it("joins multiple simple segments", () => { + expect(joinPath("api", "v1", "users")).toBe("/api/v1/users"); + }); + + it('returns "/" when given no segments', () => { + expect(joinPath()).toBe("/"); + }); + + it('returns "/" when all segments are empty/null/undefined', () => { + expect(joinPath("", null, undefined)).toBe("/"); + }); + + it("filters out empty segments among valid ones", () => { + expect(joinPath("api", "", "users")).toBe("/api/users"); + }); + + it("filters out null and undefined segments among valid ones", () => { + expect(joinPath("api", null, "users", undefined)).toBe("/api/users"); + }); + + it("cleans slashes within each segment before joining", () => { + expect(joinPath("/api/", "/v1/", "/users/")).toBe("/api/v1/users"); + }); + + it("handles a single segment", () => { + expect(joinPath("users")).toBe("/users"); + }); +}); + +describe("normalizeBasePath", () => { + it("returns empty string for undefined", () => { + expect(normalizeBasePath(undefined)).toBe(""); + }); + + it("returns empty string for null", () => { + expect(normalizeBasePath(null)).toBe(""); + }); + + it('returns empty string for "/"', () => { + expect(normalizeBasePath("/")).toBe(""); + }); + + it("returns empty string for empty string", () => { + expect(normalizeBasePath("")).toBe(""); + }); + + it("normalizes a simple base path", () => { + expect(normalizeBasePath("/api/v1")).toBe("/api/v1"); + }); + + it("adds leading slash if missing", () => { + expect(normalizeBasePath("api/v1")).toBe("/api/v1"); + }); + + it("strips trailing slash", () => { + expect(normalizeBasePath("/api/v1/")).toBe("/api/v1"); + }); +}); + +describe("stripBasePath", () => { + it("returns incoming path unchanged when basePath is empty/root", () => { + expect(stripBasePath("/users/123", "/")).toBe("/users/123"); + }); + + it("returns incoming path unchanged when basePath is undefined", () => { + expect(stripBasePath("/users/123", undefined)).toBe("/users/123"); + }); + + it("strips a matching basePath prefix", () => { + expect(stripBasePath("/api/v1/users", "/api/v1")).toBe("/users"); + }); + + it('returns "/" when incoming path equals the basePath exactly', () => { + expect(stripBasePath("/api/v1", "/api/v1")).toBe("/"); + }); + + it("returns incoming path unchanged when it does not start with basePath", () => { + expect(stripBasePath("/other/users", "/api/v1")).toBe("/other/users"); + }); + + it("does not falsely strip a prefix-like but non-matching path", () => { + expect(stripBasePath("/api/v10/users", "/api/v1")).toBe("/api/v10/users"); + }); +}); + +describe("matchPath", () => { + it("matches an exact static path", () => { + expect(matchPath("/users", "/users")).toBe(true); + }); + + it("does not match a different static path", () => { + expect(matchPath("/users", "/posts")).toBe(false); + }); + + it("matches a path with a single param", () => { + expect(matchPath("/users/:id", "/users/123")).toBe(true); + }); + + it("matches a path with multiple params", () => { + expect(matchPath("/users/:userId/posts/:postId", "/users/1/posts/99")).toBe(true); + }); + + it("does not match when incoming has extra segments", () => { + expect(matchPath("/users/:id", "/users/123/extra")).toBe(false); + }); + + it("does not match when incoming has fewer segments", () => { + expect(matchPath("/users/:id", "/users")).toBe(false); + }); + + it("does not match a param segment against an empty segment", () => { + expect(matchPath("/users/:id", "/users/")).toBe(false); + }); +}); + +describe("buildMockPath", () => { + it("joins basePath and endpoint path", () => { + expect(buildMockPath("/api/v1", "/users")).toBe("/api/v1/users"); + }); + + it("works with no basePath", () => { + expect(buildMockPath(undefined, "/users")).toBe("/users"); + }); + + it("works with no basePath and messy slashes", () => { + expect(buildMockPath(null, "///users///")).toBe("/users"); + }); +}); + +describe("buildMockUrl", () => { + it("builds a full mock URL with basePath", () => { + expect(buildMockUrl("http://localhost:3000", "proj1", "/api/v1", "/users")).toBe( + "http://localhost:3000/mock/proj1/api/v1/users", + ); + }); + + it("builds a full mock URL without basePath", () => { + expect(buildMockUrl("http://localhost:3000", "proj1", undefined, "/users")).toBe( + "http://localhost:3000/mock/proj1/users", + ); + }); +}); + +describe("getMockOrigin", () => { + const originalWindow = globalThis.window; + const originalEnv = { ...process.env }; + afterEach(() => { + if (originalWindow === undefined) { + // @ts-expect-error cleaning up test-only global + delete globalThis.window; + } else { + globalThis.window = originalWindow; + } + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); + }); + it("returns window.location.origin when window is defined", () => { + vi.stubGlobal("window", { location: { origin: "http://browser-origin.test" } }); + expect(getMockOrigin()).toBe("http://browser-origin.test"); + }); + it("falls back to localhost with default port when no window and no PORT set", () => { + // @ts-expect-error simulating server (no window) environment + delete globalThis.window; + delete process.env.PORT; + expect(getMockOrigin()).toBe("http://localhost:3000"); + }); + it("uses process.env.PORT when set and no window", () => { + // @ts-expect-error simulating server (no window) environment + delete globalThis.window; + process.env.PORT = "4000"; + expect(getMockOrigin()).toBe("http://localhost:4000"); + }); +}); diff --git a/src/lib/mock-path.ts b/src/lib/mock-path.ts index 4b69952..35e4ce8 100644 --- a/src/lib/mock-path.ts +++ b/src/lib/mock-path.ts @@ -20,9 +20,9 @@ export function normalizeBasePath(basePath: string | undefined | null): string { export function stripBasePath(incomingPath: string, basePath: string | undefined | null): string { const normalized = normalizeBasePath(basePath); if (!normalized) return incomingPath; - if (incomingPath.startsWith(normalized)) { - const stripped = incomingPath.slice(normalized.length); - return stripped || "/"; + if (incomingPath === normalized) return "/"; + if (incomingPath.startsWith(normalized + "/")) { + return incomingPath.slice(normalized.length); } return incomingPath; } @@ -48,6 +48,5 @@ export function buildMockUrl( export function getMockOrigin(): string { if (typeof window !== "undefined") return window.location.origin; - if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; return `http://localhost:${process.env.PORT ?? 3000}`; } diff --git a/src/lib/schema-resolver.test.ts b/src/lib/schema-resolver.test.ts new file mode 100644 index 0000000..f9c058a --- /dev/null +++ b/src/lib/schema-resolver.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from "vitest"; +import { resolveSchema, resolveResponseData } from "./schema-resolver"; + +describe("resolveSchema", () => { + it("returns plain strings unchanged", () => { + expect(resolveSchema("hello")).toBe("hello"); + }); + + it("returns numbers unchanged", () => { + expect(resolveSchema(42)).toBe(42); + }); + + it("returns booleans unchanged", () => { + expect(resolveSchema(true)).toBe(true); + }); + + it("returns null unchanged", () => { + expect(resolveSchema(null)).toBe(null); + }); + + it("returns undefined unchanged", () => { + expect(resolveSchema(undefined)).toBe(undefined); + }); + + it("resolves a valid $faker path to a value", () => { + const result = resolveSchema("$faker.person.firstName"); + expect(typeof result).toBe("string"); + expect(result).not.toBe("$faker.person.firstName"); + }); + + it("resolves a deeper $faker path (internet.email)", () => { + const result = resolveSchema("$faker.internet.email"); + expect(typeof result).toBe("string"); + expect(result).toContain("@"); + }); + + it("returns null for an invalid/nonexistent $faker path", () => { + expect(resolveSchema("$faker.notReal.nope")).toBe(null); + }); + + it("returns null when $faker path points to a non-function (namespace, not leaf)", () => { + expect(resolveSchema("$faker.person")).toBe(null); + }); + + it('does not treat a string merely containing "faker" as a directive', () => { + expect(resolveSchema("this is not $faker.anything really")).toBe( + "this is not $faker.anything really", + ); + }); + + it("resolves a plain array by mapping resolveSchema over each item", () => { + const input = ["a", 1, true]; + expect(resolveSchema(input)).toEqual(["a", 1, true]); + }); + + it("resolves an empty array to an empty array", () => { + expect(resolveSchema([])).toEqual([]); + }); + + it("resolves a nested array of objects", () => { + const input = [{ name: "x" }, { name: "y" }]; + expect(resolveSchema(input)).toEqual([{ name: "x" }, { name: "y" }]); + }); + + it("resolves $array with explicit $count", () => { + const result = resolveSchema({ $array: "static", $count: 5 }) as unknown[]; + expect(result).toHaveLength(5); + expect(result.every((v) => v === "static")).toBe(true); + }); + + it("defaults $array to count 3 when $count is omitted", () => { + const result = resolveSchema({ $array: "x" }) as unknown[]; + expect(result).toHaveLength(3); + }); + + it("resolves $array with $count: 0 to an empty array", () => { + const result = resolveSchema({ $array: "x", $count: 0 }) as unknown[]; + expect(result).toHaveLength(0); + }); + + it("resolves $array whose template is an object (nested)", () => { + const result = resolveSchema({ + $array: { id: "$faker.string.uuid", name: "static" }, + $count: 2, + }) as Array<{ id: string; name: string }>; + expect(result).toHaveLength(2); + result.forEach((item) => { + expect(typeof item.id).toBe("string"); + expect(item.name).toBe("static"); + }); + }); + + it("resolves a simple flat object", () => { + const input = { name: "static", age: 30 }; + expect(resolveSchema(input)).toEqual({ name: "static", age: 30 }); + }); + + it("resolves an empty object to an empty object", () => { + expect(resolveSchema({})).toEqual({}); + }); + + it("resolves a multi-level nested object", () => { + const input = { + user: { + profile: { + contact: { + email: "static@test.com", + }, + }, + }, + }; + expect(resolveSchema(input)).toEqual(input); + }); + + it("resolves an object mixing faker fields, static fields, and nested $array", () => { + const input = { + id: "$faker.string.uuid", + label: "fixed", + tags: { $array: "$faker.word.noun", $count: 2 }, + }; + const result = resolveSchema(input) as { + id: string; + label: string; + tags: string[]; + }; + expect(typeof result.id).toBe("string"); + expect(result.label).toBe("fixed"); + expect(result.tags).toHaveLength(2); + result.tags.forEach((t) => expect(typeof t).toBe("string")); + }); + + it("handles deeply nested arrays-of-objects-containing-arrays", () => { + const input = { + $array: { + id: "$faker.string.uuid", + children: { $array: "leaf", $count: 2 }, + }, + $count: 2, + }; + const result = resolveSchema(input) as Array<{ + id: string; + children: string[]; + }>; + expect(result).toHaveLength(2); + result.forEach((item) => { + expect(typeof item.id).toBe("string"); + expect(item.children).toEqual(["leaf", "leaf"]); + }); + }); +}); + +describe("resolveResponseData", () => { + it("returns a single resolved object when count is 1", () => { + const schema = { name: "static" }; + expect(resolveResponseData(schema, 1)).toEqual({ name: "static" }); + }); + + it("returns a single resolved object when count is 0 (not > 1)", () => { + const schema = { name: "static" }; + expect(resolveResponseData(schema, 0)).toEqual({ name: "static" }); + }); + + it("returns an array of resolved objects when count > 1", () => { + const schema = { name: "static" }; + const result = resolveResponseData(schema, 4) as unknown[]; + expect(result).toHaveLength(4); + expect(result).toEqual([ + { name: "static" }, + { name: "static" }, + { name: "static" }, + { name: "static" }, + ]); + }); + + it("produces distinct faker values across array items when count > 1", () => { + const schema = { id: "$faker.string.uuid" }; + const result = resolveResponseData(schema, 5) as Array<{ id: string }>; + const ids = result.map((r) => r.id); + expect(new Set(ids).size).toBe(5); + }); +}); diff --git a/src/lib/test-helpers/isolate-platform.ts b/src/lib/test-helpers/isolate-platform.ts new file mode 100644 index 0000000..9fce034 --- /dev/null +++ b/src/lib/test-helpers/isolate-platform.ts @@ -0,0 +1,15 @@ +export function isolatePlatformEnv() { + const originalPlatform = process.platform; + const originalEnv = { ...process.env }; + return { + setPlatform: (value: NodeJS.Platform) => + Object.defineProperty(process, "platform", { value, configurable: true }), + restore: () => { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + process.env = { ...originalEnv }; + }, + }; +} diff --git a/src/server/api/routers/endpoint.test.ts b/src/server/api/routers/endpoint.test.ts new file mode 100644 index 0000000..f27cc73 --- /dev/null +++ b/src/server/api/routers/endpoint.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import Database from "better-sqlite3"; +import { TRPCError } from "@trpc/server"; +import * as schema from "~/server/db/schema"; +import { endpointRouter } from "~/server/api/routers/endpoint"; + +vi.mock("~/lib/endpoint-data-store", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateAndSaveData: vi.fn(), + invalidateEndpointData: vi.fn(), + }; +}); +import { generateAndSaveData, invalidateEndpointData } from "~/lib/endpoint-data-store"; + +function createCtx() { + const sqlite = new Database(":memory:"); + sqlite.pragma("foreign_keys = ON"); + const db = drizzle(sqlite, { schema }); + sqlite.exec(` + CREATE TABLE voidend_projects ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + basePath TEXT NOT NULL DEFAULT '/', + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + + CREATE TABLE voidend_folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + projectId TEXT NOT NULL REFERENCES voidend_projects(id) ON DELETE CASCADE, + parentId TEXT REFERENCES voidend_folders(id) ON DELETE CASCADE, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + + CREATE TABLE voidend_endpoints ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + projectId TEXT NOT NULL REFERENCES voidend_projects(id) ON DELETE CASCADE, + folderId TEXT REFERENCES voidend_folders(id) ON DELETE CASCADE, + method TEXT NOT NULL DEFAULT 'GET', + path TEXT NOT NULL, + statusCode INTEGER NOT NULL DEFAULT 200, + responseHeaders TEXT DEFAULT '{}', + delayMs INTEGER NOT NULL DEFAULT 0, + failureRate REAL NOT NULL DEFAULT 0, + responseSchema TEXT NOT NULL DEFAULT '{}', + responseCount INTEGER NOT NULL DEFAULT 1, + errorSchema TEXT DEFAULT NULL, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + `); + return { db, headers: new Headers() }; +} + +async function insertProject(db: ReturnType["db"], overrides = {}) { + const [project] = await db + .insert(schema.projects_table) + .values({ title: "P", ...overrides }) + .returning(); + return project!; +} + +describe("endpointRouter", () => { + let ctx: ReturnType; + let caller: ReturnType; + let projectId: string; + + beforeEach(async () => { + vi.clearAllMocks(); + ctx = createCtx(); + caller = endpointRouter.createCaller(ctx); + projectId = (await insertProject(ctx.db)).id; + }); + + describe("create", () => { + it("creates an endpoint with defaults", async () => { + const endpoint = await caller.create({ name: "Users", projectId, path: "/users" }); + expect(endpoint?.method).toBe("GET"); + expect(endpoint?.statusCode).toBe(200); + expect(endpoint?.responseCount).toBe(1); + }); + + it("calls generateAndSaveData after creation", async () => { + const endpoint = await caller.create({ name: "Users", projectId, path: "/users" }); + expect(generateAndSaveData).toHaveBeenCalledWith( + projectId, + endpoint!.id, + endpoint!.responseSchema, + 1, + ); + }); + + it("rejects duplicate method+path within the same project", async () => { + await caller.create({ name: "A", projectId, path: "/users", method: "GET" }); + await expect( + caller.create({ name: "B", projectId, path: "/users", method: "GET" }), + ).rejects.toThrow(TRPCError); + }); + + it("allows same path with a different method", async () => { + await caller.create({ name: "A", projectId, path: "/users", method: "GET" }); + const endpoint = await caller.create({ + name: "B", + projectId, + path: "/users", + method: "POST", + }); + expect(endpoint?.method).toBe("POST"); + }); + + it("allows same method+path across different projects", async () => { + const otherProject = await insertProject(ctx.db, { title: "Other" }); + await caller.create({ name: "A", projectId, path: "/users" }); + const endpoint = await caller.create({ + name: "A", + projectId: otherProject.id, + path: "/users", + }); + expect(endpoint).toBeTruthy(); + }); + + it("rejects invalid statusCode", async () => { + await expect( + caller.create({ name: "X", projectId, path: "/x", statusCode: 999 }), + ).rejects.toThrow(); + }); + + it("rejects empty path", async () => { + await expect(caller.create({ name: "X", projectId, path: "" })).rejects.toThrow(); + }); + + it("fails on nonexistent projectId (FK violation)", async () => { + await expect( + caller.create({ name: "X", projectId: crypto.randomUUID(), path: "/x" }), + ).rejects.toThrow(); + }); + }); + + describe("getByProject", () => { + it("returns endpoints sorted by path then method", async () => { + await caller.create({ name: "B", projectId, path: "/b" }); + await caller.create({ name: "A2", projectId, path: "/a", method: "POST" }); + await caller.create({ name: "A1", projectId, path: "/a", method: "GET" }); + const endpoints = await caller.getByProject({ projectId }); + expect(endpoints.map((e) => [e.path, e.method])).toEqual([ + ["/a", "GET"], + ["/a", "POST"], + ["/b", "GET"], + ]); + }); + + it("returns empty array when project has no endpoints", async () => { + expect(await caller.getByProject({ projectId })).toEqual([]); + }); + }); + + describe("getByFolder", () => { + it("returns only endpoints in the given folder", async () => { + const [folder] = await ctx.db + .insert(schema.folders_table) + .values({ name: "F", projectId }) + .returning(); + await caller.create({ name: "In", projectId, path: "/in", folderId: folder!.id }); + await caller.create({ name: "Out", projectId, path: "/out" }); + + const endpoints = await caller.getByFolder({ folderId: folder!.id }); + expect(endpoints).toHaveLength(1); + expect(endpoints[0]?.name).toBe("In"); + }); + }); + + describe("getById", () => { + it("returns the endpoint when found", async () => { + const created = await caller.create({ name: "X", projectId, path: "/x" }); + const found = await caller.getById({ id: created!.id }); + expect(found?.id).toBe(created!.id); + }); + + it("returns null when not found", async () => { + expect(await caller.getById({ id: crypto.randomUUID() })).toBeNull(); + }); + }); + + describe("update", () => { + it("updates a non-conflicting field without regeneration", async () => { + const created = await caller.create({ name: "X", projectId, path: "/x" }); + vi.clearAllMocks(); + const updated = await caller.update({ id: created!.id, name: "Renamed" }); + expect(updated?.name).toBe("Renamed"); + expect(generateAndSaveData).not.toHaveBeenCalled(); + expect(invalidateEndpointData).not.toHaveBeenCalled(); + }); + + it("regenerates data when responseSchema changes", async () => { + const created = await caller.create({ name: "X", projectId, path: "/x" }); + await caller.update({ id: created!.id, responseSchema: { name: "string" } }); + expect(invalidateEndpointData).toHaveBeenCalledWith(projectId, created!.id); + expect(generateAndSaveData).toHaveBeenCalledWith( + projectId, + created!.id, + { name: "string" }, + 1, + ); + }); + + it("regenerates data when responseCount changes", async () => { + const created = await caller.create({ name: "X", projectId, path: "/x" }); + await caller.update({ id: created!.id, responseCount: 5 }); + expect(invalidateEndpointData).toHaveBeenCalledWith(projectId, created!.id); + expect(generateAndSaveData).toHaveBeenCalled(); + }); + + it("throws NOT_FOUND when changing method/path on a missing endpoint", async () => { + await expect(caller.update({ id: crypto.randomUUID(), path: "/new" })).rejects.toThrow( + TRPCError, + ); + }); + + it("rejects rename that conflicts with another endpoint's method+path", async () => { + await caller.create({ name: "A", projectId, path: "/a", method: "GET" }); + const b = await caller.create({ name: "B", projectId, path: "/b", method: "GET" }); + await expect(caller.update({ id: b!.id, path: "/a" })).rejects.toThrow(TRPCError); + }); + + it("allows updating path to its own current value", async () => { + const created = await caller.create({ + name: "A", + projectId, + path: "/a", + method: "GET", + }); + const updated = await caller.update({ id: created!.id, path: "/a" }); + expect(updated?.path).toBe("/a"); + }); + + it("allows clearing folderId to null", async () => { + const [folder] = await ctx.db + .insert(schema.folders_table) + .values({ name: "F", projectId }) + .returning(); + const created = await caller.create({ + name: "X", + projectId, + path: "/x", + folderId: folder!.id, + }); + const updated = await caller.update({ id: created!.id, folderId: null }); + expect(updated?.folderId).toBeNull(); + }); + }); + + describe("delete", () => { + it("removes the endpoint and invalidates its data", async () => { + const created = await caller.create({ name: "X", projectId, path: "/x" }); + vi.clearAllMocks(); + await caller.delete({ id: created!.id }); + + expect(invalidateEndpointData).toHaveBeenCalledWith(projectId, created!.id); + expect(await caller.getById({ id: created!.id })).toBeNull(); + }); + + it("does not call invalidateEndpointData when endpoint does not exist", async () => { + await caller.delete({ id: crypto.randomUUID() }); + expect(invalidateEndpointData).not.toHaveBeenCalled(); + }); + }); + + describe("resolve", () => { + it("resolves an endpoint at root basePath", async () => { + await caller.create({ name: "Users", projectId, path: "/users", method: "GET" }); + const resolved = await caller.resolve({ projectId, method: "GET", path: "/users" }); + expect(resolved?.path).toBe("/users"); + }); + + it("strips a non-root basePath before matching", async () => { + const scoped = await insertProject(ctx.db, { title: "Scoped", basePath: "/api/v1" }); + await caller.create({ name: "Users", projectId: scoped.id, path: "/users" }); + + const resolved = await caller.resolve({ + projectId: scoped.id, + method: "GET", + path: "/api/v1/users", + }); + expect(resolved?.path).toBe("/users"); + }); + + it("returns null when project does not exist", async () => { + const resolved = await caller.resolve({ + projectId: crypto.randomUUID(), + method: "GET", + path: "/x", + }); + expect(resolved).toBeNull(); + }); + + it("returns null when no endpoint matches path+method", async () => { + await caller.create({ name: "Users", projectId, path: "/users", method: "GET" }); + const resolved = await caller.resolve({ projectId, method: "POST", path: "/users" }); + expect(resolved).toBeNull(); + }); + }); +}); diff --git a/src/server/api/routers/endpoint.ts b/src/server/api/routers/endpoint.ts index 4040fc1..0b9120f 100644 --- a/src/server/api/routers/endpoint.ts +++ b/src/server/api/routers/endpoint.ts @@ -4,6 +4,7 @@ import { endpoints_table } from "~/server/db/schema"; import { eq } from "drizzle-orm"; import { generateAndSaveData, invalidateEndpointData } from "~/lib/endpoint-data-store"; import { TRPCError } from "@trpc/server"; +import { stripBasePath } from "~/lib/mock-path"; const HttpMethod = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]); @@ -187,11 +188,7 @@ export const endpointRouter = createTRPCRouter({ }); if (!project) return null; - - const basePath = project.basePath.replace(/\/$/, ""); - const strippedPath = input.path.startsWith(basePath) - ? input.path.slice(basePath.length) || "/" - : input.path; + const strippedPath = stripBasePath(input.path, project.basePath); const endpoint = await ctx.db.query.endpoints_table.findFirst({ where: (e, { eq, and }) => diff --git a/src/server/api/routers/folder.test.ts b/src/server/api/routers/folder.test.ts new file mode 100644 index 0000000..8849fd5 --- /dev/null +++ b/src/server/api/routers/folder.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import Database from "better-sqlite3"; +import * as schema from "~/server/db/schema"; +import { folderRouter } from "~/server/api/routers/folder"; + +vi.mock("~/lib/endpoint-data-store", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + invalidateEndpointData: vi.fn(), + }; +}); +import { invalidateEndpointData } from "~/lib/endpoint-data-store"; + +function createCtx() { + const sqlite = new Database(":memory:"); + const db = drizzle(sqlite, { schema }); + sqlite.exec(` + CREATE TABLE voidend_projects ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + basePath TEXT NOT NULL DEFAULT '/', + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + + CREATE TABLE voidend_folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + projectId TEXT NOT NULL REFERENCES voidend_projects(id) ON DELETE CASCADE, + parentId TEXT REFERENCES voidend_folders(id) ON DELETE CASCADE, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + + CREATE TABLE voidend_endpoints ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + projectId TEXT NOT NULL REFERENCES voidend_projects(id) ON DELETE CASCADE, + folderId TEXT REFERENCES voidend_folders(id) ON DELETE CASCADE, + method TEXT NOT NULL DEFAULT 'GET', + path TEXT NOT NULL, + statusCode INTEGER NOT NULL DEFAULT 200, + responseHeaders TEXT DEFAULT '{}', + delayMs INTEGER NOT NULL DEFAULT 0, + failureRate REAL NOT NULL DEFAULT 0, + responseSchema TEXT NOT NULL DEFAULT '{}', + responseCount INTEGER NOT NULL DEFAULT 1, + errorSchema TEXT DEFAULT NULL, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + `); + return { db, headers: new Headers() }; +} + +async function insertProject(db: ReturnType["db"], title = "P") { + const [project] = await db.insert(schema.projects_table).values({ title }).returning(); + return project!; +} + +describe("folderRouter", () => { + let ctx: ReturnType; + let caller: ReturnType; + let projectId: string; + + beforeEach(async () => { + vi.clearAllMocks(); + ctx = createCtx(); + caller = folderRouter.createCaller(ctx); + projectId = (await insertProject(ctx.db)).id; + }); + + describe("create", () => { + it("creates a top-level folder", async () => { + const folder = await caller.create({ name: "Root", projectId }); + expect(folder?.name).toBe("Root"); + expect(folder?.parentId).toBeNull(); + }); + + it("creates a nested folder with parentId", async () => { + const parent = await caller.create({ name: "Parent", projectId }); + const child = await caller.create({ name: "Child", projectId, parentId: parent!.id }); + expect(child?.parentId).toBe(parent!.id); + }); + + it("rejects empty name", async () => { + await expect(caller.create({ name: "", projectId })).rejects.toThrow(); + }); + + it("rejects invalid projectId", async () => { + await expect(caller.create({ name: "X", projectId: "not-a-uuid" })).rejects.toThrow(); + }); + + it("fails on nonexistent projectId (FK violation)", async () => { + await expect( + caller.create({ name: "X", projectId: crypto.randomUUID() }), + ).rejects.toThrow(); + }); + }); + + describe("update", () => { + it("renames a folder", async () => { + const folder = await caller.create({ name: "Old", projectId }); + const updated = await caller.update({ id: folder!.id, name: "New" }); + expect(updated?.name).toBe("New"); + }); + + it("reparents a folder", async () => { + const a = await caller.create({ name: "A", projectId }); + const b = await caller.create({ name: "B", projectId }); + const updated = await caller.update({ id: b!.id, parentId: a!.id }); + expect(updated?.parentId).toBe(a!.id); + }); + + it("clears parentId when explicitly set to null", async () => { + const a = await caller.create({ name: "A", projectId }); + const b = await caller.create({ name: "B", projectId, parentId: a!.id }); + const updated = await caller.update({ id: b!.id, parentId: null }); + expect(updated?.parentId).toBeNull(); + }); + + it("returns undefined when id not found", async () => { + const updated = await caller.update({ id: crypto.randomUUID(), name: "X" }); + expect(updated).toBeUndefined(); + }); + }); + + describe("delete", () => { + it("deletes a leaf folder with no endpoints", async () => { + const folder = await caller.create({ name: "Leaf", projectId }); + await caller.delete({ id: folder!.id }); + + const remaining = await caller.getByProject({ projectId }); + expect(remaining).toHaveLength(0); + expect(invalidateEndpointData).not.toHaveBeenCalled(); + }); + + it("invalidates endpoints directly inside the deleted folder", async () => { + const folder = await caller.create({ name: "Leaf", projectId }); + const [endpoint] = await ctx.db + .insert(schema.endpoints_table) + .values({ name: "E1", projectId, folderId: folder!.id, path: "/e1" }) + .returning(); + + await caller.delete({ id: folder!.id }); + + expect(invalidateEndpointData).toHaveBeenCalledWith(projectId, endpoint!.id); + }); + + it("invalidates endpoints in nested descendant folders", async () => { + const parent = await caller.create({ name: "Parent", projectId }); + const child = await caller.create({ name: "Child", projectId, parentId: parent!.id }); + const grandchild = await caller.create({ + name: "Grandchild", + projectId, + parentId: child!.id, + }); + const [endpoint] = await ctx.db + .insert(schema.endpoints_table) + .values({ name: "Deep", projectId, folderId: grandchild!.id, path: "/deep" }) + .returning(); + + await caller.delete({ id: parent!.id }); + + expect(invalidateEndpointData).toHaveBeenCalledWith(projectId, endpoint!.id); + expect(invalidateEndpointData).toHaveBeenCalledTimes(1); + }); + + it("does not invalidate endpoints in sibling folders", async () => { + const parent = await caller.create({ name: "Parent", projectId }); + const sibling = await caller.create({ name: "Sibling", projectId }); + const [endpoint] = await ctx.db + .insert(schema.endpoints_table) + .values({ name: "SiblingEndpoint", projectId, folderId: sibling!.id, path: "/s" }) + .returning(); + + await caller.delete({ id: parent!.id }); + + expect(invalidateEndpointData).not.toHaveBeenCalledWith(projectId, endpoint!.id); + }); + + it("is a no-op (does not throw) when folder does not exist", async () => { + await expect(caller.delete({ id: crypto.randomUUID() })).resolves.not.toThrow(); + expect(invalidateEndpointData).not.toHaveBeenCalled(); + }); + }); + + describe("getByProject", () => { + it("returns folders for project sorted by name", async () => { + await caller.create({ name: "Zebra", projectId }); + await caller.create({ name: "Apple", projectId }); + const folders = await caller.getByProject({ projectId }); + expect(folders.map((f) => f.name)).toEqual(["Apple", "Zebra"]); + }); + + it("does not return folders from other projects", async () => { + const otherProject = await insertProject(ctx.db, "Other"); + await caller.create({ name: "Mine", projectId }); + await caller.create({ name: "Theirs", projectId: otherProject.id }); + const folders = await caller.getByProject({ projectId }); + expect(folders).toHaveLength(1); + expect(folders[0]?.name).toBe("Mine"); + }); + + it("returns empty array for project with no folders", async () => { + const folders = await caller.getByProject({ projectId }); + expect(folders).toEqual([]); + }); + }); +}); diff --git a/src/server/api/routers/project.test.ts b/src/server/api/routers/project.test.ts new file mode 100644 index 0000000..35ae828 --- /dev/null +++ b/src/server/api/routers/project.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import Database from "better-sqlite3"; +import * as schema from "~/server/db/schema"; +import { projectRouter } from "~/server/api/routers/project"; + +vi.mock("~/lib/endpoint-data-store", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + deleteProjectData: vi.fn(), + }; +}); +import { deleteProjectData } from "~/lib/endpoint-data-store"; + +function createCtx() { + const sqlite = new Database(":memory:"); + const db = drizzle(sqlite, { schema }); + sqlite.exec(` + CREATE TABLE voidend_projects ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + basePath TEXT NOT NULL DEFAULT '/', + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + `); + return { db, headers: new Headers() }; +} + +describe("projectRouter", () => { + let ctx: ReturnType; + let caller: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + ctx = createCtx(); + caller = projectRouter.createCaller(ctx); + }); + + describe("create", () => { + it("creates a project with defaults", async () => { + const project = await caller.create({ title: "Test" }); + expect(project?.title).toBe("Test"); + expect(project?.basePath).toBe("/"); + }); + + it("rejects empty title", async () => { + await expect(caller.create({ title: "" })).rejects.toThrow(); + }); + }); + + describe("getAll", () => { + it("returns projects ordered by createdAt desc", async () => { + await caller.create({ title: "First" }); + await new Promise((r) => setTimeout(r, 1100)); + await caller.create({ title: "Second" }); + const all = await caller.getAll(); + expect(all).toHaveLength(2); + expect(all[0]?.title).toBe("Second"); + }); + + it("returns empty array when none exist", async () => { + expect(await caller.getAll()).toEqual([]); + }); + }); + + describe("getById", () => { + it("returns project when found", async () => { + const created = await caller.create({ title: "Findme" }); + const found = await caller.getById({ id: created!.id }); + expect(found?.id).toBe(created!.id); + }); + + it("returns null when not found", async () => { + const found = await caller.getById({ id: crypto.randomUUID() }); + expect(found).toBeNull(); + }); + + it("rejects invalid uuid", async () => { + await expect(caller.getById({ id: "not-a-uuid" })).rejects.toThrow(); + }); + }); + + describe("update", () => { + it("updates provided fields only", async () => { + const created = await caller.create({ title: "Old", description: "d" }); + const updated = await caller.update({ id: created!.id, title: "New" }); + expect(updated?.title).toBe("New"); + expect(updated?.description).toBe("d"); + }); + + it("returns undefined when id not found", async () => { + const updated = await caller.update({ id: crypto.randomUUID(), title: "X" }); + expect(updated).toBeUndefined(); + }); + }); + + describe("delete", () => { + it("removes project and calls deleteProjectData", async () => { + const created = await caller.create({ title: "ToDelete" }); + await caller.delete({ id: created!.id }); + + expect(deleteProjectData).toHaveBeenCalledWith(created!.id); + expect(await caller.getById({ id: created!.id })).toBeNull(); + }); + + it("calls deleteProjectData even if project id does not exist", async () => { + const id = crypto.randomUUID(); + await caller.delete({ id }); + expect(deleteProjectData).toHaveBeenCalledWith(id); + }); + }); +}); diff --git a/tests/integration/cascade-delete.test.ts b/tests/integration/cascade-delete.test.ts new file mode 100644 index 0000000..df97851 --- /dev/null +++ b/tests/integration/cascade-delete.test.ts @@ -0,0 +1,167 @@ +import fs from "fs"; +import path from "path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { getDataDir } from "~/lib/endpoint-data-store"; +import * as schema from "~/server/db/schema"; +import { projectRouter } from "~/server/api/routers/project"; +import { folderRouter } from "~/server/api/routers/folder"; +import { endpointRouter } from "~/server/api/routers/endpoint"; +import { createRealCtx, setupIsolatedDataDir } from "./test-context"; + +describe("folder cascade delete (integration, real db + real fs)", () => { + let ctx: ReturnType; + let isolated: ReturnType; + let projectCaller: ReturnType; + let folderCaller: ReturnType; + let endpointCaller: ReturnType; + + beforeEach(() => { + isolated = setupIsolatedDataDir(); + ctx = createRealCtx(); + projectCaller = projectRouter.createCaller(ctx); + folderCaller = folderRouter.createCaller(ctx); + endpointCaller = endpointRouter.createCaller(ctx); + }); + + afterEach(() => { + isolated.cleanup(); + }); + + it("removes nested folders and their endpoints from the db via FK cascade", async () => { + const project = await projectCaller.create({ title: "P" }); + const parent = await folderCaller.create({ name: "Parent", projectId: project!.id }); + const child = await folderCaller.create({ + name: "Child", + projectId: project!.id, + parentId: parent!.id, + }); + const grandchild = await folderCaller.create({ + name: "Grandchild", + projectId: project!.id, + parentId: child!.id, + }); + const endpoint = await endpointCaller.create({ + name: "Deep", + projectId: project!.id, + folderId: grandchild!.id, + path: "/deep", + }); + + await folderCaller.delete({ id: parent!.id }); + + const remainingFolders = await ctx.db.query.folders_table.findMany({ + where: (f, { eq }) => eq(f.projectId, project!.id), + }); + expect(remainingFolders).toHaveLength(0); + + const remainingEndpoint = await ctx.db.query.endpoints_table.findFirst({ + where: (e, { eq }) => eq(e.id, endpoint!.id), + }); + expect(remainingEndpoint).toBeUndefined(); + }); + + it("does not cascade-delete sibling folders or their endpoints", async () => { + const project = await projectCaller.create({ title: "P" }); + const target = await folderCaller.create({ name: "Target", projectId: project!.id }); + const sibling = await folderCaller.create({ name: "Sibling", projectId: project!.id }); + const siblingEndpoint = await endpointCaller.create({ + name: "Sibling endpoint", + projectId: project!.id, + folderId: sibling!.id, + path: "/sibling", + }); + + await folderCaller.delete({ id: target!.id }); + + const remainingFolder = await ctx.db.query.folders_table.findFirst({ + where: (f, { eq }) => eq(f.id, sibling!.id), + }); + expect(remainingFolder).toBeDefined(); + + const remainingEndpoint = await ctx.db.query.endpoints_table.findFirst({ + where: (e, { eq }) => eq(e.id, siblingEndpoint!.id), + }); + expect(remainingEndpoint).toBeDefined(); + }); + + it("removes on-disk data files for every endpoint under a deleted folder tree", async () => { + const project = await projectCaller.create({ title: "P" }); + const parent = await folderCaller.create({ name: "Parent", projectId: project!.id }); + const child = await folderCaller.create({ + name: "Child", + projectId: project!.id, + parentId: parent!.id, + }); + + const inParent = await endpointCaller.create({ + name: "InParent", + projectId: project!.id, + folderId: parent!.id, + path: "/in-parent", + }); + const inChild = await endpointCaller.create({ + name: "InChild", + projectId: project!.id, + folderId: child!.id, + path: "/in-child", + }); + const outside = await endpointCaller.create({ + name: "Outside", + projectId: project!.id, + path: "/outside", + }); + + const dataDir = path.join(getDataDir(), project!.id); + const parentFile = path.join(dataDir, `${inParent!.id}.json`); + const childFile = path.join(dataDir, `${inChild!.id}.json`); + const outsideFile = path.join(dataDir, `${outside!.id}.json`); + + expect(fs.existsSync(parentFile)).toBe(true); + expect(fs.existsSync(childFile)).toBe(true); + expect(fs.existsSync(outsideFile)).toBe(true); + + await folderCaller.delete({ id: parent!.id }); + + expect(fs.existsSync(parentFile)).toBe(false); + expect(fs.existsSync(childFile)).toBe(false); + expect(fs.existsSync(outsideFile)).toBe(true); + }); + + it("leaves the folder itself deleted even if it has no endpoints", async () => { + const project = await projectCaller.create({ title: "P" }); + const empty = await folderCaller.create({ name: "Empty", projectId: project!.id }); + + await folderCaller.delete({ id: empty!.id }); + + const remaining = await ctx.db.query.folders_table.findFirst({ + where: (f, { eq }) => eq(f.id, empty!.id), + }); + expect(remaining).toBeUndefined(); + }); + + it("deleting a project cascades through folders and endpoints in the db too", async () => { + const project = await projectCaller.create({ title: "P" }); + const folder = await folderCaller.create({ name: "F", projectId: project!.id }); + const endpoint = await endpointCaller.create({ + name: "E", + projectId: project!.id, + folderId: folder!.id, + path: "/e", + }); + + await projectCaller.delete({ id: project!.id }); + + const remainingFolder = await ctx.db + .select() + .from(schema.folders_table) + .where(eq(schema.folders_table.id, folder!.id)); + expect(remainingFolder).toHaveLength(0); + + const remainingEndpoint = await ctx.db + .select() + .from(schema.endpoints_table) + .where(eq(schema.endpoints_table.id, endpoint!.id)); + expect(remainingEndpoint).toHaveLength(0); + }); +}); diff --git a/tests/integration/project-lifecycle.test.ts b/tests/integration/project-lifecycle.test.ts new file mode 100644 index 0000000..faecd28 --- /dev/null +++ b/tests/integration/project-lifecycle.test.ts @@ -0,0 +1,139 @@ +import fs from "fs"; +import path from "path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { getDataDir } from "~/lib/endpoint-data-store"; +import { projectRouter } from "~/server/api/routers/project"; +import { folderRouter } from "~/server/api/routers/folder"; +import { endpointRouter } from "~/server/api/routers/endpoint"; +import { createRealCtx, setupIsolatedDataDir } from "./test-context"; + +describe("project lifecycle (integration, real fs + real db)", () => { + let ctx: ReturnType; + let isolated: ReturnType; + let projectCaller: ReturnType; + let folderCaller: ReturnType; + let endpointCaller: ReturnType; + + beforeEach(() => { + isolated = setupIsolatedDataDir(); + ctx = createRealCtx(); + projectCaller = projectRouter.createCaller(ctx); + folderCaller = folderRouter.createCaller(ctx); + endpointCaller = endpointRouter.createCaller(ctx); + }); + + afterEach(() => { + isolated.cleanup(); + }); + + it("writes a data file to disk when an endpoint is created", async () => { + const project = await projectCaller.create({ title: "Test Project" }); + const folder = await folderCaller.create({ name: "Users", projectId: project!.id }); + const endpoint = await endpointCaller.create({ + name: "List users", + projectId: project!.id, + folderId: folder!.id, + path: "/users", + responseSchema: { id: "$faker.string.uuid", name: "$faker.person.fullName" }, + responseCount: 3, + }); + + const filePath = path.join(getDataDir(), project!.id, `${endpoint!.id}.json`); + expect(fs.existsSync(filePath)).toBe(true); + + const contents = JSON.parse(fs.readFileSync(filePath, "utf-8")); + expect(contents).toHaveLength(3); + expect(contents[0]).toHaveProperty("id"); + expect(contents[0]).toHaveProperty("name"); + }); + + it("regenerates the data file with new content when responseSchema is updated", async () => { + const project = await projectCaller.create({ title: "Test Project" }); + const endpoint = await endpointCaller.create({ + name: "List users", + projectId: project!.id, + path: "/users", + responseSchema: { id: "$faker.string.uuid" }, + }); + + const filePath = path.join(getDataDir(), project!.id, `${endpoint!.id}.json`); + const before = fs.readFileSync(filePath, "utf-8"); + + await endpointCaller.update({ + id: endpoint!.id, + responseSchema: { id: "$faker.string.uuid", email: "$faker.internet.email" }, + }); + + const after = fs.readFileSync(filePath, "utf-8"); + expect(after).not.toBe(before); + const parsed = JSON.parse(after); + expect(parsed).toHaveProperty("email"); + }); + + it("returns an array (not a bare object) when responseCount is greater than 1", async () => { + const project = await projectCaller.create({ title: "Test Project" }); + const endpoint = await endpointCaller.create({ + name: "List users", + projectId: project!.id, + path: "/users", + responseSchema: { id: "$faker.string.uuid" }, + responseCount: 5, + }); + + const filePath = path.join(getDataDir(), project!.id, `${endpoint!.id}.json`); + const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(5); + }); + + it("removes the data file when an endpoint is deleted", async () => { + const project = await projectCaller.create({ title: "Test Project" }); + const endpoint = await endpointCaller.create({ + name: "List users", + projectId: project!.id, + path: "/users", + }); + const filePath = path.join(getDataDir(), project!.id, `${endpoint!.id}.json`); + expect(fs.existsSync(filePath)).toBe(true); + + await endpointCaller.delete({ id: endpoint!.id }); + expect(fs.existsSync(filePath)).toBe(false); + }); + + it("wipes the whole project data directory when the project is deleted", async () => { + const project = await projectCaller.create({ title: "Test Project" }); + const endpointA = await endpointCaller.create({ + name: "A", + projectId: project!.id, + path: "/a", + }); + const endpointB = await endpointCaller.create({ + name: "B", + projectId: project!.id, + path: "/b", + }); + + const projectDir = path.join(getDataDir(), project!.id); + expect(fs.existsSync(path.join(projectDir, `${endpointA!.id}.json`))).toBe(true); + expect(fs.existsSync(path.join(projectDir, `${endpointB!.id}.json`))).toBe(true); + + await projectCaller.delete({ id: project!.id }); + + expect(fs.existsSync(projectDir)).toBe(false); + }); + + it("does not touch other projects' data directories on delete", async () => { + const projectA = await projectCaller.create({ title: "A" }); + const projectB = await projectCaller.create({ title: "B" }); + const endpointB = await endpointCaller.create({ + name: "B endpoint", + projectId: projectB!.id, + path: "/b", + }); + + await projectCaller.delete({ id: projectA!.id }); + + const bFilePath = path.join(getDataDir(), projectB!.id, `${endpointB!.id}.json`); + expect(fs.existsSync(bFilePath)).toBe(true); + }); +}); diff --git a/tests/integration/resolve-flow.test.ts b/tests/integration/resolve-flow.test.ts new file mode 100644 index 0000000..eebd35b --- /dev/null +++ b/tests/integration/resolve-flow.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { stripBasePath } from "~/lib/mock-path"; +import { projectRouter } from "~/server/api/routers/project"; +import { endpointRouter } from "~/server/api/routers/endpoint"; +import { createRealCtx } from "./test-context"; + +describe("endpoint.resolve basePath stripping (integration)", () => { + let ctx: ReturnType; + let projectCaller: ReturnType; + let endpointCaller: ReturnType; + + beforeEach(() => { + ctx = createRealCtx(); + projectCaller = projectRouter.createCaller(ctx); + endpointCaller = endpointRouter.createCaller(ctx); + }); + + it("resolves correctly when project has root basePath", async () => { + const project = await projectCaller.create({ title: "Root", basePath: "/" }); + await endpointCaller.create({ name: "Users", projectId: project!.id, path: "/users" }); + + const resolved = await endpointCaller.resolve({ + projectId: project!.id, + method: "GET", + path: "/users", + }); + expect(resolved?.path).toBe("/users"); + }); + + it("resolves correctly when the request path matches basePath + endpoint path", async () => { + const project = await projectCaller.create({ title: "Scoped", basePath: "/api/v1" }); + await endpointCaller.create({ name: "Users", projectId: project!.id, path: "/users" }); + + const resolved = await endpointCaller.resolve({ + projectId: project!.id, + method: "GET", + path: "/api/v1/users", + }); + expect(resolved?.path).toBe("/users"); + }); + + it("agrees with stripBasePath on an exact basePath match with no trailing segment", async () => { + const project = await projectCaller.create({ title: "Scoped", basePath: "/api/v1" }); + const expected = stripBasePath("/api/v1", project!.basePath); + expect(expected).toBe("/"); + }); + + it("resolves correctly even when the request path shares a prefix but not a path boundary with basePath", async () => { + const project = await projectCaller.create({ title: "Scoped", basePath: "/api" }); + await endpointCaller.create({ name: "X", projectId: project!.id, path: "/ary/foo" }); + + const resolved = await endpointCaller.resolve({ + projectId: project!.id, + method: "GET", + path: "/apiary/foo", + }); + expect(resolved).toBeNull(); + }); + + it("returns null when the request path matches nothing, prefix or otherwise", async () => { + const project = await projectCaller.create({ title: "Scoped", basePath: "/api/v1" }); + await endpointCaller.create({ name: "Users", projectId: project!.id, path: "/users" }); + + const resolved = await endpointCaller.resolve({ + projectId: project!.id, + method: "GET", + path: "/orders", + }); + expect(resolved).toBeNull(); + }); + + it("resolves even without the basePath prefix, since both paths happen to coincide", async () => { + const project = await projectCaller.create({ title: "Scoped", basePath: "/api/v1" }); + await endpointCaller.create({ name: "Users", projectId: project!.id, path: "/users" }); + + const resolved = await endpointCaller.resolve({ + projectId: project!.id, + method: "GET", + path: "/users", + }); + expect(resolved?.path).toBe("/users"); + }); +}); diff --git a/tests/integration/test-context.ts b/tests/integration/test-context.ts new file mode 100644 index 0000000..639b0fa --- /dev/null +++ b/tests/integration/test-context.ts @@ -0,0 +1,77 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import Database from "better-sqlite3"; +import * as schema from "~/server/db/schema"; +import { isolatePlatformEnv } from "~/lib/test-helpers/isolate-platform"; + +export function createRealCtx() { + const sqlite = new Database(":memory:"); + sqlite.pragma("foreign_keys = ON"); + const db = drizzle(sqlite, { schema }); + sqlite.exec(` + CREATE TABLE voidend_projects ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + basePath TEXT NOT NULL DEFAULT '/', + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + + CREATE TABLE voidend_folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + projectId TEXT NOT NULL REFERENCES voidend_projects(id) ON DELETE CASCADE, + parentId TEXT REFERENCES voidend_folders(id) ON DELETE CASCADE, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + + CREATE TABLE voidend_endpoints ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + projectId TEXT NOT NULL REFERENCES voidend_projects(id) ON DELETE CASCADE, + folderId TEXT REFERENCES voidend_folders(id) ON DELETE CASCADE, + method TEXT NOT NULL DEFAULT 'GET', + path TEXT NOT NULL, + statusCode INTEGER NOT NULL DEFAULT 200, + responseHeaders TEXT DEFAULT '{}', + delayMs INTEGER NOT NULL DEFAULT 0, + failureRate REAL NOT NULL DEFAULT 0, + responseSchema TEXT NOT NULL DEFAULT '{}', + responseCount INTEGER NOT NULL DEFAULT 1, + errorSchema TEXT DEFAULT NULL, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + `); + return { db, headers: new Headers() }; +} + +export async function insertProject( + db: ReturnType["db"], + overrides: Partial<{ title: string; basePath: string }> = {}, +) { + const [project] = await db + .insert(schema.projects_table) + .values({ title: "P", ...overrides }) + .returning(); + return project!; +} + +export function setupIsolatedDataDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "voidend-test-")); + const platformEnv = isolatePlatformEnv(); + platformEnv.setPlatform("linux"); + process.env.XDG_DATA_HOME = dir; + + return { + dir, + cleanup: () => { + fs.rmSync(dir, { recursive: true, force: true }); + platformEnv.restore(); + }, + }; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2ee75b8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + tsconfigPaths: true, + }, + test: { + environment: "node", + globals: true, + }, +});