From 79ff043314e3b60f4593a6dc56f1bfbdc83fd842 Mon Sep 17 00:00:00 2001 From: OrigamiSpace <292462706+OrigamiSpace@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:39:36 +0100 Subject: [PATCH] Add integration tests --- .env.test | 13 + .github/workflows/test.yml | 40 ++ .gitignore | 1 + drizzle.config.ts | 2 +- next.config.mjs | 4 + package.json | 10 +- pnpm-lock.yaml | 717 ++++++++++++++++++++++++++++- scripts/export-dump.ts | 2 +- scripts/import-dump.ts | 2 +- scripts/make-test-keys.ts | 2 +- src/db/migrate.ts | 2 +- src/db/seed.ts | 2 +- src/lib/load-env.ts | 25 + src/lib/rate-limit.ts | 10 +- tests/env.ts | 13 + tests/global-setup.ts | 108 +++++ tests/helpers.ts | 43 ++ tests/integration/auth.test.ts | 66 +++ tests/integration/keys.test.ts | 94 ++++ tests/integration/segments.test.ts | 162 +++++++ vitest.config.ts | 14 + 21 files changed, 1313 insertions(+), 19 deletions(-) create mode 100644 .env.test create mode 100644 .github/workflows/test.yml create mode 100644 src/lib/load-env.ts create mode 100644 tests/env.ts create mode 100644 tests/global-setup.ts create mode 100644 tests/helpers.ts create mode 100644 tests/integration/auth.test.ts create mode 100644 tests/integration/keys.test.ts create mode 100644 tests/integration/segments.test.ts create mode 100644 vitest.config.ts diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..eeb5e02 --- /dev/null +++ b/.env.test @@ -0,0 +1,13 @@ +# --------------------------------------------------------------------------- +# Env for the integration test suite (`pnpm test`). Not secret — points at a +# disposable local database (dropped + recreated on every run) and a +# dedicated port so it never collides with `pnpm dev`. +# +# THIS FILE IS COMMITTED TO GIT. DO NOT PUT REAL SECRETS HERE. +# --------------------------------------------------------------------------- + +DATABASE_URL="postgresql://skipdb:skipdb@localhost:5433/skipdb_test" +AUTH_SECRET="test-secret-do-not-use-in-production-aaaaaaaaaaaaaaaaaaaaaaaaaaaa" +AUTH_URL="http://localhost:3100" +NEXT_PUBLIC_BASE_URL="http://localhost:3100" +NEXT_PUBLIC_API_URL="http://localhost:3100" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5fbc7f4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Test + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: skipdb + POSTGRES_PASSWORD: skipdb + POSTGRES_DB: skipdb + ports: + - 5433:5432 + options: >- + --health-cmd="pg_isready -U skipdb" + --health-interval=5s + --health-timeout=5s + --health-retries=10 + + steps: + - uses: actions/checkout@v7 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Test + run: pnpm test diff --git a/.gitignore b/.gitignore index 4d3aa60..c0b16de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules .next +.next-test out .env .env*.local diff --git a/drizzle.config.ts b/drizzle.config.ts index 4ab16b8..67b47e5 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,4 +1,4 @@ -import "dotenv/config"; +import "./src/lib/load-env"; import { defineConfig } from "drizzle-kit"; const url = diff --git a/next.config.mjs b/next.config.mjs index 5127ebd..bf70463 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,9 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + // The test suite runs its own `next dev` alongside a normal one in the same + // checkout — Next.js locks one dev server per distDir, so give the test + // server a separate build output dir to avoid colliding with it. + ...(process.env.SKIPDB_TEST_SERVER ? { distDir: ".next-test" } : {}), images: { remotePatterns: [ { protocol: "https", hostname: "image.tmdb.org" }, diff --git a/package.json b/package.json index e88d446..0f45a91 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,13 @@ "dev:keys": "tsx scripts/make-test-keys.ts", "db:export": "tsx scripts/export-dump.ts", "db:import": "tsx scripts/import-dump.ts", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@auth/drizzle-adapter": "^1.11.2", "@vercel/analytics": "^2.0.1", - "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "next": "16.2.9", "next-auth": "5.0.0-beta.31", @@ -43,13 +44,16 @@ "@types/pg": "^8.20.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@types/supertest": "^7.2.0", "drizzle-kit": "^0.31.10", "eslint": "^9.39.4", "eslint-config-next": "16.2.9", "postcss": "^8.5.6", + "supertest": "^7.2.2", "tailwindcss": "^4.3.1", "tsx": "^4.22.4", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.9" }, "packageManager": "pnpm@11.9.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec118a6..4deac06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1(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) - dotenv: - specifier: ^17.4.2 - version: 17.4.2 drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@cloudflare/workers-types@4.20260629.1)(@types/pg@8.20.0)(pg@8.22.0) @@ -60,6 +57,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) + '@types/supertest': + specifier: ^7.2.0 + version: 7.2.0 drizzle-kit: specifier: ^0.31.10 version: 0.31.10 @@ -72,6 +72,9 @@ importers: postcss: specifier: ^8.5.6 version: 8.5.15 + supertest: + specifier: ^7.2.2 + version: 7.2.2 tailwindcss: specifier: ^4.3.1 version: 4.3.1 @@ -81,6 +84,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@26.0.1)(vite@8.1.1(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) deploy/cloudflare-segments-worker: devDependencies: @@ -240,6 +246,9 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} @@ -249,6 +258,9 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@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' @@ -995,6 +1007,10 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1011,9 +1027,15 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -1023,6 +1045,104 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@rolldown/binding-android-arm64@1.1.3': + resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.3': + resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.3': + resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.3': + resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.3': + resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.3': + resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.3': + resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.3': + resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.3': + resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.3': + resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + 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==} @@ -1033,6 +1153,9 @@ packages: '@speed-highlight/core@1.2.17': resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + '@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==} @@ -1131,6 +1254,15 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -1140,6 +1272,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} @@ -1157,6 +1292,12 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/superagent@8.1.10': + resolution: {integrity: sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==} + + '@types/supertest@7.2.0': + resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} + '@typescript-eslint/eslint-plugin@8.62.0': resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1365,6 +1506,35 @@ packages: vue-router: 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/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1421,6 +1591,13 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + 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==} @@ -1428,6 +1605,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -1493,6 +1673,10 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + 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'} @@ -1507,16 +1691,30 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1567,18 +1765,21 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -1712,6 +1913,9 @@ packages: resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==} engines: {node: '>= 0.4'} + es-module-lexer@2.2.0: + resolution: {integrity: sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -1867,10 +2071,17 @@ 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'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1884,6 +2095,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1919,6 +2133,14 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2312,10 +2534,27 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + miniflare@4.20260625.0: resolution: {integrity: sha512-3kKXwRUObJsnBYPBgR0NiNZYKF/yv8GFyha1cx2EeAEraxNODgRVcyeRo+F1ok1tg5Mg7iUpOWSkknQTHuFhwA==} engines: {node: '>=22.0.0'} @@ -2431,6 +2670,13 @@ 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'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2525,6 +2771,10 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -2560,6 +2810,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2599,6 +2853,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.1.3: + resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2666,6 +2925,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2684,6 +2946,12 @@ 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==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2732,6 +3000,14 @@ packages: babel-plugin-macros: optional: true + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -2751,10 +3027,21 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + 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'} + 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'} @@ -2834,6 +3121,90 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite@8.1.1: + resolution: {integrity: sha512-X/05/cT+VITy2AeDc1der6smvGWWREtL4hPbPTaVbjSBuuWkmNOjR6HP3NzqcQA2nF6VHGUPaFRJyft/2AE9Kg==} + 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.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 + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2855,6 +3226,11 @@ packages: engines: {node: '>= 8'} 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'} @@ -2874,6 +3250,9 @@ packages: '@cloudflare/workers-types': optional: true + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -3071,6 +3450,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -3086,6 +3471,11 @@ snapshots: 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 @@ -3507,6 +3897,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@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@16.2.9': {} '@next/eslint-plugin-next@16.2.9': @@ -3537,6 +3934,8 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.9': optional: true + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3551,8 +3950,14 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oxc-project/types@0.137.0': {} + '@panva/hkdf@1.2.1': {} + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -3565,12 +3970,65 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@rolldown/binding-android-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-x64@1.1.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.3': + 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.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rtsao/scc@1.1.0': {} '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.17': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -3649,12 +4107,23 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/cookiejar@2.1.5': {} + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} + '@types/methods@1.1.4': {} + '@types/node@26.0.1': dependencies: undici-types: 8.3.0 @@ -3677,6 +4146,18 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/superagent@8.1.10': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 26.0.1 + form-data: 4.0.6 + + '@types/supertest@7.2.0': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.10 + '@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3843,6 +4324,47 @@ 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 + '@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.1.1(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.1(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4) + + '@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/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -3931,10 +4453,16 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asap@2.0.6: {} + + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -3995,6 +4523,8 @@ snapshots: caniuse-lite@1.0.30001799: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4008,12 +4538,22 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + component-emitter@1.3.1: {} + concat-map@0.0.1: {} convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + cookie@1.1.1: {} + cookiejar@2.1.4: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4064,14 +4604,19 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + detect-libc@2.1.2: {} + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + doctrine@2.1.0: dependencies: esutils: 2.0.3 - dotenv@17.4.2: {} - drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -4189,6 +4734,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@2.2.0: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -4502,8 +5049,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -4518,6 +5071,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4550,6 +5105,20 @@ snapshots: dependencies: is-callable: 1.2.7 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + fsevents@2.3.3: optional: true @@ -4915,11 +5484,21 @@ snapshots: merge2@1.4.1: {} + methods@1.1.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@2.6.0: {} + miniflare@4.20260625.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -5037,6 +5616,12 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + obug@2.1.3: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5129,6 +5714,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postgres-array@2.0.0: {} postgres-bytea@1.0.1: {} @@ -5155,6 +5746,11 @@ snapshots: punycode@2.3.1: {} + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + queue-microtask@1.2.3: {} react-dom@19.2.7(react@19.2.7): @@ -5201,6 +5797,27 @@ snapshots: reusify@1.1.0: {} + rolldown@1.1.3: + dependencies: + '@oxc-project/types': 0.137.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.3 + '@rolldown/binding-darwin-arm64': 1.1.3 + '@rolldown/binding-darwin-x64': 1.1.3 + '@rolldown/binding-freebsd-x64': 1.1.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 + '@rolldown/binding-linux-arm64-gnu': 1.1.3 + '@rolldown/binding-linux-arm64-musl': 1.1.3 + '@rolldown/binding-linux-ppc64-gnu': 1.1.3 + '@rolldown/binding-linux-s390x-gnu': 1.1.3 + '@rolldown/binding-linux-x64-gnu': 1.1.3 + '@rolldown/binding-linux-x64-musl': 1.1.3 + '@rolldown/binding-openharmony-arm64': 1.1.3 + '@rolldown/binding-wasm32-wasi': 1.1.3 + '@rolldown/binding-win32-arm64-msvc': 1.1.3 + '@rolldown/binding-win32-x64-msvc': 1.1.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -5317,6 +5934,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -5330,6 +5949,10 @@ snapshots: stable-hash@0.0.5: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -5397,6 +6020,28 @@ snapshots: optionalDependencies: '@babel/core': 7.29.7 + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.3 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + supports-color@10.2.2: {} supports-color@7.2.0: @@ -5409,11 +6054,17 @@ snapshots: tapable@2.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 + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -5539,6 +6190,47 @@ snapshots: dependencies: punycode: 2.3.1 + vite@8.1.1(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.16 + rolldown: 1.1.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.0.1 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.22.4 + + vitest@4.1.9(@types/node@26.0.1)(vite@8.1.1(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.1.1(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) + '@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.2.0 + 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.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.1(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.0.1 + transitivePeerDependencies: + - msw + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -5584,6 +6276,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} workerd@1.20260625.1: @@ -5611,6 +6308,8 @@ snapshots: - bufferutil - utf-8-validate + wrappy@1.0.2: {} + ws@8.21.0: {} xtend@4.0.2: {} diff --git a/scripts/export-dump.ts b/scripts/export-dump.ts index 2b5c77c..8e3b0a9 100644 --- a/scripts/export-dump.ts +++ b/scripts/export-dump.ts @@ -7,7 +7,7 @@ * Requires DATABASE_URL in env (or .env file). */ -import "dotenv/config"; +import "../src/lib/load-env"; import { writeFileSync } from "fs"; import { db } from "../src/db"; import { segments, titles } from "../src/db/schema"; diff --git a/scripts/import-dump.ts b/scripts/import-dump.ts index 8e2e755..55a6fdc 100644 --- a/scripts/import-dump.ts +++ b/scripts/import-dump.ts @@ -10,7 +10,7 @@ * Requires DATABASE_URL in env (or .env file). */ -import "dotenv/config"; +import "../src/lib/load-env"; import { readFileSync } from "fs"; import { db } from "../src/db"; import { titles, segments, users } from "../src/db/schema"; diff --git a/scripts/make-test-keys.ts b/scripts/make-test-keys.ts index 8594e40..8f20093 100644 --- a/scripts/make-test-keys.ts +++ b/scripts/make-test-keys.ts @@ -1,4 +1,4 @@ -import "dotenv/config"; +import "../src/lib/load-env"; import { createHash, randomBytes } from "node:crypto"; import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 7d9d824..f4efbdc 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -1,4 +1,4 @@ -import "dotenv/config"; +import "../lib/load-env"; import { drizzle } from "drizzle-orm/node-postgres"; import { migrate } from "drizzle-orm/node-postgres/migrator"; import { Pool } from "pg"; diff --git a/src/db/seed.ts b/src/db/seed.ts index d058823..7d0abc2 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -1,4 +1,4 @@ -import "dotenv/config"; +import "../lib/load-env"; import { db } from "./index"; import { titles, episodes, segments, users } from "./schema"; import { eq } from "drizzle-orm"; diff --git a/src/lib/load-env.ts b/src/lib/load-env.ts new file mode 100644 index 0000000..f54ab01 --- /dev/null +++ b/src/lib/load-env.ts @@ -0,0 +1,25 @@ +import { loadEnvFile } from "node:process"; + +/** + * Loads `.env` into process.env for standalone scripts (migrations, seed, + * dump import/export, drizzle-kit config) that run outside Next.js's own env + * loading. Node's built-in loader (stable since v20.12) replaces the + * `dotenv` package. + * + * Import this for its side effect only, as the first import in the entry + * file — e.g. `import "./load-env";` — so it runs before any sibling import + * (like `@/db`) that reads `process.env` at module load time. A plain + * statement placed before those imports would NOT work: import declarations + * are evaluated before a module's own top-level statements regardless of + * where they're written, but imports *do* run in the order listed relative + * to each other — which this relies on. + * + * Swallows the "file not found" case — some of these scripts run in CI + * (e.g. the data-export GitHub Action) where real env vars are injected + * directly and no `.env` file is checked out. + */ +try { + loadEnvFile(); +} catch { + // no .env file — fine, real env vars are expected to already be set +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 073f134..66abf77 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -28,6 +28,14 @@ export function rateLimit( limit: number, windowMs: number = config.limits.rateWindowMs, ): RateLimitResult { + // The integration test server hits these endpoints far more densely than + // any real client in a 60s window (every test file shares one server/IP + // bucket), which isn't the abuse pattern rate limiting exists to catch. + // SKIPDB_TEST_SERVER is only ever set by tests/global-setup.ts. + const effectiveLimit = process.env.SKIPDB_TEST_SERVER + ? limit * 1000 + : limit; + const now = Date.now(); const existing = buckets.get(key); @@ -40,7 +48,7 @@ export function rateLimit( existing.count += 1; const remaining = Math.max(0, limit - existing.count); return { - ok: existing.count <= limit, + ok: existing.count <= effectiveLimit, limit, remaining, resetAt: existing.resetAt, diff --git a/tests/env.ts b/tests/env.ts new file mode 100644 index 0000000..d0ed5c5 --- /dev/null +++ b/tests/env.ts @@ -0,0 +1,13 @@ +import { loadEnvFile } from "node:process"; + +// Imported by both the global setup (spawns the server, resets the DB) and +// individual test files (need the DB URL to assert state the API responses +// don't echo back, e.g. an edited segment's new start/end times). Node's +// built-in loader (stable since v20.12) avoids pulling in dotenv just for +// this — fine here since this module is always the first thing to touch +// these vars in a fresh vitest worker, so there's nothing to override. +loadEnvFile(".env.test"); + +export const TEST_PORT = 3100; +export const BASE_URL = `http://localhost:${TEST_PORT}`; +export const DATABASE_URL = process.env.DATABASE_URL!; diff --git a/tests/global-setup.ts b/tests/global-setup.ts new file mode 100644 index 0000000..e118c53 --- /dev/null +++ b/tests/global-setup.ts @@ -0,0 +1,108 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { Client, Pool } from "pg"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { TEST_PORT, BASE_URL, DATABASE_URL } from "./env"; + +const TSCONFIG_PATH = "tsconfig.json"; + +/** Recreate the test database from scratch so every run starts clean. */ +async function resetTestDatabase() { + const url = new URL(DATABASE_URL); + const dbName = url.pathname.replace(/^\//, ""); + + const maintenanceUrl = new URL(DATABASE_URL); + maintenanceUrl.pathname = "/postgres"; + + const client = new Client({ connectionString: maintenanceUrl.toString() }); + await client.connect(); + try { + // FORCE (PG 13+) drops even if a previous run left connections open. + await client.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`); + await client.query(`CREATE DATABASE "${dbName}"`); + } finally { + await client.end(); + } +} + +async function runMigrations() { + const pool = new Pool({ connectionString: DATABASE_URL }); + const db = drizzle(pool); + await migrate(db, { migrationsFolder: "./src/db/migrations" }); + await pool.end(); +} + +async function waitForServer(url: string, timeoutMs: number) { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const res = await fetch(url); + if (res.ok || res.status === 429) return; // server is up and responding + } catch (err) { + lastError = err; + } + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error(`Server at ${url} did not become ready in time: ${lastError}`); +} + +export default async function globalSetup() { + console.log(`[tests] Resetting test database…`); + await resetTestDatabase(); + + console.log(`[tests] Running migrations…`); + await runMigrations(); + + // Next.js auto-patches tsconfig.json's `include` with references to each + // distDir it manages (for generated route types). Since the test server + // runs its own distDir (see next.config.mjs) alongside a possibly-running + // `pnpm dev`, it does this to our real, tracked tsconfig.json — snapshot it + // now and restore it verbatim once the test server is gone. + const tsconfigSnapshot = readFileSync(TSCONFIG_PATH, "utf8"); + + console.log(`[tests] Starting test server on port ${TEST_PORT}…`); + // Spawn the `next` binary directly rather than via `npx next` — npx adds a + // wrapper process, and if it doesn't reliably forward our kill signal to + // its `next` child, the real process can outlive our shutdown wait and + // write to tsconfig.json again after we've already restored it below. + const server: ChildProcess = spawn( + "node_modules/.bin/next", + ["dev", "-p", String(TEST_PORT)], + { + // SKIPDB_TEST_SERVER routes this instance to a separate distDir (see + // next.config.mjs) so its dev-server lockfile doesn't collide with an + // already-running `pnpm dev` in the same checkout. + env: { ...process.env, SKIPDB_TEST_SERVER: "1" }, + stdio: process.env.CI ? "pipe" : "ignore", + }, + ); + + server.on("exit", (code) => { + if (code != null && code !== 0) { + console.error(`[tests] Test server exited early with code ${code}`); + } + }); + + await waitForServer(`${BASE_URL}/api/dump`, 60_000); + console.log(`[tests] Test server ready.`); + + return async () => { + // Wait for the process to actually exit before restoring tsconfig.json — + // otherwise its own shutdown could still be mid-write to the file. Force + // it if it ignores SIGTERM rather than racing a timeout, since a + // still-alive process could write to tsconfig.json after we restore it. + const exited = new Promise((resolve) => server.once("exit", () => resolve())); + server.kill("SIGTERM"); + const timedOut = await Promise.race([ + exited.then(() => false), + new Promise((r) => setTimeout(() => r(true), 10_000)), + ]); + if (timedOut) { + server.kill("SIGKILL"); + await exited; + } + writeFileSync(TSCONFIG_PATH, tsconfigSnapshot); + }; +} diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..ae91871 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,43 @@ +import { randomUUID } from "node:crypto"; +import { Client } from "pg"; +import { DATABASE_URL } from "./env"; + +export { BASE_URL } from "./env"; + +/** + * A fresh email per call so tests can register independent users without + * colliding. Uses a real, MX-having domain — `/api/register` does a live DNS + * lookup and rejects domains without one (e.g. example.com has none). + */ +export function uniqueEmail(): string { + return `skipdb-test-${randomUUID()}@gmail.com`; +} + +export const TEST_PASSWORD = "correct horse battery staple"; + +/** + * Raw read of a segment row, for assertions on fields the API doesn't echo + * back in its responses (e.g. PATCH only returns status/reasons, not the new + * start/end times). + */ +export async function getSegmentRow(id: number): Promise<{ + start_ms: number; + end_ms: number; + status: string; + submitted_by: string | null; +} | null> { + const client = new Client({ connectionString: DATABASE_URL }); + await client.connect(); + try { + // start_ms/end_ms are bigint columns — cast to int so node-postgres + // returns JS numbers instead of strings (its default for int8/bigint). + const { rows } = await client.query( + `select start_ms::int as start_ms, end_ms::int as end_ms, status, submitted_by + from segments where id = $1`, + [id], + ); + return rows[0] ?? null; + } finally { + await client.end(); + } +} diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts new file mode 100644 index 0000000..c8227cb --- /dev/null +++ b/tests/integration/auth.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import request from "supertest"; +import { BASE_URL, uniqueEmail, TEST_PASSWORD } from "../helpers"; + +describe("POST /api/register", () => { + it("creates a user and starts a session", async () => { + const email = uniqueEmail(); + const res = await request(BASE_URL) + .post("/api/register") + .send({ email, password: TEST_PASSWORD, name: "Test User" }); + + expect(res.status).toBe(201); + expect(res.body.ok).toBe(true); + expect(res.body.user.email).toBe(email); + expect(res.headers["set-cookie"]?.[0]).toMatch(/authjs\.session-token=/); + }); + + it("rejects a duplicate email", async () => { + const email = uniqueEmail(); + const first = await request(BASE_URL) + .post("/api/register") + .send({ email, password: TEST_PASSWORD }); + expect(first.status).toBe(201); + + const second = await request(BASE_URL) + .post("/api/register") + .send({ email, password: TEST_PASSWORD }); + expect(second.status).toBe(409); + }); + + it("rejects a password shorter than 8 characters", async () => { + const res = await request(BASE_URL) + .post("/api/register") + .send({ email: uniqueEmail(), password: "short" }); + expect(res.status).toBe(422); + }); +}); + +describe("POST /api/login", () => { + it("logs in with correct credentials", async () => { + const email = uniqueEmail(); + await request(BASE_URL) + .post("/api/register") + .send({ email, password: TEST_PASSWORD }); + + const res = await request(BASE_URL) + .post("/api/login") + .send({ email, password: TEST_PASSWORD }); + + expect(res.status).toBe(200); + expect(res.headers["set-cookie"]?.[0]).toMatch(/authjs\.session-token=/); + }); + + it("rejects the wrong password", async () => { + const email = uniqueEmail(); + await request(BASE_URL) + .post("/api/register") + .send({ email, password: TEST_PASSWORD }); + + const res = await request(BASE_URL) + .post("/api/login") + .send({ email, password: "definitely-wrong" }); + + expect(res.status).toBe(401); + }); +}); diff --git a/tests/integration/keys.test.ts b/tests/integration/keys.test.ts new file mode 100644 index 0000000..b3563e4 --- /dev/null +++ b/tests/integration/keys.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import request from "supertest"; +import { BASE_URL, uniqueEmail, TEST_PASSWORD } from "../helpers"; + +describe("API key lifecycle", () => { + const agent = request.agent(BASE_URL); + + beforeAll(async () => { + const email = uniqueEmail(); + const res = await agent + .post("/api/register") + .send({ email, password: TEST_PASSWORD }); + expect(res.status).toBe(201); + }); + + it("has no active key for a fresh account", async () => { + const res = await agent.get("/api/keys"); + expect(res.status).toBe(200); + expect(res.body.key).toBeNull(); + }); + + it("generates a key that authenticates a write request, then revokes it", async () => { + const generated = await agent.post("/api/keys"); + expect(generated.status).toBe(201); + expect(generated.body.key).toMatch(/^skdb_/); + expect(generated.body.prefix).toBeTruthy(); + + const info = await agent.get("/api/keys"); + expect(info.body.key.keyPrefix).toBe(generated.body.prefix); + + const key = generated.body.key; + const submit = await request(BASE_URL) + .post("/api/segments") + .set("Authorization", `Bearer ${key}`) + .send({ + imdb_id: "tt1000001", + season: 1, + episode: 1, + segment_type: "intro", + start_ms: 60_000, + end_ms: 90_000, + }); + expect(submit.status).toBe(201); + + const revoke = await agent.delete("/api/keys"); + expect(revoke.status).toBe(200); + expect(revoke.body.revoked).toBe(true); + + const rejected = await request(BASE_URL) + .post("/api/segments") + .set("Authorization", `Bearer ${key}`) + .send({ + imdb_id: "tt1000002", + season: 1, + episode: 1, + segment_type: "intro", + start_ms: 60_000, + end_ms: 90_000, + }); + expect(rejected.status).toBe(401); + }); + + it("resetting the key revokes the previous one", async () => { + const first = await agent.post("/api/keys"); + const second = await agent.post("/api/keys"); + expect(second.body.key).not.toBe(first.body.key); + + const usingOld = await request(BASE_URL) + .post("/api/segments") + .set("Authorization", `Bearer ${first.body.key}`) + .send({ + imdb_id: "tt1000003", + season: 1, + episode: 1, + segment_type: "intro", + start_ms: 60_000, + end_ms: 90_000, + }); + expect(usingOld.status).toBe(401); + + const usingNew = await request(BASE_URL) + .post("/api/segments") + .set("Authorization", `Bearer ${second.body.key}`) + .send({ + imdb_id: "tt1000004", + season: 1, + episode: 1, + segment_type: "intro", + start_ms: 60_000, + end_ms: 90_000, + }); + expect(usingNew.status).toBe(201); + }); +}); diff --git a/tests/integration/segments.test.ts b/tests/integration/segments.test.ts new file mode 100644 index 0000000..73ac57b --- /dev/null +++ b/tests/integration/segments.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import request from "supertest"; +import { BASE_URL, uniqueEmail, TEST_PASSWORD, getSegmentRow } from "../helpers"; + +async function createApiKey(): Promise { + const agent = request.agent(BASE_URL); + await agent + .post("/api/register") + .send({ email: uniqueEmail(), password: TEST_PASSWORD }); + const res = await agent.post("/api/keys"); + return res.body.key; +} + +describe("segment submission", () => { + it("requires authentication", async () => { + const res = await request(BASE_URL).post("/api/segments").send({ + imdb_id: "tt2000001", + season: 1, + episode: 1, + segment_type: "intro", + start_ms: 60_000, + end_ms: 90_000, + }); + expect(res.status).toBe(401); + }); + + it("rejects an end time before the start time", async () => { + const key = await createApiKey(); + const res = await request(BASE_URL) + .post("/api/segments") + .set("Authorization", `Bearer ${key}`) + .send({ + imdb_id: "tt2000002", + season: 1, + episode: 1, + segment_type: "intro", + start_ms: 90_000, + end_ms: 60_000, + }); + expect(res.status).toBe(422); + }); + + it("accepts a valid submission", async () => { + const key = await createApiKey(); + const res = await request(BASE_URL) + .post("/api/segments") + .set("Authorization", `Bearer ${key}`) + .send({ + imdb_id: "tt2000003", + season: 1, + episode: 1, + segment_type: "intro", + start_ms: 60_000, + end_ms: 90_000, + }); + expect(res.status).toBe(201); + expect(typeof res.body.id).toBe("number"); + expect(["approved", "pending"]).toContain(res.body.status); + + const row = await getSegmentRow(res.body.id); + expect(row).toMatchObject({ start_ms: 60_000, end_ms: 90_000 }); + }); + + it("blocks a second overlapping segment (any type) for the same user/episode/duration", async () => { + const key = await createApiKey(); + const first = await request(BASE_URL) + .post("/api/segments") + .set("Authorization", `Bearer ${key}`) + .send({ + imdb_id: "tt2000004", + season: 1, + episode: 1, + segment_type: "intro", + start_ms: 20_000, + end_ms: 30_000, + duration_ms: 2_820_000, + }); + expect(first.status).toBe(201); + + const overlapping = await request(BASE_URL) + .post("/api/segments") + .set("Authorization", `Bearer ${key}`) + .send({ + imdb_id: "tt2000004", + season: 1, + episode: 1, + segment_type: "preview", + start_ms: 25_000, + end_ms: 35_000, + duration_ms: 2_820_000, + }); + expect(overlapping.status).toBe(409); + }); +}); + +describe("segment editing and deletion", () => { + let ownerKey: string; + let segmentId: number; + + beforeAll(async () => { + ownerKey = await createApiKey(); + const res = await request(BASE_URL) + .post("/api/segments") + .set("Authorization", `Bearer ${ownerKey}`) + .send({ + imdb_id: "tt2000005", + season: 1, + episode: 1, + segment_type: "intro", + start_ms: 60_000, + end_ms: 90_000, + }); + segmentId = res.body.id; + }); + + it("lets the owner edit their segment's times", async () => { + const res = await request(BASE_URL) + .patch(`/api/segments/${segmentId}`) + .set("Authorization", `Bearer ${ownerKey}`) + .send({ start_ms: 65_000, end_ms: 95_000 }); + + expect(res.status).toBe(200); + const row = await getSegmentRow(segmentId); + expect(row).toMatchObject({ start_ms: 65_000, end_ms: 95_000 }); + }); + + it("blocks another user from editing it", async () => { + const otherKey = await createApiKey(); + const res = await request(BASE_URL) + .patch(`/api/segments/${segmentId}`) + .set("Authorization", `Bearer ${otherKey}`) + .send({ start_ms: 1_000, end_ms: 2_000 }); + expect(res.status).toBe(403); + }); + + it("blocks another user from deleting it", async () => { + const otherKey = await createApiKey(); + const res = await request(BASE_URL) + .delete(`/api/segments/${segmentId}`) + .set("Authorization", `Bearer ${otherKey}`); + expect(res.status).toBe(403); + }); + + it("lets the owner delete their segment", async () => { + const res = await request(BASE_URL) + .delete(`/api/segments/${segmentId}`) + .set("Authorization", `Bearer ${ownerKey}`); + expect(res.status).toBe(200); + expect(res.body.deleted).toBe(true); + + const row = await getSegmentRow(segmentId); + expect(row).toBeNull(); + }); + + it("404s when editing an already-deleted segment", async () => { + const res = await request(BASE_URL) + .patch(`/api/segments/${segmentId}`) + .set("Authorization", `Bearer ${ownerKey}`) + .send({ start_ms: 1_000, end_ms: 2_000 }); + expect(res.status).toBe(404); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7a75d00 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/integration/**/*.test.ts"], + globalSetup: ["./tests/global-setup.ts"], + testTimeout: 20_000, + hookTimeout: 60_000, + // All test files hit the same live server + shared in-memory rate + // limiter, so run them one at a time rather than racing. + fileParallelism: false, + }, +});