From 1d099c68f2044eada5526f8e9ae709f8bcecc9d9 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 26 Jul 2026 11:17:26 +0700 Subject: [PATCH] test: add unit, API, component and end-to-end test suites The repo had no automated tests. Every change was verified by hand. Adds four layers, 2023 tests in total, runnable with one command: - 1191 unit tests over the pure logic in lib/, including the full computeProjectAccess permission matrix and the billing gate - 167 component and hook tests in jsdom, covering the hooks that hold real logic rather than presentational wrappers - 647 API integration tests against a real Postgres, with only auth() mocked, including a data-driven sweep asserting that none of the 60 route modules answers 2xx to an unauthenticated caller - 18 Playwright specs driving a real browser against a real build Infrastructure: vitest.config.ts with three projects, a disposable Postgres and MinIO in docker-compose.test.yml, factories and helpers under tests/, scripts/test.sh as the single entry point, a pre-push hook running bun run verify, and CI split into check, test and e2e jobs. The test database is built with prisma db push plus a replay of the hand-written SQL, because prisma migrate deploy cannot build this schema from empty: the migration history has no captured baseline. This mirrors what scripts/docker-db-bootstrap.ts already does in production, and tests/setup/db-global.ts carries a drift guard so a new migration fails the run until someone reviews it. Production code is unchanged apart from one pure-function extraction out of use-video-player.ts, which was too large to test in jsdom. Several tests pin behaviour that looks wrong, each marked KNOWN BUG in place. TESTING.md section 12 records where the plan turned out to be wrong, and AGENTS.md now states which layer a change needs a test in. --- .dockerignore | 5 + .env.test.example | 78 + .github/workflows/ci.yml | 183 +++ .gitignore | 4 + .husky/pre-push | 1 + .prettierignore | 3 + AGENTS.md | 45 + CONTRIBUTING.md | 30 + README.md | 22 + TESTING.md | 733 +++++++++ bun.lock | 325 +++- .../video-page/hooks/use-video-player.ts | 146 +- .../video-page/hooks/video-player-utils.ts | 156 ++ docker-compose.test.yml | 159 ++ eslint.config.mjs | 16 + package.json | 21 +- playwright.config.ts | 158 ++ scripts/test-db-bootstrap.ts | 22 + scripts/test.sh | 302 ++++ tests/api/approvals.test.ts | 793 ++++++++++ tests/api/assets-authz.test.ts | 669 ++++++++ tests/api/auth-matrix.test.ts | 820 ++++++++++ tests/api/comments.test.ts | 1009 ++++++++++++ tests/api/download-authz.test.ts | 482 ++++++ tests/api/infrastructure.test.ts | 116 ++ tests/api/project-members.test.ts | 753 +++++++++ tests/api/project-settings-authz.test.ts | 531 +++++++ tests/api/projects.test.ts | 741 +++++++++ tests/api/rate-limit.test.ts | 386 +++++ tests/api/register.test.ts | 369 +++++ tests/api/share-links.test.ts | 615 ++++++++ tests/api/storage-quota.test.ts | 487 ++++++ tests/api/stripe-webhook.test.ts | 493 ++++++ tests/api/video-authz.test.ts | 1065 +++++++++++++ tests/api/videos.test.ts | 969 ++++++++++++ tests/api/watch.test.ts | 712 +++++++++ tests/api/workspaces.test.ts | 675 ++++++++ tests/component/comment-rich-text.test.tsx | 261 ++++ tests/component/error-boundary.test.tsx | 272 ++++ tests/component/guest-gate.test.tsx | 134 ++ .../hooks/use-comment-actions.test.ts | 859 +++++++++++ .../hooks/use-comment-export.test.ts | 247 +++ .../hooks/use-version-duration-sync.test.ts | 194 +++ .../hooks/use-watch-progress.test.ts | 480 ++++++ tests/component/linkify.test.tsx | 109 ++ tests/component/share-link-unlock.test.tsx | 193 +++ tests/e2e/.gitkeep | 0 tests/e2e/approvals.spec.ts | 87 ++ tests/e2e/auth.spec.ts | 106 ++ tests/e2e/billing-gate.spec.ts | 60 + tests/e2e/comments.spec.ts | 132 ++ tests/e2e/dashboard-mobile.spec.ts | 33 + tests/e2e/fixtures.ts | 401 +++++ tests/e2e/global-setup.ts | 46 + tests/e2e/onboarding.spec.ts | 56 + tests/e2e/project-lifecycle.spec.ts | 85 + tests/e2e/share-link.spec.ts | 132 ++ tests/e2e/video-upload.spec.ts | 80 + tests/factories/approval.ts | 44 + tests/factories/comment.ts | 46 + tests/factories/index.ts | 42 + tests/factories/project.ts | 69 + tests/factories/scenario.ts | 73 + tests/factories/seq.ts | 19 + tests/factories/share.ts | 38 + tests/factories/user.ts | 95 ++ tests/factories/video.ts | 120 ++ tests/factories/workspace.ts | 79 + tests/fixtures/.gitkeep | 0 tests/fixtures/sample.mp4 | Bin 0 -> 7133 bytes tests/helpers/db.ts | 109 ++ tests/helpers/env.ts | 86 ++ tests/helpers/mail.ts | 47 + tests/helpers/request.ts | 125 ++ tests/helpers/session.ts | 66 + tests/setup/api.ts | 190 +++ tests/setup/component.ts | 95 ++ tests/setup/db-global.ts | 246 +++ tests/unit/lib/api-response.test.ts | 252 +++ tests/unit/lib/approval-workflow.test.ts | 160 ++ tests/unit/lib/async-pool.test.ts | 184 +++ tests/unit/lib/billing.test.ts | 1160 ++++++++++++++ tests/unit/lib/cleanup-warnings.test.ts | 124 ++ tests/unit/lib/comment-export.test.ts | 440 ++++++ tests/unit/lib/comment-tags.test.ts | 53 + .../unit/lib/content-security-policy.test.ts | 216 +++ tests/unit/lib/email-brand.test.ts | 176 +++ tests/unit/lib/email-validation.test.ts | 95 ++ tests/unit/lib/feature-flags.test.ts | 339 ++++ tests/unit/lib/guest-identity.test.ts | 243 +++ tests/unit/lib/json-serialize.test.ts | 112 ++ tests/unit/lib/project-access.test.ts | 592 +++++++ tests/unit/lib/project-download.test.ts | 1366 +++++++++++++++++ tests/unit/lib/rate-limit.test.ts | 342 +++++ tests/unit/lib/seo.test.ts | 254 +++ tests/unit/lib/share-links.test.ts | 366 +++++ tests/unit/lib/upload-validation.test.ts | 353 +++++ tests/unit/lib/validation.test.ts | 379 +++++ tests/unit/lib/video-providers.test.ts | 566 +++++++ tests/unit/video-player-utils.test.ts | 273 ++++ vitest.config.ts | 52 + 101 files changed, 27625 insertions(+), 122 deletions(-) create mode 100644 .env.test.example create mode 100644 .husky/pre-push create mode 100644 TESTING.md create mode 100644 components/video-page/hooks/video-player-utils.ts create mode 100644 docker-compose.test.yml create mode 100644 playwright.config.ts create mode 100644 scripts/test-db-bootstrap.ts create mode 100755 scripts/test.sh create mode 100644 tests/api/approvals.test.ts create mode 100644 tests/api/assets-authz.test.ts create mode 100644 tests/api/auth-matrix.test.ts create mode 100644 tests/api/comments.test.ts create mode 100644 tests/api/download-authz.test.ts create mode 100644 tests/api/infrastructure.test.ts create mode 100644 tests/api/project-members.test.ts create mode 100644 tests/api/project-settings-authz.test.ts create mode 100644 tests/api/projects.test.ts create mode 100644 tests/api/rate-limit.test.ts create mode 100644 tests/api/register.test.ts create mode 100644 tests/api/share-links.test.ts create mode 100644 tests/api/storage-quota.test.ts create mode 100644 tests/api/stripe-webhook.test.ts create mode 100644 tests/api/video-authz.test.ts create mode 100644 tests/api/videos.test.ts create mode 100644 tests/api/watch.test.ts create mode 100644 tests/api/workspaces.test.ts create mode 100644 tests/component/comment-rich-text.test.tsx create mode 100644 tests/component/error-boundary.test.tsx create mode 100644 tests/component/guest-gate.test.tsx create mode 100644 tests/component/hooks/use-comment-actions.test.ts create mode 100644 tests/component/hooks/use-comment-export.test.ts create mode 100644 tests/component/hooks/use-version-duration-sync.test.ts create mode 100644 tests/component/hooks/use-watch-progress.test.ts create mode 100644 tests/component/linkify.test.tsx create mode 100644 tests/component/share-link-unlock.test.tsx create mode 100644 tests/e2e/.gitkeep create mode 100644 tests/e2e/approvals.spec.ts create mode 100644 tests/e2e/auth.spec.ts create mode 100644 tests/e2e/billing-gate.spec.ts create mode 100644 tests/e2e/comments.spec.ts create mode 100644 tests/e2e/dashboard-mobile.spec.ts create mode 100644 tests/e2e/fixtures.ts create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/onboarding.spec.ts create mode 100644 tests/e2e/project-lifecycle.spec.ts create mode 100644 tests/e2e/share-link.spec.ts create mode 100644 tests/e2e/video-upload.spec.ts create mode 100644 tests/factories/approval.ts create mode 100644 tests/factories/comment.ts create mode 100644 tests/factories/index.ts create mode 100644 tests/factories/project.ts create mode 100644 tests/factories/scenario.ts create mode 100644 tests/factories/seq.ts create mode 100644 tests/factories/share.ts create mode 100644 tests/factories/user.ts create mode 100644 tests/factories/video.ts create mode 100644 tests/factories/workspace.ts create mode 100644 tests/fixtures/.gitkeep create mode 100644 tests/fixtures/sample.mp4 create mode 100644 tests/helpers/db.ts create mode 100644 tests/helpers/env.ts create mode 100644 tests/helpers/mail.ts create mode 100644 tests/helpers/request.ts create mode 100644 tests/helpers/session.ts create mode 100644 tests/setup/api.ts create mode 100644 tests/setup/component.ts create mode 100644 tests/setup/db-global.ts create mode 100644 tests/unit/lib/api-response.test.ts create mode 100644 tests/unit/lib/approval-workflow.test.ts create mode 100644 tests/unit/lib/async-pool.test.ts create mode 100644 tests/unit/lib/billing.test.ts create mode 100644 tests/unit/lib/cleanup-warnings.test.ts create mode 100644 tests/unit/lib/comment-export.test.ts create mode 100644 tests/unit/lib/comment-tags.test.ts create mode 100644 tests/unit/lib/content-security-policy.test.ts create mode 100644 tests/unit/lib/email-brand.test.ts create mode 100644 tests/unit/lib/email-validation.test.ts create mode 100644 tests/unit/lib/feature-flags.test.ts create mode 100644 tests/unit/lib/guest-identity.test.ts create mode 100644 tests/unit/lib/json-serialize.test.ts create mode 100644 tests/unit/lib/project-access.test.ts create mode 100644 tests/unit/lib/project-download.test.ts create mode 100644 tests/unit/lib/rate-limit.test.ts create mode 100644 tests/unit/lib/seo.test.ts create mode 100644 tests/unit/lib/share-links.test.ts create mode 100644 tests/unit/lib/upload-validation.test.ts create mode 100644 tests/unit/lib/validation.test.ts create mode 100644 tests/unit/lib/video-providers.test.ts create mode 100644 tests/unit/video-player-utils.test.ts create mode 100644 vitest.config.ts diff --git a/.dockerignore b/.dockerignore index 8887c82..bbbf819 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,11 @@ node_modules coverage dist testsprite_tests +tests +vitest.config.ts +playwright.config.ts +playwright-report +test-results tsconfig.tsbuildinfo README.md PROGRESS.md diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..9c8a776 --- /dev/null +++ b/.env.test.example @@ -0,0 +1,78 @@ +# Environment for the `api` Vitest project. Copy to `.env.test` (gitignored): +# cp .env.test.example .env.test +# +# `tests/setup/db-global.ts` and `tests/setup/api.ts` both load this file (via +# `tests/helpers/env.ts`) before anything imports `@/lib/db`, which reads +# DATABASE_URL once at module load and memoizes the pool. An already-exported +# variable always wins over the file, so CI can override DATABASE_URL without +# editing anything. + +# --------------------------------------------------------------------------- +# DATABASE +# --------------------------------------------------------------------------- +# The default targets the container-to-container hostname, because the test +# runner itself runs in a container attached to the `openframe-test` network: +# podman compose -f docker-compose.test.yml up -d +# podman run --rm --network openframe-test -v "$PWD":/workspace:z -w /workspace \ +# docker.io/oven/bun:alpine sh -c "bun run test:api" +DATABASE_URL="postgresql://openframe:openframe@postgres-test:5432/openframe_test?schema=public" + +# From the host instead (psql, or a runner that is not on that network), use the +# published port: +# DATABASE_URL="postgresql://openframe:openframe@127.0.0.1:55432/openframe_test?schema=public" +# +# On GitHub Actions, where Postgres is a service container on the job network: +# DATABASE_URL="postgresql://openframe:openframe@localhost:5432/openframe_test?schema=public" + +# --------------------------------------------------------------------------- +# AUTH +# --------------------------------------------------------------------------- +# `auth()` is mocked in tests, so NEXTAUTH_SECRET is only used for real work by +# lib/share-session.ts, which HMAC-signs the share cookies. +NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_SECRET="test-secret-not-used-for-anything-real" +NEXT_PUBLIC_APP_URL="http://localhost:3000" + +# --------------------------------------------------------------------------- +# FEATURE FLAGS +# --------------------------------------------------------------------------- +# Stripe on, because that is what production runs and because it is what arms +# every billing gate: hasBillingAccess() short-circuits to `true` when the flag +# is off, which would silently neuter the whole access-control surface. Tests +# that want the self-hosted behaviour stub the flag off per test. +OPENFRAME_ENABLE_STRIPE="true" +# Dummy credentials so isStripeBillingEnabled() is true and getStripePriceId() +# does not throw. `@/lib/stripe` is module-mocked, so no request ever leaves. +STRIPE_SECRET_KEY="sk_test_openframe_dummy" +STRIPE_PRICE_ID="price_test_openframe_dummy" +STRIPE_WEBHOOK_SECRET="whsec_test_openframe_dummy" + +OPENFRAME_REQUIRE_INVITE_CODE="true" +INVITE_CODE="test-invite" + +# Direct-upload providers are deliberately left unconfigured, so +# isDirectFileUploadEnabled() is false by default. Suites that exercise the +# presigned-upload routes stub R2_* / BUNNY_* in per test. + +# No proxy in front of the test runner, so getClientIp() returns 127.0.0.1. +TRUSTED_PROXY_MODE="none" + +# Rate limits are DB-backed and keyed on the client IP, which is that same +# constant for every request in the suite. Left on, one test exhausting a +# window would make the next test's 429 look like a passing authorization +# check. tests/api/rate-limit.test.ts re-enables it with vi.stubEnv (the flag +# is read per call, not at import) and is the only place that asserts on it. +DISABLE_RATE_LIMIT="true" + +# SMTP is configured on purpose: isEmailVerificationEnabled() is derived from +# these three variables, and with them unset the register/verify routes take a +# different branch than production does. `nodemailer` is module-mocked in +# tests/setup/api.ts, so nothing leaves the process; the messages are captured +# and assertable through tests/helpers/mail.ts. +SMTP_HOST="localhost" +SMTP_PORT="1025" +SMTP_USER="test" +SMTP_PASSWORD="test" +SMTP_FROM="OpenFrame Test " + +NODE_ENV="test" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad3814d..50bcaee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,3 +13,186 @@ jobs: - uses: oven-sh/setup-bun@v2 - run: bun install - run: bun run check + + test: + runs-on: ubuntu-latest + services: + # Postgres for the `api` Vitest project. This job runs directly on the + # runner, so the service is reachable on localhost through the published + # port, not by service name. + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: openframe + POSTGRES_PASSWORD: openframe + POSTGRES_DB: openframe_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U openframe -d openframe_test" + --health-interval 2s + --health-timeout 3s + --health-retries 30 + env: + DATABASE_URL: postgresql://openframe:openframe@localhost:5432/openframe_test?schema=public + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - name: Create .env.test from the committed example + # The `api` project's setup files read `.env.test`, which is gitignored. + # The example carries every value the suites need; only the database host + # differs, because locally it is the compose service and here it is a + # service container. The appended line wins inside the file and the + # exported job env wins over the file, so the override holds either way. + run: | + cp .env.test.example .env.test + printf '\nDATABASE_URL=%s\n' "$DATABASE_URL" >> .env.test + # No migration step here on purpose. The api project's globalSetup + # (tests/setup/db-global.ts) builds the schema itself, and it cannot use + # `prisma migrate deploy`: prisma/migrations is a stack of patches on top + # of a baseline that was never captured, so the second migration alters an + # enum that nothing in the history creates. That file explains it in full. + - name: Unit and component tests + run: bun run test + - name: API integration tests + run: bun run test:api + - name: Coverage report + # Diagnostic only. There is no coverage threshold gate on purpose, see + # TESTING.md section 11. + # + # Run under node rather than `bun run test:coverage`: @vitest/coverage-v8 + # needs the V8 inspector API, which bun does not implement, so under bun + # every file reports "Coverage APIs are not supported" and the numbers + # come out as zero. The suite itself passes under both runtimes. + run: node node_modules/vitest/vitest.mjs run --project unit --coverage + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage/ + if-no-files-found: ignore + retention-days: 7 + + e2e: + runs-on: ubuntu-latest + needs: [check] + container: + # Must stay pinned to the installed @playwright/test version, because the + # image carries the matching browser build and Playwright refuses a + # mismatched pair. Microsoft publishes the image a while after the npm + # release, which is why package.json pins 1.61.1 rather than the newer + # 1.62.0: no v1.62.0-noble image exists yet. Bump both together, and check + # the tag is published first: + # curl -sI https://mcr.microsoft.com/v2/playwright/manifests/v1.61.1-noble + image: mcr.microsoft.com/playwright:v1.61.1-noble + # Chromium needs a real /dev/shm in a container. + options: --ipc=host + # No --user override on purpose: the image has no bun, and installing one + # needs root. The runner is ephemeral, so root-owned files do no harm. + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: openframe + POSTGRES_PASSWORD: openframe + POSTGRES_DB: openframe_test + options: >- + --health-cmd "pg_isready -U openframe -d openframe_test" + --health-interval 2s + --health-timeout 3s + --health-retries 30 + # Object storage for video-upload.spec.ts. The browser PUTs the file + # straight at the presigned URL, so there is nothing to mock at that + # boundary from inside a browser. Mirrors the minio-test service in + # docker-compose.test.yml. + minio: + image: minio/minio:latest + env: + MINIO_ROOT_USER: openframe + MINIO_ROOT_PASSWORD: openframe-test-secret + # lib/r2.ts signs with `region: 'auto'`, so MinIO has to accept it. + MINIO_REGION_NAME: auto + options: >- + --health-cmd "mc ready local" + --health-interval 2s + --health-timeout 3s + --health-retries 30 + env: + # This job runs inside a container, so it shares a network with its + # services and reaches them by service name. No published ports are + # involved, which is why there are no `ports:` blocks above. + DATABASE_URL: postgresql://openframe:openframe@postgres:5432/openframe_test?schema=public + # The Playwright web server builds and starts the app, and `next build` + # does not run with NODE_ENV=test, so it never picks up `.env.test`. These + # values are therefore set on the job itself. Port 3100 matches + # playwright.config.ts. + NEXTAUTH_URL: http://localhost:3100 + NEXTAUTH_SECRET: ci-secret-not-used-for-anything-real + NEXT_PUBLIC_APP_URL: http://localhost:3100 + # Required. NextAuth v5 answers every /api/auth/* request with + # `UntrustedHost` in a production build unless the host is trusted, which + # is why .env.docker.example sets the same variable for real deployments. + AUTH_TRUST_HOST: 'true' + # Stripe ON, with dummy credentials, and deliberately not 'false'. + # hasBillingAccess() short-circuits to `true` when the flag is off and + # buildBillingAccessWhereInput() returns `{}`, which disarms the whole + # billing gate: billing-gate.spec.ts would then assert nothing. No spec + # walks into checkout, so nothing reaches Stripe. This also keeps the + # e2e job consistent with .env.test, which the api suite already runs + # with the flag on for the same reason. + OPENFRAME_ENABLE_STRIPE: 'true' + STRIPE_SECRET_KEY: sk_test_openframe_dummy + STRIPE_PRICE_ID: price_test_openframe_dummy + STRIPE_WEBHOOK_SECRET: whsec_test_openframe_dummy + OPENFRAME_REQUIRE_INVITE_CODE: 'true' + INVITE_CODE: test-invite + TRUSTED_PROXY_MODE: none + # Direct video uploads, pointed at the MinIO service. Without these the + # `Direct Upload` tab is not rendered and video-upload.spec.ts fails on its + # first assertion rather than silently testing nothing. + OPENFRAME_ENABLE_S3_VIDEO_UPLOADS: 'true' + OPENFRAME_ENABLE_BUNNY_UPLOADS: 'false' + R2_ENDPOINT: http://minio:9000 + R2_ACCESS_KEY_ID: openframe + R2_SECRET_ACCESS_KEY: openframe-test-secret + R2_BUCKET_NAME: openframe-test + steps: + - uses: actions/checkout@v4 + - name: Install bun + # oven-sh/setup-bun cannot be used inside this container: it unpacks a + # zip archive and the Playwright image ships no unzip. It ships node and + # npm, so npm installs the bun binary instead. bun is needed both for + # `bun run test:e2e` and for the web server command in + # playwright.config.ts. + run: npm install --global bun + - run: bun install + - name: Create .env.test from the committed example + run: | + cp .env.test.example .env.test + printf '\nDATABASE_URL=%s\n' "$DATABASE_URL" >> .env.test + - name: Create the MinIO bucket + # Nothing at runtime creates it: ensureR2BucketExists() lives in + # scripts/self-host-bootstrap.ts, not on the request path, so a missing + # bucket would surface as a presigned PUT returning NoSuchBucket. The + # Playwright image has no mc, so this goes through the same image the + # service container uses. + run: | + curl -sSfL -o /usr/local/bin/mc https://dl.min.io/client/mc/release/linux-amd64/mc + chmod +x /usr/local/bin/mc + mc alias set ciminio "$R2_ENDPOINT" "$R2_ACCESS_KEY_ID" "$R2_SECRET_ACCESS_KEY" + mc mb --ignore-existing "ciminio/$R2_BUCKET_NAME" + # No `bun run test:db:bootstrap` step: tests/e2e/global-setup.ts calls the + # same setup function before the web server starts, and also clears the + # rate_limits table so a retry does not inherit a spent window. + - name: End-to-end tests + run: bun run test:e2e + - name: Upload the Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.gitignore b/.gitignore index 6fa35f5..2545768 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ # testing /coverage +/playwright-report +/test-results +/.playwright # next.js /.next/ @@ -34,6 +37,7 @@ yarn-error.log* .env* !.env.example !.env.docker.example +!.env.test.example # vercel .vercel diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..78123ed --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +bun run verify diff --git a/.prettierignore b/.prettierignore index 67f6b0d..353d080 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,6 @@ node_modules/ prisma/migrations/ bun.lock +coverage/ +playwright-report/ +test-results/ diff --git a/AGENTS.md b/AGENTS.md index f10d06c..e8e30e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,51 @@ ## Validation before finishing - Run `bun run check`. +- Run `bun run verify` (this is `bun run check` plus the unit and component tests). +- If you touched an API route, also run `bun run test:api`. It needs the test database: + `bun run test:db:up` first. + +## Testing + +- The testing stack, layout and conventions live in `TESTING.md`. Read it before adding a + test. +- Tests live in a top-level `tests/` tree, never colocated with the code. +- Import test globals explicitly: `import { describe, it, expect, vi } from 'vitest';`. +- Never write a BigInt literal (`1n`) in any file. `tsconfig.json` targets ES2017, so `tsc` + rejects the syntax with TS2737 and `bun run check` fails. Use `BigInt(1)` instead, and + compare `BigInt(...)` against `BigInt(...)`. + +### When a change needs a test + +Match the change to a layer. Most changes need exactly one. + +| You changed | Write | +| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| A pure function in `lib/` (validation, a limit, a date rule, a URL or filename check, a permission calculation) | a unit test in `tests/unit/lib/` | +| An API route, or any authorization, quota or billing rule behind one | an integration test in `tests/api/` | +| A new API route | classify it in `tests/api/auth-matrix.test.ts`, or the suite fails until you do | +| Real logic in a React hook (optimistic updates, throttling, retries) | a hook test in `tests/component/hooks/` | +| A user-visible flow across more than one page | an end-to-end spec in `tests/e2e/` | +| Presentation only (styling, copy, layout, a `components/ui/` wrapper) | nothing | + +Always write a test for a bug fix, at the layer where the bug lived. The test must fail +before the fix and pass after it. If it passes before the fix, it is testing the wrong +thing. + +For an API route, three cases are the minimum: an unauthenticated caller, a caller who is +signed in but not authorized, and the happy path. Assert the database row, not only the +status code: a refused DELETE has to leave the row present. + +### Two ways a test can be worthless + +Both have been found in this repo, so they are worth naming. + +1. **A test that cannot fail.** Before you finish, name the specific mutation of the + production code your test would catch. If you cannot name one, delete the test. When it + matters, prove it: break the code on purpose, watch the test go red, then revert. +2. **A test whose input comes from the code under test.** Iterating the same constant the + function looks up means deleting an entry from that constant also deletes its own test + case. Write expected values by hand as literals. ## Repo-specific conventions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 52fd458..1ec33e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,6 +29,34 @@ bun run db:generate bun run check ``` +## Running the tests + +The testing stack, layout, and conventions live in [TESTING.md](TESTING.md). Read it before adding a test, and see the "When a change needs a test" table in [AGENTS.md](AGENTS.md) for which layer your change belongs in. A bug fix always needs a test that fails before the fix. + +| Command | Runs | Needs the test database | +| ------------------ | -------------------------------------------------- | ----------------------- | +| `bun run test` | unit and component suites | no | +| `bun run test:api` | API integration suites | yes | +| `bun run test:e2e` | Playwright end-to-end specs | yes | +| `bun run verify` | `bun run check` plus the unit and component suites | no | + +The test database is a disposable Postgres defined in `docker-compose.test.yml`, on port `55432` so it cannot collide with your dev stack. + +```bash +bun run test:db:up +bun run test:api +bun run test:db:down +``` + +`scripts/test.sh ` does all of that in one command. It runs each suite inside a container, so no package manager runs on your host, and it starts the test database first when the suite needs one. + +```bash +./scripts/test.sh unit +./scripts/test.sh all +``` + +The `pre-push` hook runs `bun run verify`, so lint, format, typecheck, and the unit and component suites have to pass before a push leaves your machine. `bun run test:api` is deliberately not in the hook, because it needs the database container. Run it yourself when you change an API route. + ## Contribution workflow 1. Fork and create a branch from `master`. @@ -56,6 +84,8 @@ type(scope): short summary ## Required checks before opening a PR - Run `bun run check`. +- Run `bun run verify`, or let the `pre-push` hook run it for you. +- If you changed an API route, also run `bun run test:api` with the test database up. - If `prisma/schema.prisma` changed, run `bun run db:generate`. - Ensure no unrelated file changes are included. - Ensure no secrets or private keys are committed. diff --git a/README.md b/README.md index 3c1d141..adc1b64 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,28 @@ bun run check Feature flags and self-hosting environment variables are documented in `.env.example` and `.env.docker.example`. +### Running The Tests + +The testing stack, layout, and conventions are documented in [TESTING.md](TESTING.md). + +```bash +bun run test # unit and component suites, no database needed +bun run test:api # API integration suites, needs the test database +bun run test:e2e # Playwright end-to-end specs, needs the test database +bun run verify # bun run check plus the unit and component suites +``` + +The API and end-to-end suites need the disposable Postgres defined in `docker-compose.test.yml`, and the end-to-end suite also needs the MinIO service in its `e2e` profile. Start Postgres with `bun run test:db:up` and stop everything with `bun run test:db:down`. Run those two suites one at a time: they share a database, and the API suite empties every table between its tests. + +`scripts/test.sh ` is the shortcut: it runs a suite inside a container, so no package manager runs on your host, and it starts the test database first when the suite needs one. + +```bash +./scripts/test.sh unit +./scripts/test.sh api +``` + +The `pre-push` Git hook runs `bun run verify` on every push. It leaves `bun run test:api` out on purpose, because that suite needs the database container. + ## License OpenFrame is Fair Source, licensed under the [Functional Source License](https://fsl.software/) (FSL-1.1-ALv2). The full source code is publicly available, you can self-host it, and every release automatically becomes Apache 2.0 open source two years after its publication. See [LICENCE](LICENCE) for the full terms. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..7df924d --- /dev/null +++ b/TESTING.md @@ -0,0 +1,733 @@ +# Testing Plan + +Status: **all six phases delivered**. What actually landed, and where reality differed +from the plan, is in Section 12. The sections below are kept as written so the reasoning +behind each decision stays readable. + +| Suite | Command | Tests | Runtime | +| ---------------- | ------------------ | -------- | ------- | +| Unit + component | `bun run test` | 1160 | 6s | +| API integration | `bun run test:api` | 537 | 45s | +| End to end | `bun run test:e2e` | 18 | 40s | +| **Total** | `bun run test:all` | **1715** | | + +OpenFrame is ~56k lines across 60 API route handlers, ~90 components and ~50 `lib/` +modules. Before this, every change was verified by hand. This document defines the stack, +the layout, the priority order, and the exact commands so that verification becomes +`bun run test`. + +--- + +## 0. Primer + +Short glossary, because this repo has no testing history: + +- **Unit test**: calls one function directly with fixed inputs and asserts the return + value. No database, no network, no browser. Runs in milliseconds. +- **Integration test**: exercises several real pieces together. Here that means calling an + API route handler with a real request object against a real (test) Postgres, with only + the session faked. +- **E2E test**: drives a real browser against a running app. Verifies what a user sees. +- **Mock / stub**: a fake stand-in for a dependency (`auth()`, Stripe, S3). +- **Factory**: a helper that inserts a realistic row into the test DB + (`createProject({ visibility: 'PUBLIC' })`). +- **Fixture**: a fixed input file or dataset a test reads from. +- **Flaky test**: passes and fails on the same code. Usually a timing bug in the test. + Flaky tests are worse than no tests; fix or delete them, never retry them away. +- **AAA**: Arrange, Act, Assert. The shape every test in this repo should have. + +The rule of thumb we follow: **many unit tests, a solid layer of API integration tests, +a handful of E2E tests, almost no component tests.** Cost per test rises and stability +falls as you go up that list. + +--- + +## 1. Stack decisions + +| Layer | Tool | Why | +| ---------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Unit + API integration | **Vitest 4** | Native ESM/TS, resolves the `@/*` alias via `vite-tsconfig-paths`, first-class module mocking (`vi.mock`) which we need for `auth()`, and multi-project config so node and jsdom suites live in one runner. | +| Component + hooks | **@testing-library/react 16** + **jsdom 29** | Standard for React 19. Gives us `renderHook`, which is what we actually want for the big hooks in `components/video-page/hooks/`. | +| E2E | **Playwright 1.62** | Real Chromium/Firefox/WebKit, auto-waiting (kills most flakiness), trace viewer for debugging CI failures, official container image so it runs under podman. | +| Coverage | **@vitest/coverage-v8** | Built in, no extra config. | + +### Rejected alternatives, and why + +- **`bun test`**: fast and already in the toolchain, but its jsdom/React story and Next.js + module-mocking story are still thinner than Vitest's. We run Vitest _with_ bun + (`bun run vitest`), so we keep bun as the only package manager and task runner. +- **Jest**: needs `next/jest`, babel config and ESM workarounds. Strictly more setup for + strictly less speed. +- **Mocked Prisma (`vitest-mock-extended` / `prismock`)**: rejected for API tests. The + bugs this repo actually produces are wrong `where` filters and missing `OR` branches + (see the `buildBillingAccessWhereInput` usage in `app/api/projects/route.ts`). A mocked + client asserts that we called Prisma, not that the query is correct, and the mock setup + is more code than the test. A real Postgres in a container costs seconds. +- **MSW**: not needed yet. External calls (Stripe, Bunny, R2) are reached through thin + wrappers in `lib/`, so `vi.mock('@/lib/stripe')` is simpler than intercepting HTTP. + Revisit only if we start testing client-side fetch flows in jsdom. +- **Cypress**: Playwright is faster, has better parallelism and better container support. +- **Snapshot tests**: deliberately out of scope. They fail on every intentional markup + change and assert nothing about behaviour. + +### One structural constraint to be aware of + +There are no `use server` actions in this repo; all mutations go through +`app/api/**/route.ts`. That is good news: route handlers are plain exported functions, so +they can be imported and called directly in a test without a running server. + +Conversely, **async Server Components cannot be unit tested** with Testing Library. Every +`page.tsx` in `app/(dashboard)` is therefore covered by E2E, not by component tests. This +is the single biggest reason the component layer stays thin. + +--- + +## 2. Directory layout + +``` +tests/ + unit/ # node env, no DB, no mocks + lib/ + billing.test.ts + project-access.test.ts + validation.test.ts + ... + api/ # node env, real test Postgres, auth() mocked + auth-matrix.test.ts # data-driven: no route returns 2xx unauthenticated + projects.test.ts + comments.test.ts + ... + component/ # jsdom env + hooks/ + use-watch-progress.test.ts + ... + comment-rich-text.test.tsx + e2e/ # Playwright + auth.spec.ts + project-lifecycle.spec.ts + ... + factories/ # test-DB row builders + index.ts + user.ts + project.ts + video.ts + helpers/ + db.ts # truncate + connect + request.ts # NextRequest builders, route invocation + session.ts # session mock control + setup/ + api.ts # per-file setup for the api project + component.ts # jsdom polyfills + jest-dom matchers + db-global.ts # global setup: migrate the test DB once + fixtures/ + sample.mp4 # tiny (<100KB) media for upload paths + sample.png +``` + +Rationale for a top-level `tests/` tree rather than colocated `*.test.ts`: it keeps `app/` +free of non-route files, makes the Docker build ignore rules trivial, and lets each layer +have its own environment without per-file pragmas. + +**Import style:** no globals. Every test file does +`import { describe, it, expect, vi } from 'vitest';`. This keeps `tsconfig.json` +untouched and keeps `bun run typecheck` covering the test files, so a broken test is a +failed `bun run check`. + +**Naming:** `describe('functionName')` / `it('returns X when Y')`. No "should". + +--- + +## 3. Phase 0: Foundation + +Goal: `bun run test` runs and reports "no tests found" instead of erroring. Nothing is +tested yet; the wiring is done. + +- [x] Add dev dependencies: + + ``` + bun add -d vitest@^4.1.10 @vitest/coverage-v8@^4.1.10 \ + @vitejs/plugin-react@^6.0.4 vite-tsconfig-paths@^6.1.1 \ + jsdom@^29.1.1 @testing-library/react@^16.3.2 \ + @testing-library/jest-dom@^7.0.0 @testing-library/user-event@^14.6.1 + ``` + + (Playwright is added in Phase 3 so the browser download does not slow Phase 0.) + +- [x] `vitest.config.ts` at the repo root, using Vitest 4 `projects`: + + ```ts + import { defineConfig } from 'vitest/config'; + import react from '@vitejs/plugin-react'; + import tsconfigPaths from 'vite-tsconfig-paths'; + + export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + projects: [ + { + extends: true, + test: { + name: 'unit', + environment: 'node', + include: ['tests/unit/**/*.test.ts'], + }, + }, + { + extends: true, + test: { + name: 'api', + environment: 'node', + include: ['tests/api/**/*.test.ts'], + setupFiles: ['tests/setup/api.ts'], + globalSetup: ['tests/setup/db-global.ts'], + // One shared test database; parallel files would fight over TRUNCATE. + fileParallelism: false, + testTimeout: 20_000, + }, + }, + { + extends: true, + plugins: [tsconfigPaths(), react()], + test: { + name: 'component', + environment: 'jsdom', + include: ['tests/component/**/*.test.{ts,tsx}'], + setupFiles: ['tests/setup/component.ts'], + }, + }, + ], + }, + }); + ``` + +- [x] `package.json` scripts: + + ```json + "test": "vitest run --project unit --project component", + "test:watch": "vitest --project unit --project component", + "test:api": "vitest run --project api", + "test:e2e": "playwright test", + "test:all": "bun run test && bun run test:api && bun run test:e2e", + "test:coverage": "vitest run --project unit --coverage", + "verify": "bun run check && bun run test", + "test:db:up": "podman compose -f docker-compose.test.yml up -d --wait postgres-test", + "test:db:down": "podman compose -f docker-compose.test.yml down -v" + ``` + + `test` intentionally excludes the `api` project so the default command needs no + infrastructure and stays instant. `test:all` is the full sweep. + +- [x] `.prettierignore`: add `coverage/`, `playwright-report/`, `test-results/`. +- [x] `.gitignore`: add `/playwright-report`, `/test-results`, `/.playwright`, and the + exception `!.env.test.example` (the existing `.env*` rule would otherwise hide it). +- [x] `eslint.config.mjs`: append an override for `tests/**` relaxing + `@typescript-eslint/no-explicit-any` and any `no-restricted-imports` that fight + test helpers. Keep `--max-warnings=0` intact. +- [x] `.dockerignore`: add `tests/`, `vitest.config.ts`, `playwright.config.ts` so the + production image does not grow. +- [x] `AGENTS.md`: add "run `bun run verify` before finishing" alongside the existing + `bun run check` rule, and a line pointing at this file. + +**Definition of done:** `bun run test` exits 0. + +--- + +## 4. Phase 1: Pure unit tests + +Highest value per hour of work in the whole plan. No DB, no mocks, no async. This is also +where the authorization logic lives, which is where the repo's real bugs have been. + +Target: **~200 tests across 20 files**, total runtime under 2 seconds. + +Priority order, most valuable first: + +- [x] **`tests/unit/lib/project-access.test.ts`**: `computeProjectAccess()` from + `lib/auth.ts`. This is the heart of every permission decision in the product. Build + an explicit matrix: anonymous / non-member / project member / project ADMIN / + workspace member / workspace ADMIN / workspace OWNER / project owner, crossed with + `visibility` PRIVATE|PUBLIC and workspace-owner billing active|expired. Assert all + of `hasAccess`, `canEdit`, `canDelete`, `isWorkspaceAdmin`, `ownerBillingActive`. + Recent history (`fix/public-project-hides-workspace-admin-actions`) says bugs land + exactly here. ~24 tests. +- [x] **`tests/unit/lib/billing.test.ts`**: `hasActiveTrial`, `hasActiveSubscription`, + `hasRecoverableSubscription`, `hasBillingAccess`, `getBillingAccessEndDate`, + `getStorageCleanupEligibleAt`, `getDefaultTrialEndsAt`, `mapStripeSubscriptionStatus` + (every Stripe status string), `selectAuthoritativeSubscription`, + `getBillingStatusLabel`. All take an injectable `now`, so no fake timers needed. + Also snapshot-free shape assertions on `buildBillingAccessWhereInput` and + `buildExpiredBillingWhereInput`. ~32 tests. +- [x] **`tests/unit/lib/validation.test.ts`**: `validateAnnotationStrokes` (limits: 500 + strokes, 2000 points, colour regex, stroke width bounds, prototype-pollution + payloads, `__proto__` keys, NaN/Infinity coords), `isValidHttpUrl` + (`javascript:`, `data:`, `file:`), `isSafeAppRelativePath` (traversal, wrong UUID + shape), `validateOptionalUrlOrAppPath`. Security boundaries that are impossible to + test by hand. ~24 tests. +- [x] **`tests/unit/lib/feature-flags.test.ts`**: env-driven, so use + `vi.stubEnv`. Cover `readBooleanEnv` defaults and garbage values, the S3-over-Bunny + precedence in `isBunnyUploadsEnabled`, `isDirectFileUploadEnabled`, + `getMaxVideoUploadBytes` fallback on invalid/negative input, and the + `getR2MultipartPartSizeBytes` 5 MiB clamp. ~20 tests. +- [x] **`tests/unit/lib/rate-limit.test.ts`**: `getClientIp` under each + `TRUSTED_PROXY_MODE`, spoofed `x-forwarded-for` chains, the `IP_PATTERN` reject + path, plus `rateLimitHeaders` and a sanity check that every entry in + `RATE_LIMIT_CONFIGS` has positive window and max. ~14 tests. +- [x] **`tests/unit/lib/api-response.test.ts`**: each `apiErrors.*` helper returns the + right status and `code`, `successResponse` serialises `meta` and BigInt values, + `withCacheControl` sets the header. Guards the contract every route depends on. + ~14 tests. +- [x] **`tests/unit/lib/upload-validation.test.ts`**: `lib/video-upload-validation.ts` + and `lib/image-upload-validation.ts`: extension/MIME allowlists, size limits, + filename sanitisation. ~16 tests. +- [x] **`tests/unit/lib/share-links.test.ts`**: token generation shape, expiry logic, + permission comparison. ~10 tests. +- [x] **`tests/unit/lib/guest-identity.test.ts`**: cookie parse/serialise, name + sanitisation, invalid payloads. ~8 tests. +- [x] **`tests/unit/lib/content-security-policy.test.ts`**: `buildContentSecurityPolicy` + includes runtime storage endpoints when set, omits them when not, and never emits + `unsafe-eval` in production mode. ~8 tests. +- [x] **`tests/unit/lib/video-providers.test.ts`**: provider resolution in + `lib/video-providers/index.ts`, YouTube ID extraction from every URL form + (`watch?v=`, `youtu.be`, `shorts/`, with extra params, invalid), `metadata-cache` + hit/miss/expiry. ~14 tests. +- [x] **`tests/unit/lib/comment-export.test.ts`**: timecode formatting, CSV/text escaping + of quotes and newlines, ordering. ~10 tests. +- [x] **`tests/unit/lib/approval-workflow.test.ts`**: status transition rules. ~8 tests. +- [x] **`tests/unit/lib/async-pool.test.ts`**: concurrency bound is respected, results + keep input order, one rejection does not lose the others. ~6 tests. +- [x] **`tests/unit/lib/json-serialize.test.ts`**: `bigIntReplacer` on nested structures, + `0n`, negative values. ~5 tests. +- [x] **`tests/unit/lib/email-validation.test.ts`**: ~6 tests. +- [x] **`tests/unit/lib/cleanup-warnings.test.ts`**: warning threshold boundaries. ~6 tests. +- [x] **`tests/unit/lib/email-brand.test.ts`**: HTML escaping in email templates. ~5 tests. +- [x] **`tests/unit/lib/seo.test.ts`** + `lib/marketing/metadata.ts`: canonical URLs, + title/description length bounds. ~6 tests. +- [x] **`tests/unit/lib/comment-tags.test.ts`**: `DEFAULT_COMMENT_TAGS` invariants + (unique slugs, valid colours). ~4 tests. + +**Definition of done:** `bun run test` runs ~200 assertions in under 2 seconds, and +`bun run test:coverage` reports >85% line coverage on the files listed above. + +--- + +## 5. Phase 2: API integration tests + +Goal: for each covered route, prove that an unauthorised caller cannot reach it, that +malformed input is rejected with 400, and that the happy path writes the right rows. + +### Infrastructure + +- [x] `docker-compose.test.yml`: Postgres only (no MinIO for now; storage is mocked at + the `lib/r2.ts` boundary), on port `55432` so it cannot collide with the dev stack, + with `tmpfs` for the data directory to keep it fast and disposable: + ```yaml + services: + postgres-test: + image: postgres:16-alpine + environment: + POSTGRES_USER: openframe + POSTGRES_PASSWORD: openframe + POSTGRES_DB: openframe_test + command: ['postgres', '-c', 'fsync=off', '-c', 'full_page_writes=off'] + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U openframe -d openframe_test'] + interval: 2s + timeout: 3s + retries: 30 + ports: + - '127.0.0.1:55432:5432' + ``` +- [x] `.env.test.example` committed, `.env.test` gitignored. Minimum set: + `DATABASE_URL` (pointing at 55432), `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, + `NEXT_PUBLIC_APP_URL`, `OPENFRAME_ENABLE_STRIPE=false`, + `OPENFRAME_REQUIRE_INVITE_CODE=true`, `INVITE_CODE=test-invite`, + `TRUSTED_PROXY_MODE=none`, `NODE_ENV=test`. +- [x] `tests/setup/db-global.ts`: global setup, runs once. Loads `.env.test`, waits for + Postgres, runs `prisma migrate deploy` against the test DB. Migrations (not + `db push`) because `prisma/migrations/*/migration.sql` contains hand-written SQL + such as `cleanup_rate_limits()` that the routes depend on. +- [x] `tests/setup/api.ts`: per-file setup. Loads `.env.test` **before** any `@/lib/db` + import, registers `afterEach(resetDb)`, and installs the `auth()` mock. +- [x] `tests/helpers/db.ts`: `resetDb()` truncates every table except + `_prisma_migrations`, discovered dynamically from `information_schema.tables` so it + never drifts from the schema: + `TRUNCATE TABLE RESTART IDENTITY CASCADE`. +- [x] `tests/helpers/session.ts`: controls the mock: + ```ts + vi.mock('@/lib/auth', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, auth: vi.fn() }; + }); + ``` + plus `signedInAs(user)` / `signedOut()` wrappers. Partial mock, so the real + `checkProjectAccess` / `checkWorkspaceAccess` still run against the real DB. That + is the whole point: the authorization code under test is not the code being faked. +- [x] `tests/helpers/request.ts`: `apiRequest(url, { method, body, headers, cookies })` + returning a `NextRequest`, and `callRoute(handler, request, params)` that wraps + params in a resolved promise, matching the `params: Promise<...>` convention from + `AGENTS.md`. +- [x] `tests/factories/`: `createUser({ trialEndsAt, subscriptionStatus })`, + `createWorkspace({ ownerId })`, `addWorkspaceMember`, `createProject({ visibility })`, + `addProjectMember({ role })`, `createVideo`, `createVersion`, `createComment`, + `createShareLink`, `createApprovalRequest`. Unique values from a module-level + counter, no faker dependency. +- [x] Module mocks for external services, in `tests/setup/api.ts`: + `@/lib/r2` (presign returns a fake URL), `@/lib/stripe`, + `@/lib/bunny-upload-token`, and `nodemailer` (assert on captured mail instead of + sending). + +### The cheap win: auth matrix + +- [x] `tests/api/auth-matrix.test.ts`: a table of all 60 route modules with their + exported methods and a sample params object. For each, assert that an + unauthenticated call returns 401 or 403, never 2xx. One file, one afternoon, + coverage across every route in the app. Routes that are legitimately public + (`/api/watch/[videoId]` with a share token, `/api/stripe/webhook`, + `/api/auth/*`) go in an explicit allowlist inside the file, so making a route public + becomes a visible diff. + +### Deep coverage, in priority order + +Each of these gets unauthorised / forbidden / invalid-input / happy-path cases: + +- [x] `tests/api/projects.test.ts`: `app/api/projects/route.ts` GET pagination guards + (page 0, page 1001, limit 101, offset > 10000), the billing filter (projects of an + expired-trial workspace owner are invisible), POST validation and default comment + tags; `[projectId]` GET/PATCH/DELETE against the `canEdit` / `canDelete` matrix. +- [x] `tests/api/project-members.test.ts` covering `members/route.ts` and + `members/[memberId]`. A project ADMIN cannot promote itself past its scope, a VIEWER + cannot invite, the owner cannot be removed. +- [x] `tests/api/comments.test.ts` covering `versions/[versionId]/comments` POST. + Annotation payload validation wired to `validateAnnotationStrokes`, guest identity + path, timecode bounds. Plus `comments/[commentId]` DELETE/PATCH, where only the + author or an admin may act. +- [x] `tests/api/approvals.test.ts`: request creation, `decision` route rejecting a + non-candidate approver, `cancel` restricted to the requester, terminal-status + transitions rejected. +- [x] `tests/api/share-links.test.ts`: creation permissions, password-protected links, + expiry, `SharePermission` levels honoured on read. +- [x] `tests/api/watch.test.ts` covering `watch/[videoId]` and `progress`. Share-session + gate, private video without session, `upload-token` scoping. +- [x] `tests/api/videos.test.ts`: `videos/route.ts`, `bulk-delete` (cross-project ids + rejected), `move` (target project permission check), `r2-init` / `r2-complete` + session lifecycle with `lib/r2.ts` mocked. +- [x] `tests/api/stripe-webhook.test.ts`: invalid signature rejected, each handled event + type maps to the right user state via `syncStripeSubscriptionToUser`, replayed + events are idempotent. Stripe SDK mocked; event payloads as fixtures. +- [x] `tests/api/register.test.ts`: invite code required/not required, duplicate email, + password hashing (never stored in clear), email normalisation to lowercase, + verification-token creation. +- [x] `tests/api/workspaces.test.ts`: creation eligibility via + `getWorkspaceCreationEligibility`, member add/remove roles. +- [x] `tests/api/storage-quota.test.ts`: `reserveStorageQuota` / + `releaseStorageReservation` concurrency: two parallel reservations cannot exceed + `PLAN_STORAGE_LIMIT_BYTES`. This exercises the advisory-lock SQL, which is exactly + the kind of thing that cannot be verified by clicking. +- [x] `tests/api/rate-limit.test.ts`: the DB-backed `checkRateLimit` actually blocks + after N requests and the window resets. + +**Definition of done:** `bun run test:api` green against a fresh +`bun run test:db:up`, total runtime under 90 seconds. + +_Later optimisation, not now:_ give each Vitest worker its own Postgres schema +(`?schema=test_w${VITEST_WORKER_ID}`) and re-enable `fileParallelism`. Only worth it if +the suite passes ~2 minutes. + +--- + +## 6. Phase 3: E2E tests + +Goal: the UI is verified by a browser, not by hand. Keep this suite small and ruthlessly +stable. Eight flows, not eighty. + +- [x] `bun add -d @playwright/test@^1.62.0` +- [x] `playwright.config.ts`: Chromium as the default project, one Mobile Chrome project + for the dashboard smoke test, `retries: 2` on CI and `0` locally, + `trace: 'on-first-retry'`, and a `webServer` running `bun run build && bun run start` + with `.env.test` and `OPENFRAME_ENABLE_STRIPE=false`. +- [x] `tests/e2e/fixtures.ts`: a seeded-user fixture using Playwright `storageState`, so + only the auth spec pays the cost of logging in through the form. +- [x] Add the app + MinIO to `docker-compose.test.yml` as a separate profile, since E2E + needs real storage for the upload flow. + +Flows, in priority order: + +- [x] `auth.spec.ts`: register with invite code, wrong invite code rejected, login, + wrong password, logout, protected route redirects to `/login`. +- [x] `onboarding.spec.ts`: a fresh user completes onboarding and lands with a workspace. +- [x] `project-lifecycle.spec.ts`: create, rename, change visibility, delete a project; + the list reflects each change. +- [x] `video-upload.spec.ts`: upload `tests/fixtures/sample.mp4` through the drag-drop + uploader, wait for the version to appear, add a second version. +- [x] `comments.spec.ts`: leave a timestamped comment, verify the timecode links back to + the right frame, draw an annotation and confirm it persists after reload, reply and + resolve. +- [x] `approvals.spec.ts`: request approval, approve as a second user in a second + browser context, verify both users' views. +- [x] `share-link.spec.ts`: create a share link, open it in a fresh unauthenticated + context, verify the guest name gate and the permission level, verify an expired link + is refused. +- [x] `billing-gate.spec.ts`: a seeded expired-trial user is pushed to `/settings` and + cannot open a project. +- [x] `dashboard-mobile.spec.ts`: mobile viewport smoke test. Navigation opens, the project + list renders, no horizontal scroll. + +**Rules for this suite** (these are what keep E2E from becoming the thing everyone +disables): locate by role and accessible name or `data-testid`, never by CSS class; never +`waitForTimeout`; every spec creates its own data and cleans up after itself; nothing +depends on execution order. + +**Definition of done:** `bun run test:e2e` green twice in a row locally and on CI. + +--- + +## 7. Phase 4: Component and hook tests + +Deliberately last and deliberately narrow. Most components here are presentational +wrappers over Radix or are async Server Components (untestable in jsdom, already covered +by E2E). The real logic sits in hooks. + +- [x] `tests/setup/component.ts`: `@testing-library/jest-dom/vitest`, plus the jsdom + polyfills Radix and the video player need: `matchMedia`, + `Element.prototype.scrollIntoView`, `ResizeObserver`, `PointerEvent` methods, + `HTMLMediaElement.prototype.play/pause`, `URL.createObjectURL`. + +Worth testing (via `renderHook`): + +- [x] `components/video-page/hooks/use-watch-progress.ts`: throttling, resume position, + the boundary where progress counts as "watched". +- [x] `components/video-page/hooks/use-version-duration-sync.ts`: small and pure enough + to pin exactly. +- [x] `components/video-page/hooks/use-comment-export.ts`: pairs with the + `lib/comment-export.ts` unit tests. +- [x] `components/video-page/hooks/use-comment-actions.ts`: 39k of logic. Test optimistic + insert, rollback on failed request, reply threading, resolve toggling. Highest-value + item in this phase. +- [x] `components/video-page/hooks/use-video-player.ts`: 48k. Do **not** attempt full + coverage in jsdom. Pull the pure parts (timecode parsing/formatting, frame stepping + arithmetic, keyboard-shortcut mapping) into a sibling module and unit test those in + Phase 1 style; leave playback behaviour to E2E. + +Worth testing (via `render`): + +- [x] `components/video-page/comment-rich-text.tsx`: URL linkification and + `@[name](asset:id)` mention parsing, including the XSS-shaped inputs + (`javascript:` hrefs must not render as links). +- [x] `components/linkify.tsx`: same regex, different component. +- [x] `components/error-boundary.tsx`: renders the fallback and does not swallow the + error. +- [x] `components/share-link-unlock.tsx` and `components/guest-gate.tsx`: small forms + with real validation branches. + +Explicitly **not** tested here: everything in `components/ui/` (upstream shadcn/Radix), +`LandingPage.tsx`, `components/marketing/*`, `assets-pane.tsx`, `comments-pane.tsx`, +`video-page-content.tsx`. Those are covered by E2E where they are covered at all. + +--- + +## 8. Phase 5: One-click and CI + +- [x] `.husky/pre-push` (new hook): + ```sh + bun run verify + ``` + `pre-commit` stays as-is (`lint-staged`) so committing stays fast. Push is the right + gate: it is where work leaves the machine. +- [x] Rewrite `.github/workflows/ci.yml` into three jobs: + ```yaml + jobs: + check: # existing: lint + format + typecheck + test: # unit + component + api, with a postgres:16-alpine service + e2e: # playwright, needs: [check], uploads the report on failure + ``` + `test` runs `bun run test && bun run test:api` with `DATABASE_URL` pointing at the + service container and `prisma migrate deploy` first. `e2e` uses + `mcr.microsoft.com/playwright:v1.62.0-noble` as the job container and uploads + `playwright-report/` via `actions/upload-artifact` when it fails. +- [x] Add a coverage summary comment or a `coverage-summary.json` artifact. No coverage + _threshold_ gate initially: a hard gate on a suite this young turns into people + writing tests for getters. Revisit once Phase 2 is complete. +- [x] `README.md` and `CONTRIBUTING.md`: a "Running the tests" section pointing here. + +### Local commands, all under podman + +Per the project rule, no npm package touches the host filesystem. + +```fish +# Unit + component, the everyday loop +podman run -it --rm -v "$PWD":/workspace:z -w /workspace docker.io/oven/bun:alpine \ + sh -c "bun install && bun run test" + +# Watch mode while writing code +podman run -it --rm -v "$PWD":/workspace:z -w /workspace docker.io/oven/bun:alpine \ + sh -c "bun install && bun run test:watch" + +# API integration: start the test DB on the shared network first +podman network create openframe-test # once +podman compose -f docker-compose.test.yml up -d --wait postgres-test +podman run -it --rm --network openframe-test -v "$PWD":/workspace:z -w /workspace \ + docker.io/oven/bun:alpine sh -c "bun install && bun run test:api" + +# E2E: browsers preinstalled in the Playwright image +podman run -it --rm --network openframe-test -v "$PWD":/workspace:z -w /workspace \ + mcr.microsoft.com/playwright:v1.62.0-noble sh -c "bun run test:e2e" +``` + +- [x] Wrap these in `scripts/test.sh ` so the everyday invocation is one + short command instead of a memorised podman line. + +--- + +## 9. Risks and spikes + +Each of these gets a 15-minute spike **before** the phase that depends on it. If a spike +fails, the fallback is listed. + +1. **`vi.mock` partial-mocking `@/lib/auth` (Phase 2).** Importing the real module + initialises NextAuth v5 beta with `PrismaAdapter(db)` at module load. It should be + inert without a request, but beta versions surprise. + _Fallback:_ extract `computeProjectAccess`, `checkProjectAccess`, + `checkWorkspaceAccess` and `projectAccessInclude` into `lib/access.ts` (a pure + re-export from `lib/auth.ts` keeps every call site working). Then tests import + `lib/access.ts` and mock `lib/auth.ts` wholesale. This is a better structure anyway. + +2. **`lib/db.ts` env timing (Phase 2).** `db` is a module-level singleton that reads + `process.env.DATABASE_URL` at import. Setup files must load `.env.test` before the + first `@/lib/db` import in the module graph. + _Fallback:_ pass env explicitly on the command line + (`DATABASE_URL=... vitest run --project api`) instead of relying on a setup file. + +3. **`process.on('SIGINT'|'SIGTERM')` in `lib/db.ts` (Phase 2).** Every test file that + imports `db` adds listeners. With many files this trips Node's + `MaxListenersExceededWarning` and, with `--max-warnings` style strictness, noise. + _Fallback:_ guard the registration with `if (process.env.NODE_ENV !== 'test')`, or + call `process.setMaxListeners(0)` in the api setup file. + +4. **Radix + React 19 under jsdom 29 (Phase 4).** Radix uses pointer-capture APIs jsdom + does not implement. + _Fallback:_ the polyfill list in `tests/setup/component.ts`; and if a component still + resists, it moves to E2E instead. No fighting jsdom for hours. + +5. **Playwright `webServer` build time (Phase 3).** `next build` on a 56k-line app is not + fast, so a naive config rebuilds on every local run. + _Fallback:_ `reuseExistingServer: !process.env.CI` and a cached `.next` between runs; + note that per this repo's worktree recipe, a cold `.next` in a worktree causes Prisma + 500s, so the E2E setup must seed `.next` or run in the main checkout. + +6. **BigInt in assertions (Phases 1-2).** Storage sizes are `bigint`. `expect(x).toBe(1)` + fails against `1n`. Establish the convention early: always compare + `BigInt(...)` to `BigInt(...)`. + +--- + +## 10. Effort and sequencing + +| Phase | Scope | Rough effort | Value | +| ----------------- | ------------------------------------ | -------------- | ------------- | +| 0 Foundation | config, scripts, lint/ignore wiring | half a session | enabling | +| 1 Unit | ~200 tests, 20 files | 1-2 sessions | **very high** | +| 2 API | infra + auth matrix + 12 deep suites | 3-4 sessions | **very high** | +| 3 E2E | 9 specs + compose profile | 2-3 sessions | high | +| 4 Component/hooks | ~10 targets | 1-2 sessions | medium | +| 5 CI + hooks | 3 jobs, pre-push, docs | half a session | high | + +Recommended order of delivery: **0 → 1 → 5 (partial: pre-push + `test` job) → 2 → 3 → 4**. +Wiring CI right after Phase 1 means the tests start protecting `master` while they are +still cheap, instead of waiting for the whole pyramid. + +--- + +## 11. Non-goals + +Written down so they do not get relitigated: + +- No snapshot tests. +- No tests for `components/ui/*` (upstream shadcn/Radix). +- No mocked Prisma client. +- No 100% coverage target. Coverage is a diagnostic, not a goal. +- No test for a getter, a re-export, or a constant. +- No visual regression testing (Percy/Chromatic) at this stage. +- No load or performance testing at this stage. + +--- + +## 12. Where the plan was wrong + +Corrections found while implementing it. Recorded so the sections above are read with them +in mind, and so nobody "fixes" a deliberate deviation back. + +**Infrastructure** + +- `prisma migrate deploy` **cannot build this database**, which invalidates Section 5's + instruction. `prisma/migrations` is a stack of patches on top of a baseline that was + never captured, so the second migration runs `ALTER TYPE "VideoAssetKind" ADD VALUE` + against a type nothing in the history creates, and dies with P3018 / 42704 on an empty + database. The test schema therefore comes from `prisma db push` plus a replay of the + hand-written SQL that `schema.prisma` cannot express (`cleanup_rate_limits()`, the + `UNLOGGED` rate-limit table, three partial unique indexes on `video_versions`). This is + the same approach `scripts/docker-db-bootstrap.ts` already takes in production. + `tests/setup/db-global.ts` documents it in full and carries a drift guard that fails the + run when a migration is added without review. +- **Coverage does not work under bun.** `@vitest/coverage-v8` needs the V8 inspector API, + which bun does not implement, so `bun run test:coverage` reports zeros. The suites pass + under both runtimes, so CI runs the coverage step under node instead. Getting the unit + project to run under node needed `server.deps.inline: [/next-auth/]`, because + `next-auth/lib/env.js` imports the extensionless `next/server`, which node's ESM + resolver cannot resolve and bun can. +- **`@playwright/test` is pinned to 1.61.1, not the newest release.** The container image + is what fixes the ceiling: `mcr.microsoft.com/playwright:v1.62.0-noble` is not published, + and Playwright refuses a browser build that does not match the package. Bump the package + and the image tag together, and check the tag exists first. +- **The Playwright image ships no bun**, and `oven-sh/setup-bun` cannot run inside it + either, because the image has no `unzip`. Both the CI job and `scripts/test.sh` install + bun with `npm install --global bun` first. +- **eslint keeps its own ignore list**, so a coverage run used to break `bun run lint` on + the reporter's vendored JS. `coverage/**`, `playwright-report/**` and `test-results/**` + are now in `globalIgnores`. +- **`tsconfig.json` targets ES2017, so `1n` is a compile error** (TS2737) even though the + BigInt type resolves. Always `BigInt(1)`. Section 9 framed this as a runtime assertion + mismatch and understated it. +- **Testing Library's auto-cleanup never installed.** It only registers its own + `afterEach(cleanup)` when a global `afterEach` is visible, and Section 2 mandates + explicit imports. Components stayed mounted for the rest of each file, with their + intervals and listeners live, which produced real cross-test contamination. + `tests/setup/component.ts` now calls `cleanup()` itself. +- **Risk #1 did not materialize.** `@/lib/auth` imports cleanly in a node test environment, + so the `lib/access.ts` extraction was not needed and was not done. Risk #3 was real but + `process.setMaxListeners(0)` in the api setup was enough, so `lib/db.ts` stays untouched. + +**Test environment** + +- **`OPENFRAME_ENABLE_STRIPE` must be `true`** in the test environment, not `false` as + Sections 5 and 6 suggested. With the flag off, `hasBillingAccess()` short-circuits to + `true` and `buildBillingAccessWhereInput()` returns `{}`, which disarms the entire + billing gate and makes every access-control assertion meaningless. Dummy Stripe keys are + used and no test walks into checkout. +- `DISABLE_RATE_LIMIT=true` for the api suite, because every request in it shares one + client IP and one file exhausting a window would make the next file's 429 look like a + passing authorization check. `rate-limit.test.ts` re-enables it per test. +- `vi.stubEnv` does **not** auto-restore. `tests/setup/api.ts` calls `vi.unstubAllEnvs()` + in `afterEach` centrally, after four tests were caught passing for the wrong reason. +- The E2E suite deliberately does not truncate between tests: several Playwright workers + drive one app against one database, so each test seeds uniquely tagged rows and deletes + its own users, letting the schema cascade do the rest. + +**Assignments in the plan that did not match the code** + +- `lib/share-links.ts` generates no tokens; it exports `validateShareLinkAccess`. +- `lib/approval-workflow.ts` has no status transition rules; it exports + `getApprovalCandidatesForProject`. +- `runWithConcurrency` returns `Promise`, so "results keep input order" is not a + property it can have. +- `lib/rate-limit.ts`'s real off-by-one lives in `checkRateLimit`, which Section 4 omitted. +- `use-video-player.ts` contains no timecode parser or formatter. `formatTime` is injected + as a parameter and is duplicated in four components; hoisting it into `lib/` is a + separate change. The extraction covered frame and playhead arithmetic instead. +- Section 5 lists `/api/watch/[videoId]` as public. It is not: it 403s anonymously on a + private project and needs a share-session cookie. +- Section 8's `.dockerignore` item does not achieve its stated goal. Image size comes from + a non-production `bun install` whose `node_modules` is copied wholesale into the runner + stage, not from source files. +- A coverage PR comment needs `pull-requests: write`, which conflicts with keeping + `permissions: contents: read`, so CI uploads an artifact instead. diff --git a/bun.lock b/bun.lock index a9ab07b..0c2a965 100644 --- a/bun.lock +++ b/bun.lock @@ -37,22 +37,31 @@ "devDependencies": { "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", + "@playwright/test": "1.61.1", "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/bcryptjs": "^3.0.0", "@types/node": "^20", "@types/nodemailer": "^7.0.9", "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.4", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9", "eslint-config-next": "16.1.6", "eslint-config-prettier": "^10.1.5", "husky": "^9.1.7", + "jsdom": "^29.1.1", "lint-staged": "^15.5.1", "prettier": "^3.5.3", "shadcn": "^3.8.3", "tailwindcss": "^4", "typescript": "^5", + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.10", }, }, }, @@ -64,10 +73,20 @@ "sharp": "^0.35.3", }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], + + "@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + "@auth/core": ["@auth/core@0.41.1", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw=="], "@auth/prisma-adapter": ["@auth/prisma-adapter@2.11.1", "", { "dependencies": { "@auth/core": "0.41.1" }, "peerDependencies": { "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" } }, "sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug=="], @@ -186,12 +205,18 @@ "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@10.5.0", "", { "dependencies": { "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw=="], "@chevrotain/gast": ["@chevrotain/gast@10.5.0", "", { "dependencies": { "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A=="], @@ -234,6 +259,18 @@ "@commitlint/types": ["@commitlint/types@19.8.1", "", { "dependencies": { "@types/conventional-commits-parser": "^5.0.0", "chalk": "^5.3.0" } }, "sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw=="], + "@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.10", "", { "dependencies": { "@csstools/color-helpers": "^6.1.0", "@csstools/css-calc": "^3.3.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.7", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.52.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], @@ -244,11 +281,11 @@ "@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.2.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" } }, "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A=="], - "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -268,6 +305,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="], @@ -370,7 +409,7 @@ "@mswjs/interceptors": ["@mswjs/interceptors@0.41.0", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-edAo9bW53BLYeSK+UPRr2Iz1Fj9DeGMjytvVM0HXRoo750ElWUgPsZPAOTQa12EUiwgDErH2PsFNTLvk1jBxjQ=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], "@next/env": ["@next/env@16.2.11", "", {}, "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA=="], @@ -414,8 +453,12 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], + "@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="], + "@prisma/adapter-pg": ["@prisma/adapter-pg@7.3.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.3.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-iuYQMbIPO6i9O45Fv8TB7vWu00BXhCaNAShenqF7gLExGDbnGp5BfFB4yz1K59zQ59jF6tQ9YHrg0P6/J3OoLg=="], "@prisma/client": ["@prisma/client@7.3.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.3.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-FXBIxirqQfdC6b6HnNgxGmU7ydCPEPk7maHMOduJJfnTP+MuOGa15X4omjR/zpPUUpm8ef/mEFQjJudOGkXFcQ=="], @@ -562,6 +605,38 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], + + "@rolldown/binding-wasm32-wasi": ["@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" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], @@ -620,14 +695,28 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11" } }, "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/conventional-commits-parser": ["@types/conventional-commits-parser@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -706,6 +795,24 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg=="], + + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="], + + "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], + + "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], + + "@vitest/snapshot": ["@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" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], + + "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], + + "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], + "JSONStream": ["JSONStream@1.3.5", "", { "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" }, "bin": { "JSONStream": "./bin.js" } }, "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -722,7 +829,7 @@ "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -752,10 +859,14 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="], + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], @@ -772,6 +883,8 @@ "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "bowser": ["bowser@2.13.1", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="], @@ -800,6 +913,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001768", "", {}, "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="], @@ -868,6 +983,10 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -880,6 +999,8 @@ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], @@ -890,6 +1011,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], @@ -914,6 +1037,8 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -924,6 +1049,8 @@ "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], @@ -946,6 +1073,8 @@ "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], + "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -960,6 +1089,8 @@ "es-iterator-helpers": ["es-iterator-helpers@1.2.2", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "safe-array-concat": "^1.1.3" } }, "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w=="], + "es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -1008,6 +1139,8 @@ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], @@ -1020,6 +1153,8 @@ "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], @@ -1074,6 +1209,8 @@ "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], @@ -1122,6 +1259,8 @@ "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -1158,6 +1297,10 @@ "hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], @@ -1178,6 +1321,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], @@ -1240,6 +1385,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], @@ -1276,6 +1423,12 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1284,10 +1437,12 @@ "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -1396,16 +1551,24 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], "lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], @@ -1426,6 +1589,8 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -1488,6 +1653,8 @@ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -1518,6 +1685,8 @@ "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], @@ -1554,7 +1723,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], @@ -1562,6 +1731,10 @@ "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], + + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], @@ -1588,6 +1761,8 @@ "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "prisma": ["prisma@7.3.0", "", { "dependencies": { "@prisma/config": "7.3.0", "@prisma/dev": "0.20.0", "@prisma/engines": "7.3.0", "@prisma/studio-core": "0.13.1", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-ApYSOLHfMN8WftJA+vL6XwAPOh/aZ0BgUyyKPwUFgjARmG6EBI9LzDPf6SWULQMSAxydV9qn5gLj037nPNlg2w=="], @@ -1622,7 +1797,7 @@ "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -1636,6 +1811,8 @@ "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="], @@ -1666,6 +1843,8 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "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" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], @@ -1680,6 +1859,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], @@ -1714,6 +1895,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], @@ -1732,9 +1915,11 @@ "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], @@ -1766,6 +1951,8 @@ "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "stripe": ["stripe@20.4.1", "", { "peerDependencies": { "@types/node": ">=16" }, "optionalPeers": ["@types/node"] }, "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w=="], @@ -1778,6 +1965,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], @@ -1792,10 +1981,14 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tldts": ["tldts@7.0.22", "", { "dependencies": { "tldts-core": "^7.0.22" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw=="], "tldts-core": ["tldts-core@7.0.22", "", {}, "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw=="], @@ -1804,12 +1997,16 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1838,6 +2035,8 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -1870,8 +2069,22 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], + + "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="], + + "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@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.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "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": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -1882,6 +2095,8 @@ "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], @@ -1890,8 +2105,12 @@ "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -1930,8 +2149,12 @@ "@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1952,8 +2175,6 @@ "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], - "@dotenvx/dotenvx/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -1968,18 +2189,20 @@ "@mrleebo/prisma-ast/lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], - "@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], - "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], "@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], + "@prisma/dev/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.3.0", "", { "dependencies": { "@prisma/debug": "7.3.0" } }, "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg=="], "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.3.0", "", { "dependencies": { "@prisma/debug": "7.3.0" } }, "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg=="], "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], + "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -1992,6 +2215,10 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@ts-morph/common/minimatch": ["minimatch@10.1.2", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.1" } }, "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -2000,6 +2227,8 @@ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], @@ -2034,8 +2263,6 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "fdir/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "is-bun-module/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -2048,6 +2275,14 @@ "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "magicast/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "msw/tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next-auth/@auth/core": ["@auth/core@0.41.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ=="], @@ -2062,8 +2297,12 @@ "pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -2082,7 +2321,17 @@ "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "vite/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "vite/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], + + "vite/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -2130,18 +2379,24 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "eslint-plugin-import/tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "magicast/@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "shadcn/execa/get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], @@ -2154,7 +2409,29 @@ "shadcn/execa/strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "vite/postcss/nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -2162,7 +2439,11 @@ "@dotenvx/dotenvx/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "magicast/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "magicast/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "shadcn/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], diff --git a/components/video-page/hooks/use-video-player.ts b/components/video-page/hooks/use-video-player.ts index 0128d7a..8248017 100644 --- a/components/video-page/hooks/use-video-player.ts +++ b/components/video-page/hooks/use-video-player.ts @@ -20,17 +20,19 @@ import type { Version, } from '@/components/video-page/types'; import { validateAnnotationStrokes } from '@/lib/validation'; - -// A frame number is only meaningful against a stable rate: a raw measurement -// drifts (29.94, 30.07, ...) and would slide the count by whole frames late in a -// long video. Snap to the nearest broadcast standard when we are close enough. -const STANDARD_FRAME_RATES = [23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60, 120]; - -function normalizeFrameRate(rate: number | undefined): number | null { - if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 12 || rate > 120) return null; - const standard = STANDARD_FRAME_RATES.find((value) => Math.abs(rate - value) / value < 0.015); - return standard ?? rate; -} +import { + clampSeekTime, + getAdjacentPlaybackSpeed, + getFrameIndexAtTime, + getFrameStepLabel, + getFrameStepSeconds, + getPlayheadPercent, + isTypingTarget, + normalizeFrameRate, + resolvePlayerShortcut, + resolveSkipAmount as resolveSkipAmountFor, + timeFromClientX as timeFromClientXWithin, +} from '@/components/video-page/hooks/video-player-utils'; interface UseVideoPlayerParams { activeVersion: Version | undefined; @@ -133,19 +135,12 @@ export function useVideoPlayer({ }; }, []); - const frameStepSeconds = useMemo(() => { - if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) { - return 1 / estimatedFrameRate; - } - return 1; - }, [estimatedFrameRate]); + const frameStepSeconds = useMemo( + () => getFrameStepSeconds(estimatedFrameRate), + [estimatedFrameRate] + ); - const frameStepLabel = useMemo(() => { - if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) { - return '1f'; - } - return '1s'; - }, [estimatedFrameRate]); + const frameStepLabel = useMemo(() => getFrameStepLabel(estimatedFrameRate), [estimatedFrameRate]); const stopBunnyFrameTracking = useCallback(() => { const videoEl = videoRef.current; @@ -950,7 +945,7 @@ export function useVideoPlayer({ const applyPlayhead = useCallback( (time: number) => { const d = durationRef.current; - const percent = d > 0 ? Math.max(0, Math.min(100, (time / d) * 100)) : 0; + const percent = getPlayheadPercent(time, d); if (progressRef.current) progressRef.current.style.width = `${percent}%`; if (playheadRef.current) playheadRef.current.style.left = `calc(${percent}% - 2px)`; @@ -963,11 +958,7 @@ export function useVideoPlayer({ if (rate === null) { readoutEl.textContent = formatTime(time); } else { - // Frame N covers [N/rate, (N+1)/rate); the epsilon keeps a time that - // lands exactly on a boundary from floating-point-ing down to N-1. - const lastFrame = d > 0 ? Math.max(0, Math.ceil(d * rate) - 1) : 0; - const frame = Math.min(Math.floor(time * rate + 1e-6), lastFrame); - readoutEl.textContent = `${formatTime(time)} · f${frame}`; + readoutEl.textContent = `${formatTime(time)} · f${getFrameIndexAtTime(time, rate, d)}`; } } }, @@ -1043,11 +1034,7 @@ export function useVideoPlayer({ }, [currentTime, isPlaying, isDragging, applyPlayhead]); const resolveSkipAmount = useCallback( - (seconds: number) => { - if (!isFrameMode) return seconds; - const direction = seconds === 0 ? 1 : Math.sign(seconds); - return frameStepSeconds * direction; - }, + (seconds: number) => resolveSkipAmountFor(seconds, { isFrameMode, frameStepSeconds }), [frameStepSeconds, isFrameMode] ); @@ -1118,7 +1105,7 @@ export function useVideoPlayer({ const handleSkip = useCallback( (seconds: number) => { - const newTime = Math.max(0, Math.min(duration, currentTime + resolveSkipAmount(seconds))); + const newTime = clampSeekTime(currentTime + resolveSkipAmount(seconds), duration); handleSeekToTimestamp(newTime); flashSeekReadout(); }, @@ -1131,15 +1118,23 @@ export function useVideoPlayer({ return; } - const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + if (isTypingTarget(e.target as HTMLElement)) { return; } - switch (e.code) { - case 'Space': - case 'KeyK': - e.preventDefault(); + const shortcut = resolvePlayerShortcut(e); + if (shortcut === null) return; + e.preventDefault(); + + const stepPlaybackSpeed = (direction: 1 | -1) => { + const newSpeed = getAdjacentPlaybackSpeed(speedOptions, playbackSpeed, direction); + if (newSpeed === null) return; + setPlaybackSpeed(newSpeed); + playerRef.current?.setPlaybackRate(newSpeed); + }; + + switch (shortcut) { + case 'toggle-play': if (playerRef.current) { if (isPlaying) { playerRef.current.pauseVideo(); @@ -1148,60 +1143,19 @@ export function useVideoPlayer({ } } break; - case 'ArrowLeft': - e.preventDefault(); + case 'skip-back': handleSkip(-5); break; - case 'ArrowRight': - e.preventDefault(); + case 'skip-forward': handleSkip(5); break; - case 'ArrowUp': - e.preventDefault(); - { - const currentIndex = speedOptions.indexOf(playbackSpeed); - if (currentIndex < speedOptions.length - 1) { - const newSpeed = speedOptions[currentIndex + 1]; - setPlaybackSpeed(newSpeed); - playerRef.current?.setPlaybackRate(newSpeed); - } - } + case 'speed-up': + stepPlaybackSpeed(1); break; - case 'ArrowDown': - e.preventDefault(); - { - const currentIndex = speedOptions.indexOf(playbackSpeed); - if (currentIndex > 0) { - const newSpeed = speedOptions[currentIndex - 1]; - setPlaybackSpeed(newSpeed); - playerRef.current?.setPlaybackRate(newSpeed); - } - } + case 'speed-down': + stepPlaybackSpeed(-1); break; - case 'Comma': - if (e.shiftKey) { - e.preventDefault(); - const currentIndex = speedOptions.indexOf(playbackSpeed); - if (currentIndex > 0) { - const newSpeed = speedOptions[currentIndex - 1]; - setPlaybackSpeed(newSpeed); - playerRef.current?.setPlaybackRate(newSpeed); - } - } - break; - case 'Period': - if (e.shiftKey) { - e.preventDefault(); - const currentIndex = speedOptions.indexOf(playbackSpeed); - if (currentIndex < speedOptions.length - 1) { - const newSpeed = speedOptions[currentIndex + 1]; - setPlaybackSpeed(newSpeed); - playerRef.current?.setPlaybackRate(newSpeed); - } - } - break; - case 'KeyM': - e.preventDefault(); + case 'toggle-mute': if (playerRef.current) { if (isMuted) { playerRef.current.unMute(); @@ -1211,8 +1165,7 @@ export function useVideoPlayer({ setIsMuted(!isMuted); } break; - case 'KeyJ': - e.preventDefault(); + case 'jump-back': if (playerRef.current?.seekTo) { const newTime = Math.max(0, currentTime - 10); playerRef.current.seekTo(newTime, true); @@ -1220,8 +1173,7 @@ export function useVideoPlayer({ flashSeekReadout(); } break; - case 'KeyL': - e.preventDefault(); + case 'jump-forward': if (playerRef.current?.seekTo) { const newTime = Math.min(duration, currentTime + 10); playerRef.current.seekTo(newTime, true); @@ -1229,8 +1181,7 @@ export function useVideoPlayer({ flashSeekReadout(); } break; - case 'KeyF': - e.preventDefault(); + case 'toggle-fullscreen': toggleFullscreen(); break; } @@ -1308,10 +1259,7 @@ export function useVideoPlayer({ // Convert a clientX into a time using the timeline rect captured at drag start // (avoids a layout read on every move). const timeFromClientX = useCallback((clientX: number) => { - const rect = dragRectRef.current; - if (!rect || rect.width === 0) return 0; - const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); - return percentage * durationRef.current; + return timeFromClientXWithin(clientX, dragRectRef.current, durationRef.current); }, []); const handleTimelineMouseDown = useCallback( diff --git a/components/video-page/hooks/video-player-utils.ts b/components/video-page/hooks/video-player-utils.ts new file mode 100644 index 0000000..608dd26 --- /dev/null +++ b/components/video-page/hooks/video-player-utils.ts @@ -0,0 +1,156 @@ +/** + * Pure helpers extracted from `use-video-player.ts`. + * + * The hook itself is ~1400 lines of hls.js wiring, iframe messaging and + * requestAnimationFrame loops that jsdom cannot run. The arithmetic below is the + * part that is actually worth pinning down with tests, so it lives here where it + * can be called directly with fixed inputs. Nothing in this module touches + * React, the DOM or any player SDK. + */ + +// A frame number is only meaningful against a stable rate: a raw measurement +// drifts (29.94, 30.07, ...) and would slide the count by whole frames late in a +// long video. Snap to the nearest broadcast standard when we are close enough. +const STANDARD_FRAME_RATES = [23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60, 120]; + +export function normalizeFrameRate(rate: number | undefined): number | null { + if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 12 || rate > 120) return null; + const standard = STANDARD_FRAME_RATES.find((value) => Math.abs(rate - value) / value < 0.015); + return standard ?? rate; +} + +/** + * How far a single frame-mode step moves the playhead. Falls back to one second + * when no frame rate has been measured yet, which is also what the label says. + */ +export function getFrameStepSeconds(estimatedFrameRate: number | null): number { + if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) { + return 1 / estimatedFrameRate; + } + return 1; +} + +export function getFrameStepLabel(estimatedFrameRate: number | null): string { + if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) { + return '1f'; + } + return '1s'; +} + +/** + * In frame mode every skip collapses to exactly one frame, keeping only the + * direction of the requested jump. A zero-second request counts as forward. + */ +export function resolveSkipAmount( + seconds: number, + options: { isFrameMode: boolean; frameStepSeconds: number } +): number { + if (!options.isFrameMode) return seconds; + const direction = seconds === 0 ? 1 : Math.sign(seconds); + return options.frameStepSeconds * direction; +} + +export function clampSeekTime(time: number, duration: number): number { + return Math.max(0, Math.min(duration, time)); +} + +/** Timeline fill / playhead offset, as a percentage clamped to [0, 100]. */ +export function getPlayheadPercent(time: number, duration: number): number { + return duration > 0 ? Math.max(0, Math.min(100, (time / duration) * 100)) : 0; +} + +/** + * Frame N covers [N/rate, (N+1)/rate); the epsilon keeps a time that lands + * exactly on a boundary from floating-point-ing down to N-1. The result never + * exceeds the last frame the duration can hold. + */ +export function getFrameIndexAtTime(time: number, frameRate: number, duration: number): number { + const lastFrame = duration > 0 ? Math.max(0, Math.ceil(duration * frameRate) - 1) : 0; + return Math.min(Math.floor(time * frameRate + 1e-6), lastFrame); +} + +/** Convert a pointer x-coordinate into a time, using a captured timeline rect. */ +export function timeFromClientX( + clientX: number, + rect: { left: number; width: number } | null, + duration: number +): number { + if (!rect || rect.width === 0) return 0; + const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + return percentage * duration; +} + +/** + * Next or previous entry in the speed ladder, or `null` at either end. An + * unknown current speed behaves like index -1, so stepping up lands on the + * slowest option and stepping down does nothing. + */ +export function getAdjacentPlaybackSpeed( + speedOptions: number[], + currentSpeed: number, + direction: 1 | -1 +): number | null { + const currentIndex = speedOptions.indexOf(currentSpeed); + if (direction === 1) { + if (currentIndex >= speedOptions.length - 1) return null; + return speedOptions[currentIndex + 1]; + } + if (currentIndex <= 0) return null; + return speedOptions[currentIndex - 1]; +} + +export type PlayerShortcut = + | 'toggle-play' + | 'skip-back' + | 'skip-forward' + | 'speed-up' + | 'speed-down' + | 'toggle-mute' + | 'jump-back' + | 'jump-forward' + | 'toggle-fullscreen'; + +/** + * Map a physical key to a player action. `null` means "not a player shortcut", + * and the caller must then leave the event alone (no `preventDefault`), so that + * an unshifted comma still types a comma. + */ +export function resolvePlayerShortcut(event: { + code: string; + shiftKey?: boolean; +}): PlayerShortcut | null { + switch (event.code) { + case 'Space': + case 'KeyK': + return 'toggle-play'; + case 'ArrowLeft': + return 'skip-back'; + case 'ArrowRight': + return 'skip-forward'; + case 'ArrowUp': + return 'speed-up'; + case 'ArrowDown': + return 'speed-down'; + case 'Comma': + return event.shiftKey ? 'speed-down' : null; + case 'Period': + return event.shiftKey ? 'speed-up' : null; + case 'KeyM': + return 'toggle-mute'; + case 'KeyJ': + return 'jump-back'; + case 'KeyL': + return 'jump-forward'; + case 'KeyF': + return 'toggle-fullscreen'; + default: + return null; + } +} + +/** True when the keystroke belongs to a text field and must not be hijacked. */ +export function isTypingTarget(target: HTMLElement): boolean { + return ( + target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable === true + ); +} diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..6c6fdb6 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,159 @@ +# Disposable infrastructure for the automated test suites. Everything here is +# on non-default ports so it can never collide with the dev stack in +# docker-compose.yml. +# +# The `openframe-test` network is declared external so that the test runner +# container can reach these services by service name. Create it once with: +# podman network create openframe-test +# +# Two tiers, split by compose profile: +# +# (no profile) postgres-test. Needed by the `api` Vitest project and by the +# Playwright suite. Started by `bun run test:db:up`. +# e2e minio-test + minio-test-init (real S3-compatible storage for +# the direct video upload flow) and app-test (the app under +# test). Started by `scripts/test.sh e2e`. +# +# Usage: +# podman compose -f docker-compose.test.yml up -d --wait postgres-test +# podman run --rm --network openframe-test -v "$PWD":/workspace:z -w /workspace \ +# docker.io/oven/bun:alpine sh -c "bun run test:api" +# +# podman compose -f docker-compose.test.yml --profile e2e up -d --wait \ +# postgres-test minio-test +# scripts/test.sh e2e +# +# app-test is opt-in and only needed when you want the app under test to run as +# a container instead of being started by Playwright's own `webServer`: +# podman compose -f docker-compose.test.yml --profile e2e up -d --wait app-test +# E2E_BASE_URL=http://app-test:3100 scripts/test.sh e2e + +services: + postgres-test: + image: postgres:16-alpine + container_name: openframe-postgres-test + environment: + POSTGRES_USER: openframe + POSTGRES_PASSWORD: openframe + POSTGRES_DB: openframe_test + # fsync off: the data directory is a tmpfs that is thrown away anyway, so + # durability buys nothing and costs a lot of wall clock time. + command: ['postgres', '-c', 'fsync=off', '-c', 'full_page_writes=off'] + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U openframe -d openframe_test'] + interval: 2s + timeout: 3s + retries: 30 + ports: + - '127.0.0.1:55432:5432' + networks: + - openframe-test + + # S3-compatible object storage for the direct video upload path. The browser + # PUTs the file straight at this endpoint with a presigned URL, so the + # hostname the app signs for has to be the hostname the browser resolves: + # both the Playwright container and the app under test are on this network, + # so both use `minio-test:9000`. MinIO's default CORS policy allows every + # origin and exposes ETag, which is exactly what lib/client/r2-video-upload.ts + # needs, so no bucket CORS configuration is involved. + minio-test: + profiles: ['e2e'] + image: docker.io/minio/minio:latest + container_name: openframe-minio-test + command: ['server', '/data', '--console-address', ':9001'] + environment: + MINIO_ROOT_USER: openframe + MINIO_ROOT_PASSWORD: openframe-test-secret + # lib/r2.ts builds its S3 client with `region: 'auto'`, so MinIO has to + # accept that region name in the SigV4 signature. + MINIO_REGION_NAME: auto + # Same reasoning as postgres-test: the data is disposable, so keep it in RAM. + tmpfs: + - /data + healthcheck: + test: ['CMD', 'mc', 'ready', 'local'] + interval: 2s + timeout: 3s + retries: 30 + ports: + - '127.0.0.1:59000:9000' + - '127.0.0.1:59001:9001' + networks: + - openframe-test + + # Creates the bucket. Nothing at runtime does: ensureR2BucketExists() lives in + # scripts/self-host-bootstrap.ts, not on the request path, so a missing bucket + # would surface as a presigned PUT returning NoSuchBucket. + minio-test-init: + profiles: ['e2e'] + image: docker.io/minio/mc:latest + container_name: openframe-minio-test-init + depends_on: + minio-test: + condition: service_healthy + entrypoint: + - sh + - -c + - > + mc alias set testminio http://minio-test:9000 openframe openframe-test-secret && + mc mb --ignore-existing testminio/openframe-test && + mc ls testminio + networks: + - openframe-test + + # The app under test, for the case where you do not want Playwright to start + # it. This is the same command playwright.config.ts uses for its `webServer`, + # run against the mounted working tree so `.next` stays warm between runs. + # + # NODE_ENV is left to `next build` / `next start` (production). The runtime + # values below are duplicated from .env.test.example on purpose: `next build` + # never loads `.env.test`, and NEXT_PUBLIC_APP_URL is inlined at build time. + app-test: + profiles: ['e2e'] + image: docker.io/oven/bun:alpine + container_name: openframe-app-test + depends_on: + postgres-test: + condition: service_healthy + minio-test: + condition: service_healthy + working_dir: /workspace + volumes: + - .:/workspace:z + environment: + DATABASE_URL: postgresql://openframe:openframe@postgres-test:5432/openframe_test?schema=public + NEXTAUTH_URL: http://app-test:3100 + NEXTAUTH_SECRET: test-secret-not-used-for-anything-real + NEXT_PUBLIC_APP_URL: http://app-test:3100 + OPENFRAME_ENABLE_STRIPE: 'true' + STRIPE_SECRET_KEY: sk_test_openframe_dummy + STRIPE_PRICE_ID: price_test_openframe_dummy + OPENFRAME_REQUIRE_INVITE_CODE: 'true' + INVITE_CODE: test-invite + TRUSTED_PROXY_MODE: none + OPENFRAME_ENABLE_S3_VIDEO_UPLOADS: 'true' + OPENFRAME_ENABLE_BUNNY_UPLOADS: 'false' + R2_ENDPOINT: http://minio-test:9000 + R2_ACCESS_KEY_ID: openframe + R2_SECRET_ACCESS_KEY: openframe-test-secret + R2_BUCKET_NAME: openframe-test + PORT: '3100' + command: + - sh + - -c + - './node_modules/.bin/next build && ./node_modules/.bin/next start -p 3100 -H 0.0.0.0' + healthcheck: + test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:3100/login'] + interval: 5s + timeout: 5s + retries: 120 + ports: + - '127.0.0.1:3100:3100' + networks: + - openframe-test + +networks: + openframe-test: + external: true diff --git a/eslint.config.mjs b/eslint.config.mjs index f5bee78..6fbb1e8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,8 +13,24 @@ const eslintConfig = defineConfig([ 'out/**', 'build/**', 'next-env.d.ts', + // Generated test output. These are gitignored, but eslint keeps its own + // ignore list, and the v8 coverage reporter ships vendored JS that trips + // `--max-warnings=0`, so a coverage run would otherwise break `bun run lint`. + 'coverage/**', + 'playwright-report/**', + 'test-results/**', ]), prettier, + { + // Tests are allowed liberties that production code is not: `any` when + // shaping a fixture, and imports that reach past the `@/` aliases into + // test helpers. `--max-warnings=0` still applies to everything else. + files: ['tests/**/*.{ts,tsx}'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + 'no-restricted-imports': 'off', + }, + }, ]); export default eslintConfig; diff --git a/package.json b/package.json index b07c2d8..1f6ee90 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,16 @@ "format:check": "prettier --check .", "typecheck": "tsc --noEmit", "check": "bun run lint && bun run format:check && bun run typecheck", + "test": "vitest run --project unit --project component", + "test:watch": "vitest --project unit --project component", + "test:api": "vitest run --project api", + "test:e2e": "playwright test", + "test:all": "bun run test && bun run test:api && bun run test:e2e", + "test:coverage": "vitest run --project unit --coverage", + "verify": "bun run check && bun run test", + "test:db:up": "podman compose -f docker-compose.test.yml up -d --wait postgres-test", + "test:db:down": "podman compose -f docker-compose.test.yml down -v", + "test:db:bootstrap": "bun run scripts/test-db-bootstrap.ts", "prepare": "husky", "postinstall": "prisma generate", "db:generate": "prisma generate", @@ -60,22 +70,31 @@ "devDependencies": { "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", + "@playwright/test": "1.61.1", "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/bcryptjs": "^3.0.0", "@types/node": "^20", "@types/nodemailer": "^7.0.9", "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.4", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9", "eslint-config-next": "16.1.6", "eslint-config-prettier": "^10.1.5", "husky": "^9.1.7", + "jsdom": "^29.1.1", "lint-staged": "^15.5.1", "prettier": "^3.5.3", "shadcn": "^3.8.3", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.10" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..e5a6fef --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,158 @@ +import { defineConfig, devices } from '@playwright/test'; + +// --------------------------------------------------------------------------- +// End-to-end suite. See TESTING.md section 6. +// +// Report output: ./playwright-report (HTML), ./test-results (traces, videos). +// Both are gitignored and both are what the `e2e` job in ci.yml uploads. +// +// Port 3100, not 3000. The developer's dev server owns 3000 on this machine, +// and reuseExistingServer would happily attach the whole suite to it, pointing +// every test at the development database. +// --------------------------------------------------------------------------- + +const PORT = Number(process.env.E2E_PORT ?? 3100); + +/** + * Where the tests point their browser. + * + * Set E2E_BASE_URL to run against an app you started yourself (the `app-test` + * service in docker-compose.test.yml, for instance). Leaving it unset is the + * normal path: Playwright builds and starts the app itself, below. + */ +const BASE_URL = process.env.E2E_BASE_URL ?? `http://localhost:${PORT}`; + +const MANAGES_OWN_SERVER = !process.env.E2E_BASE_URL; + +/** + * Environment for the app under test. + * + * `.env.test` is deliberately not reused here. Two reasons: + * + * 1. `next build` runs with NODE_ENV=production and never loads `.env.test`, + * and NEXT_PUBLIC_APP_URL is inlined into the client bundle at build time, + * so the build needs these values passed in explicitly anyway. + * 2. The R2_* variables below must NOT leak into the `api` Vitest project. + * `hasR2Config()` is derived from them, so putting them in `.env.test` + * would flip `isDirectFileUploadEnabled()` to true for 537 API tests that + * currently assert the unconfigured branch. + */ +const APP_ENV: Record = { + DATABASE_URL: + process.env.DATABASE_URL ?? + 'postgresql://openframe:openframe@postgres-test:5432/openframe_test?schema=public', + + NEXTAUTH_URL: BASE_URL, + NEXT_PUBLIC_APP_URL: BASE_URL, + NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET ?? 'test-secret-not-used-for-anything-real', + // Required. NextAuth v5 refuses every /api/auth/* request with + // `UntrustedHost` in production builds unless the host is trusted, which is + // why .env.docker.example sets the same variable for real deployments. + AUTH_TRUST_HOST: 'true', + + // Stripe stays ON, with dummy credentials. With the flag off, + // hasBillingAccess() short-circuits to `true` and + // buildBillingAccessWhereInput() returns `{}`, so the billing gate that + // billing-gate.spec.ts exists to verify would not be armed at all. No spec + // walks into checkout, so no request ever reaches Stripe. + OPENFRAME_ENABLE_STRIPE: 'true', + STRIPE_SECRET_KEY: 'sk_test_openframe_dummy', + STRIPE_PRICE_ID: 'price_test_openframe_dummy', + STRIPE_WEBHOOK_SECRET: 'whsec_test_openframe_dummy', + + OPENFRAME_REQUIRE_INVITE_CODE: 'true', + INVITE_CODE: 'test-invite', + TRUSTED_PROXY_MODE: 'none', + + // Direct video uploads through the MinIO service in docker-compose.test.yml. + // Without these the `Direct Upload` tab does not render at all, because + // app/(dashboard)/projects/[projectId]/videos/new/page.tsx passes + // isDirectFileUploadEnabled() into the client. + // + // The endpoint is the container hostname on purpose: the browser PUTs the + // file straight at the presigned URL, so the host the app signs for has to be + // the host the browser can resolve. Its origin is added to the CSP + // connect-src automatically by lib/content-security-policy.ts. + OPENFRAME_ENABLE_S3_VIDEO_UPLOADS: 'true', + OPENFRAME_ENABLE_BUNNY_UPLOADS: 'false', + R2_ENDPOINT: process.env.R2_ENDPOINT ?? 'http://minio-test:9000', + R2_ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID ?? 'openframe', + R2_SECRET_ACCESS_KEY: process.env.R2_SECRET_ACCESS_KEY ?? 'openframe-test-secret', + R2_BUCKET_NAME: process.env.R2_BUCKET_NAME ?? 'openframe-test', + + // Email verification must stay off, or a user registered through the form in + // auth.spec.ts cannot sign in until a message that nothing delivers has been + // clicked. isEmailVerificationEnabled() is derived from SMTP_HOST/USER/ + // PASSWORD, so leaving those unset is what disables it. .env.test sets them + // for the api suite, which mocks nodemailer; nothing mocks it here. +}; + +export default defineConfig({ + testDir: './tests/e2e', + outputDir: './test-results', + + // Every spec seeds its own rows and deletes them again, so files are safe to + // interleave. What they share is one app process and one database. + fullyParallel: true, + // Capped rather than left to the core count: the limit is the single Next + // server, and the DB-backed rate limiter is keyed on the client IP, which is + // the same address for every worker. + workers: process.env.CI ? 2 : 4, + + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + + // A cold run has to build the app first, and `next build` on this codebase + // takes minutes; the per-test timeout is unrelated to that but the whole-run + // one is not. + timeout: 90_000, + expect: { timeout: 15_000 }, + + // `open: 'never'` matters locally too: the report server would otherwise hold + // the run open inside a container that has no browser to open it with. + reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]], + + globalSetup: './tests/e2e/global-setup.ts', + + use: { + baseURL: BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'off', + // Chromium in a container is slower than on a desktop, and the first + // navigation after a cold start pays for the route being compiled. + actionTimeout: 20_000, + navigationTimeout: 45_000, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + testIgnore: '**/dashboard-mobile.spec.ts', + }, + { + // One mobile project, for one spec. Section 6 asks for a mobile smoke + // test, not a second full pass. + name: 'mobile-chrome', + use: { ...devices['Pixel 7'] }, + testMatch: '**/dashboard-mobile.spec.ts', + }, + ], + + webServer: MANAGES_OWN_SERVER + ? { + // `bun run build` would re-run `prebuild` (tsc --noEmit) on every cold + // start, which `bun run check` already covers. next is invoked through + // its bin so this works under both bun and node. + command: `./node_modules/.bin/next build && ./node_modules/.bin/next start -p ${PORT}`, + url: `${BASE_URL}/login`, + reuseExistingServer: !process.env.CI, + // A cold `next build` here measured a little over three minutes. + timeout: 15 * 60 * 1000, + stdout: 'pipe', + stderr: 'pipe', + env: APP_ENV, + } + : undefined, +}); diff --git a/scripts/test-db-bootstrap.ts b/scripts/test-db-bootstrap.ts new file mode 100644 index 0000000..3c6b641 --- /dev/null +++ b/scripts/test-db-bootstrap.ts @@ -0,0 +1,22 @@ +/** + * Builds the test database schema outside of Vitest. + * + * The `api` Vitest project gets this for free through its globalSetup, but the + * end-to-end suite runs the real app against the same database and needs the + * schema in place before the server starts. Both paths therefore call the same + * setup function, so there is exactly one description of how a test database is + * built (including why it uses `prisma db push` rather than `migrate deploy`, + * which is documented at the top of tests/setup/db-global.ts). + * + * Usage: bun run test:db:bootstrap + */ +import { setup } from '../tests/setup/db-global'; + +setup() + .then(() => { + console.log('Test database schema is ready'); + }) + .catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..34e9e4c --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env sh +# One entry point for the OpenFrame test suites. +# +# scripts/test.sh unit vitest unit + component projects +# scripts/test.sh api vitest api project (needs the test database) +# scripts/test.sh e2e playwright specs (needs the test database) +# scripts/test.sh all unit, then api, then e2e +# +# Run the api and e2e suites one at a time, never side by side. They share one +# database, and the api suite empties every table after each of its tests, so a +# concurrent e2e run loses the rows it seeded and fails for no real reason. +# `all` runs them in sequence for exactly this reason. +# +# Every suite runs inside a container, so no package manager runs on the host. +# TESTING.md section 8 documents the raw podman commands this wraps. + +set -eu + +bun_image='docker.io/oven/bun:alpine' +# Pinned to the installed @playwright/test version. The image carries the +# matching browser build, and Playwright refuses a mismatched pair. Microsoft +# publishes the image some time after the npm release, so check the tag exists +# before bumping either half: +# curl -sI https://mcr.microsoft.com/v2/playwright/manifests/v1.61.1-noble +playwright_image='mcr.microsoft.com/playwright:v1.61.1-noble' +# Shared podman network, so the runner container reaches Postgres by service +# name instead of a published port. +network='openframe-test' + +# Resolve the repo root from this script's own location, so the script behaves +# the same from any working directory. +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH='' cd -- "$script_dir/.." && pwd) + +compose_file="$repo_root/docker-compose.test.yml" +playwright_config="$repo_root/playwright.config.ts" +env_test="$repo_root/.env.test" +env_test_example="$repo_root/.env.test.example" + +# Attach a TTY only when there is one, so the script also works from a hook, +# a pipe, or a CI runner. +if [ -t 1 ]; then + tty_flag='-t' +else + tty_flag='' +fi + +usage() { + cat <<'EOF' +Usage: scripts/test.sh + + unit Unit and component suites. No database, no browser. + api API integration suites. Starts the disposable test Postgres first. + e2e Playwright end-to-end specs. Starts the test Postgres and MinIO first, + then builds and starts the app itself on port 3100. + all unit, then api, then e2e. + +The containers keep running afterwards so the next run is fast. Stop them +with: podman compose -f docker-compose.test.yml --profile e2e down -v +EOF +} + +say() { + printf '\n==> %s\n' "$1" +} + +# Each argument is printed on its own line, so a diagnostic can carry the fix +# right under the problem. +die() { + printf 'scripts/test.sh: %s\n' "$1" >&2 + shift + for line in "$@"; do + printf ' %s\n' "$line" >&2 + done + exit 1 +} + +show() { + printf '+' + for word in "$@"; do + # Quote the arguments that contain spaces, so the printed line reads like + # something you could paste back into a shell. + case $word in + *' '*) printf " '%s'" "$word" ;; + *) printf ' %s' "$word" ;; + esac + done + printf '\n' +} + +# Prints the command, runs it, and exits with the command's own status so the +# caller (a hook, CI, or a shell) sees the real result. +run_cmd() { + show "$@" + set +e + "$@" + status=$? + set -e + if [ "$status" -ne 0 ]; then + printf '\nscripts/test.sh: %s exited with %s\n' "$1" "$status" >&2 + exit "$status" + fi +} + +# The tty_flag expansion below stays quoted when set and disappears entirely +# when empty, which a plain "$tty_flag" cannot do (it would pass an empty +# argument to podman). +run_in_bun_image() { + bun_network=$1 + bun_command=$2 + if [ -n "$bun_network" ]; then + run_cmd podman run --rm ${tty_flag:+"$tty_flag"} --network "$bun_network" \ + -v "$repo_root:/workspace:z" -w /workspace "$bun_image" sh -c "$bun_command" + else + run_cmd podman run --rm ${tty_flag:+"$tty_flag"} \ + -v "$repo_root:/workspace:z" -w /workspace "$bun_image" sh -c "$bun_command" + fi +} + +# --ipc=host is Playwright's documented requirement for Chromium in a +# container; without it Chromium runs out of shared memory on larger pages. +run_in_playwright_image() { + run_cmd podman run --rm ${tty_flag:+"$tty_flag"} --ipc=host --network "$network" \ + -v "$repo_root:/workspace:z" -w /workspace "$playwright_image" sh -c "$1" +} + +require_compose_file() { + [ -f "$compose_file" ] && return 0 + die "docker-compose.test.yml not found at $compose_file." \ + 'The test database ships with Phase 2 of TESTING.md (section 5), so the api' \ + 'and e2e suites cannot run until that lands.' +} + +require_env_test() { + [ -f "$env_test" ] && return 0 + if [ -f "$env_test_example" ]; then + die "$env_test not found." \ + 'Create it once with: cp .env.test.example .env.test' + fi + die "Neither $env_test nor $env_test_example exists." \ + 'Both ship with Phase 2 of TESTING.md (section 5).' +} + +require_playwright_config() { + [ -f "$playwright_config" ] && return 0 + die "playwright.config.ts not found at $playwright_config." \ + 'The end-to-end suite is Phase 3 of TESTING.md (section 6) and has not' \ + 'landed yet, so there is nothing for playwright to run.' +} + +ensure_network() { + if podman network exists "$network"; then + return 0 + fi + say "creating the $network podman network" + run_cmd podman network create "$network" +} + +# `podman compose up -d --wait` is not usable here. With podman-compose 1.6.0 as +# the provider it does not block on the healthcheck at all when the service is +# starting, and worse, it never returns when the service is already up and +# healthy: an e2e run was observed wedged on it for 22 minutes with nothing to +# show for it. So every service is started without --wait and its readiness is +# polled here instead. +wait_for_test_db() { + say 'waiting for postgres-test to accept connections' + attempt=0 + while [ "$attempt" -lt 60 ]; do + if podman compose -f "$compose_file" exec -T postgres-test \ + pg_isready -U openframe -d openframe_test >/dev/null 2>&1; then + printf 'postgres-test is ready\n' + return 0 + fi + attempt=$((attempt + 1)) + sleep 1 + done + die 'postgres-test did not become ready within 60 seconds.' \ + "Inspect it with: podman compose -f $compose_file logs postgres-test" +} + +wait_for_test_storage() { + say 'waiting for minio-test to report healthy' + attempt=0 + while [ "$attempt" -lt 60 ]; do + if podman exec openframe-minio-test mc ready local >/dev/null 2>&1; then + printf 'minio-test is ready\n' + return 0 + fi + attempt=$((attempt + 1)) + sleep 1 + done + die 'minio-test did not become ready within 60 seconds.' \ + "Inspect it with: podman compose -f $compose_file logs minio-test" +} + +start_test_db() { + ensure_network + say 'starting the test database' + run_cmd podman compose -f "$compose_file" up -d postgres-test + wait_for_test_db +} + +# Object storage for the direct video upload flow. The browser PUTs the file +# straight at the presigned URL, so this has to be real; there is nothing to +# mock at that boundary from inside a browser. +# +# minio-test-init is a one-shot container that creates the bucket. Nothing at +# runtime does: ensureR2BucketExists() lives in scripts/self-host-bootstrap.ts, +# not on the request path. +start_test_storage() { + say 'starting object storage for the upload flow' + # Asking compose to start a container that is already up prints a red + # `cannot start an already running container` error and keeps going, which + # reads like a failure in the log of an otherwise clean run. Skip the call + # instead. + if podman exec openframe-minio-test mc ready local >/dev/null 2>&1; then + printf 'minio-test is already running\n' + else + run_cmd podman compose -f "$compose_file" --profile e2e up -d minio-test + wait_for_test_storage + fi + # --no-deps: the init container declares depends_on minio-test, and without + # this compose tries to start that dependency again and prints the same + # spurious `already running` error the guard above exists to avoid. + run_cmd podman compose -f "$compose_file" --profile e2e up --no-deps minio-test-init +} + +# --frozen-lockfile keeps a test run from rewriting bun.lock as a side effect. +install_step='bun install --frozen-lockfile' + +run_unit() { + say 'unit and component suites' + run_in_bun_image '' "$install_step && bun run test" +} + +run_api() { + say 'api suites' + require_compose_file + require_env_test + start_test_db + run_in_bun_image "$network" "$install_step && bun run test:api" +} + +run_e2e() { + say 'end-to-end specs' + require_compose_file + require_playwright_config + require_env_test + start_test_db + start_test_storage + # The official Playwright image carries node and the browsers but not bun. + # bun is needed for `bun run test:e2e`; the web server inside + # playwright.config.ts runs next through node_modules/.bin, so it works under + # either runtime. bun is installed into the throwaway container, never on the + # host, and `oven-sh/setup-bun` cannot be used because the image has no unzip. + # + # Playwright starts and stops the app itself (`webServer`), on port 3100 so it + # cannot attach to a dev server on 3000. The build output lands in the mounted + # .next, which is what keeps the second run fast. + run_in_playwright_image \ + "npm install --global --silent bun && $install_step && bun run test:e2e" +} + +case "${1-}" in + -h | --help | help) + usage + exit 0 + ;; +esac + +if [ "$#" -ne 1 ]; then + printf 'scripts/test.sh: exactly one mode is required\n\n' >&2 + usage >&2 + exit 64 +fi + +if ! command -v podman >/dev/null 2>&1; then + die 'podman was not found on PATH.' \ + 'Every suite runs in a container, so podman is required.' +fi + +case "$1" in + unit) + run_unit + ;; + api) + run_api + ;; + e2e) + run_e2e + ;; + all) + run_unit + run_api + run_e2e + ;; + *) + printf 'scripts/test.sh: unknown mode "%s"\n\n' "$1" >&2 + usage >&2 + exit 64 + ;; +esac diff --git a/tests/api/approvals.test.ts b/tests/api/approvals.test.ts new file mode 100644 index 0000000..fde4e76 --- /dev/null +++ b/tests/api/approvals.test.ts @@ -0,0 +1,793 @@ +import { describe, expect, it } from 'vitest'; +import { db } from '@/lib/db'; +import { + GET as listApprovals, + POST as requestApproval, +} from '@/app/api/versions/[versionId]/approvals/route'; +import { POST as decideApproval } from '@/app/api/approvals/[requestId]/decision/route'; +import { POST as cancelApproval } from '@/app/api/approvals/[requestId]/cancel/route'; +import { GET as listCandidates } from '@/app/api/projects/[projectId]/approval-candidates/route'; +import { apiRequest, callRoute, readData } from '../helpers/request'; +import { signedInAs, signedOut } from '../helpers/session'; +import { + addProjectMember, + addWorkspaceMember, + createApprovalRequest, + createUser, + seedVersion, +} from '../factories'; + +function approvalsUrl(versionId: string): string { + return `/api/versions/${versionId}/approvals`; +} + +describe('GET /api/projects/[projectId]/approval-candidates', () => { + it('returns 401 without a session', async () => { + const scenario = await seedVersion(); + signedOut(); + + const response = await callRoute( + listCandidates, + apiRequest(`/api/projects/${scenario.project.id}/approval-candidates`), + { projectId: scenario.project.id } + ); + + expect(response.status).toBe(401); + }); + + it('returns 403 for a COMMENTATOR, who cannot request approvals', async () => { + const scenario = await seedVersion(); + const commentator = await createUser(); + await addProjectMember({ + projectId: scenario.project.id, + userId: commentator.id, + role: 'COMMENTATOR', + }); + signedInAs(commentator); + + const response = await callRoute( + listCandidates, + apiRequest(`/api/projects/${scenario.project.id}/approval-candidates`), + { projectId: scenario.project.id } + ); + + expect(response.status).toBe(403); + }); + + it('lists the project owner, project members, workspace owner and workspace members once each', async () => { + const scenario = await seedVersion(); + const projectMember = await createUser({ name: 'Bianca' }); + const workspaceMember = await createUser({ name: 'Cleo' }); + const both = await createUser({ name: 'Dana' }); + await addProjectMember({ projectId: scenario.project.id, userId: projectMember.id }); + await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: workspaceMember.id }); + await addProjectMember({ projectId: scenario.project.id, userId: both.id }); + await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: both.id }); + signedInAs(scenario.owner); + + const payload = await readData<{ candidates: Array<{ id: string }> }>( + await callRoute( + listCandidates, + apiRequest(`/api/projects/${scenario.project.id}/approval-candidates`), + { projectId: scenario.project.id } + ) + ); + + const ids = payload.candidates.map((entry) => entry.id).sort(); + expect(ids).toEqual([scenario.owner.id, projectMember.id, workspaceMember.id, both.id].sort()); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe('POST /api/versions/[versionId]/approvals', () => { + it('returns 401 without a session', async () => { + const scenario = await seedVersion(); + signedOut(); + + const response = await callRoute( + requestApproval, + apiRequest(approvalsUrl(scenario.version.id), { body: { approverIds: ['x'] } }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(401); + expect(await db.approvalRequest.count()).toBe(0); + }); + + it('returns 404 for an unknown version', async () => { + const user = await createUser(); + signedInAs(user); + + const response = await callRoute( + requestApproval, + apiRequest(approvalsUrl('nope'), { body: { approverIds: ['x'] } }), + { versionId: 'nope' } + ); + + expect(response.status).toBe(404); + }); + + it('returns 403 for a COMMENTATOR', async () => { + const scenario = await seedVersion(); + const commentator = await createUser(); + await addProjectMember({ + projectId: scenario.project.id, + userId: commentator.id, + role: 'COMMENTATOR', + }); + signedInAs(commentator); + + const response = await callRoute( + requestApproval, + apiRequest(approvalsUrl(scenario.version.id), { + body: { approverIds: [scenario.owner.id] }, + }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(403); + expect(await db.approvalRequest.count()).toBe(0); + }); + + it.each([ + [{}, 'no approverIds at all'], + [{ approverIds: [] }, 'an empty approver list'], + [{ approverIds: 'not-an-array' }, 'a non-array approverIds'], + [{ approverIds: ['', ' '] }, 'blank approver ids'], + [{ approverIds: [42, null] }, 'non-string approver ids'], + ])('rejects %j with 400 (%s)', async (body, label) => { + const scenario = await seedVersion(); + signedInAs(scenario.owner); + + const response = await callRoute( + requestApproval, + apiRequest(approvalsUrl(scenario.version.id), { body }), + { versionId: scenario.version.id } + ); + + expect(response.status, label).toBe(400); + expect(await db.approvalRequest.count()).toBe(0); + }); + + it('rejects a message longer than 2000 characters', async () => { + const scenario = await seedVersion(); + const approver = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: approver.id }); + signedInAs(scenario.owner); + + const response = await callRoute( + requestApproval, + apiRequest(approvalsUrl(scenario.version.id), { + body: { approverIds: [approver.id], message: 'x'.repeat(2001) }, + }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(400); + expect(await db.approvalRequest.count()).toBe(0); + }); + + it('refuses to let the requester approve their own request', async () => { + const scenario = await seedVersion(); + signedInAs(scenario.owner); + + const response = await callRoute( + requestApproval, + apiRequest(approvalsUrl(scenario.version.id), { + body: { approverIds: [scenario.owner.id] }, + }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(400); + expect(await db.approvalRequest.count()).toBe(0); + }); + + // The candidate set is derived from project and workspace membership. Anyone + // outside it cannot be nominated, which is what stops an arbitrary user id + // being written into approval_decisions. + it('refuses an approver who is not a candidate for the project', async () => { + const scenario = await seedVersion(); + const outsider = await createUser(); + signedInAs(scenario.owner); + + const response = await callRoute( + requestApproval, + apiRequest(approvalsUrl(scenario.version.id), { body: { approverIds: [outsider.id] } }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(400); + expect(await db.approvalRequest.count()).toBe(0); + expect(await db.approvalDecision.count()).toBe(0); + }); + + it('creates the request with one PENDING decision per de-duplicated approver', async () => { + const scenario = await seedVersion(); + const first = await createUser(); + const second = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: first.id }); + await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: second.id }); + signedInAs(scenario.owner); + + const response = await callRoute( + requestApproval, + apiRequest(approvalsUrl(scenario.version.id), { + body: { + approverIds: [first.id, ` ${first.id} `, second.id], + message: ' please review ', + }, + }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(201); + const stored = await db.approvalRequest.findFirstOrThrow({ include: { decisions: true } }); + expect(stored.status).toBe('PENDING'); + expect(stored.requestedById).toBe(scenario.owner.id); + expect(stored.message).toBe('please review'); + expect(stored.resolvedAt).toBeNull(); + expect(stored.decisions).toHaveLength(2); + expect(stored.decisions.map((entry) => entry.approverId).sort()).toEqual( + [first.id, second.id].sort() + ); + expect(stored.decisions.every((entry) => entry.status === 'PENDING')).toBe(true); + expect(stored.decisions.every((entry) => entry.respondedAt === null)).toBe(true); + }); + + it('returns 409 when a request is already pending on the version', async () => { + const scenario = await seedVersion(); + const approver = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: approver.id }); + await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [approver.id], + }); + signedInAs(scenario.owner); + + const response = await callRoute( + requestApproval, + apiRequest(approvalsUrl(scenario.version.id), { body: { approverIds: [approver.id] } }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(409); + expect(await db.approvalRequest.count()).toBe(1); + }); + + it('allows a new request once the previous one is resolved', async () => { + const scenario = await seedVersion(); + const approver = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: approver.id }); + await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [approver.id], + status: 'REJECTED', + resolvedAt: new Date(), + }); + signedInAs(scenario.owner); + + const response = await callRoute( + requestApproval, + apiRequest(approvalsUrl(scenario.version.id), { body: { approverIds: [approver.id] } }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(201); + expect(await db.approvalRequest.count()).toBe(2); + }); +}); + +describe('GET /api/versions/[versionId]/approvals', () => { + it('returns 403 for a signed-in stranger even on a PUBLIC project', async () => { + const scenario = await seedVersion({ visibility: 'PUBLIC' }); + const stranger = await createUser(); + signedInAs(stranger); + + const response = await callRoute(listApprovals, apiRequest(approvalsUrl(scenario.version.id)), { + versionId: scenario.version.id, + }); + + // hasMembership is required, not just hasAccess, so a public project does + // not expose its approval history to passers-by. + expect(response.status).toBe(403); + }); + + it('lists requests newest first for a COMMENTATOR member', async () => { + const scenario = await seedVersion(); + const approver = await createUser(); + const commentator = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: approver.id }); + await addProjectMember({ + projectId: scenario.project.id, + userId: commentator.id, + role: 'COMMENTATOR', + }); + const older = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [approver.id], + status: 'CANCELED', + canceledAt: new Date(), + }); + const newer = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [approver.id], + }); + signedInAs(commentator); + + const payload = await readData<{ requests: Array<{ id: string }> }>( + await callRoute(listApprovals, apiRequest(approvalsUrl(scenario.version.id)), { + versionId: scenario.version.id, + }) + ); + + expect(payload.requests.map((entry) => entry.id)).toEqual([newer.id, older.id]); + }); +}); + +describe('POST /api/approvals/[requestId]/decision', () => { + it('returns 401 without a session', async () => { + const scenario = await seedVersion(); + const approver = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: approver.id }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [approver.id], + }); + signedOut(); + + const response = await callRoute( + decideApproval, + apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }), + { requestId: request.id } + ); + + expect(response.status).toBe(401); + expect((await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status).toBe( + 'PENDING' + ); + }); + + it.each([['MAYBE'], [''], ['approved'], [null]])( + 'returns 400 for the decision %s', + async (decision) => { + const scenario = await seedVersion(); + const approver = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: approver.id }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [approver.id], + }); + signedInAs(approver); + + const response = await callRoute( + decideApproval, + apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision } }), + { requestId: request.id } + ); + + expect(response.status).toBe(400); + expect( + (await db.approvalDecision.findFirstOrThrow({ where: { requestId: request.id } })).status + ).toBe('PENDING'); + } + ); + + // The core negative case for this route: having access to the project is not + // the same as being nominated on the request. + it('returns 403 for a project member who is not an approver on the request', async () => { + const scenario = await seedVersion(); + const approver = await createUser(); + const bystander = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: approver.id }); + await addProjectMember({ projectId: scenario.project.id, userId: bystander.id }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [approver.id], + }); + signedInAs(bystander); + + const response = await callRoute( + decideApproval, + apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }), + { requestId: request.id } + ); + + expect(response.status).toBe(403); + expect((await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status).toBe( + 'PENDING' + ); + expect(await db.approvalDecision.count({ where: { status: 'APPROVED' } })).toBe(0); + }); + + it('returns 403 for the project owner who requested it but is not an approver', async () => { + const scenario = await seedVersion(); + const approver = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: approver.id }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [approver.id], + }); + signedInAs(scenario.owner); + + const response = await callRoute( + decideApproval, + apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }), + { requestId: request.id } + ); + + expect(response.status).toBe(403); + }); + + it('returns 403 for an approver who has lost project access', async () => { + const scenario = await seedVersion(); + const approver = await createUser(); + const membership = await addProjectMember({ + projectId: scenario.project.id, + userId: approver.id, + }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [approver.id], + }); + await db.projectMember.delete({ where: { id: membership.id } }); + signedInAs(approver); + + const response = await callRoute( + decideApproval, + apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }), + { requestId: request.id } + ); + + expect(response.status).toBe(403); + }); + + it('keeps the request PENDING while other approvers have not answered', async () => { + const scenario = await seedVersion(); + const first = await createUser(); + const second = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: first.id }); + await addProjectMember({ projectId: scenario.project.id, userId: second.id }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [first.id, second.id], + }); + signedInAs(first); + + const response = await callRoute( + decideApproval, + apiRequest(`/api/approvals/${request.id}/decision`, { + body: { decision: 'APPROVED', note: ' looks good ' }, + }), + { requestId: request.id } + ); + + expect(response.status).toBe(200); + const stored = await db.approvalRequest.findUniqueOrThrow({ + where: { id: request.id }, + include: { decisions: true }, + }); + expect(stored.status).toBe('PENDING'); + expect(stored.resolvedAt).toBeNull(); + + const mine = stored.decisions.find((entry) => entry.approverId === first.id)!; + expect(mine.status).toBe('APPROVED'); + expect(mine.note).toBe('looks good'); + expect(mine.respondedAt).toBeInstanceOf(Date); + expect(stored.decisions.find((entry) => entry.approverId === second.id)?.status).toBe( + 'PENDING' + ); + }); + + it('resolves the request as APPROVED once the last approver approves', async () => { + const scenario = await seedVersion(); + const first = await createUser(); + const second = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: first.id }); + await addProjectMember({ projectId: scenario.project.id, userId: second.id }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [first.id, second.id], + }); + + signedInAs(first); + await callRoute( + decideApproval, + apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }), + { requestId: request.id } + ); + signedInAs(second); + const response = await callRoute( + decideApproval, + apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }), + { requestId: request.id } + ); + + expect(response.status).toBe(200); + const stored = await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } }); + expect(stored.status).toBe('APPROVED'); + expect(stored.resolvedAt).toBeInstanceOf(Date); + }); + + it('resolves the request as REJECTED on a single rejection', async () => { + const scenario = await seedVersion(); + const first = await createUser(); + const second = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: first.id }); + await addProjectMember({ projectId: scenario.project.id, userId: second.id }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [first.id, second.id], + }); + signedInAs(first); + + const response = await callRoute( + decideApproval, + apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'REJECTED' } }), + { requestId: request.id } + ); + + expect(response.status).toBe(200); + const stored = await db.approvalRequest.findUniqueOrThrow({ + where: { id: request.id }, + include: { decisions: true }, + }); + expect(stored.status).toBe('REJECTED'); + expect(stored.resolvedAt).toBeInstanceOf(Date); + // The second approver's row is left PENDING; the request is already decided. + expect(stored.decisions.find((entry) => entry.approverId === second.id)?.status).toBe( + 'PENDING' + ); + }); + + it('returns 409 when the same approver answers twice', async () => { + const scenario = await seedVersion(); + const first = await createUser(); + const second = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: first.id }); + await addProjectMember({ projectId: scenario.project.id, userId: second.id }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [first.id, second.id], + }); + signedInAs(first); + + await callRoute( + decideApproval, + apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }), + { requestId: request.id } + ); + const second_attempt = await callRoute( + decideApproval, + apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'REJECTED' } }), + { requestId: request.id } + ); + + expect(second_attempt.status).toBe(409); + expect( + ( + await db.approvalDecision.findFirstOrThrow({ + where: { requestId: request.id, approverId: first.id }, + }) + ).status + ).toBe('APPROVED'); + }); + + it.each([['APPROVED'], ['REJECTED'], ['CANCELED']] as const)( + 'returns 409 for a request already in the terminal status %s', + async (status) => { + const scenario = await seedVersion(); + const approver = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: approver.id }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [approver.id], + status, + resolvedAt: new Date(), + }); + signedInAs(approver); + + const response = await callRoute( + decideApproval, + apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }), + { requestId: request.id } + ); + + expect(response.status).toBe(409); + expect( + (await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status + ).toBe(status); + } + ); +}); + +describe('POST /api/approvals/[requestId]/cancel', () => { + it('returns 401 without a session', async () => { + const scenario = await seedVersion(); + const approver = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: approver.id }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [approver.id], + }); + signedOut(); + + const response = await callRoute( + cancelApproval, + apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }), + { requestId: request.id } + ); + + expect(response.status).toBe(401); + expect((await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status).toBe( + 'PENDING' + ); + }); + + it('returns 404 for an unknown request', async () => { + const user = await createUser(); + signedInAs(user); + + const response = await callRoute( + cancelApproval, + apiRequest('/api/approvals/nope/cancel', { method: 'POST', body: {} }), + { requestId: 'nope' } + ); + + expect(response.status).toBe(404); + }); + + // The nominated approver is not the requester and has no canEdit, so it + // cannot cancel the request out from under the person who asked for it. + it('returns 403 for the nominated approver', async () => { + const scenario = await seedVersion(); + const approver = await createUser(); + await addProjectMember({ + projectId: scenario.project.id, + userId: approver.id, + role: 'COMMENTATOR', + }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [approver.id], + }); + signedInAs(approver); + + const response = await callRoute( + cancelApproval, + apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }), + { requestId: request.id } + ); + + expect(response.status).toBe(403); + expect((await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status).toBe( + 'PENDING' + ); + }); + + it('returns 403 for a signed-in stranger', async () => { + const scenario = await seedVersion(); + const approver = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: approver.id }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [approver.id], + }); + const stranger = await createUser(); + signedInAs(stranger); + + const response = await callRoute( + cancelApproval, + apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }), + { requestId: request.id } + ); + + expect(response.status).toBe(403); + }); + + it('lets the requester cancel and records who did it', async () => { + const scenario = await seedVersion(); + const requester = await createUser(); + const approver = await createUser(); + await addProjectMember({ + projectId: scenario.project.id, + userId: requester.id, + role: 'ADMIN', + }); + await addProjectMember({ projectId: scenario.project.id, userId: approver.id }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: requester.id, + approverIds: [approver.id], + }); + signedInAs(requester); + + const response = await callRoute( + cancelApproval, + apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }), + { requestId: request.id } + ); + + expect(response.status).toBe(200); + const stored = await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } }); + expect(stored.status).toBe('CANCELED'); + expect(stored.canceledById).toBe(requester.id); + expect(stored.canceledAt).toBeInstanceOf(Date); + }); + + it('lets a project ADMIN cancel a request somebody else made', async () => { + const scenario = await seedVersion(); + const requester = await createUser(); + const admin = await createUser(); + const approver = await createUser(); + await addProjectMember({ + projectId: scenario.project.id, + userId: requester.id, + role: 'ADMIN', + }); + await addProjectMember({ projectId: scenario.project.id, userId: admin.id, role: 'ADMIN' }); + await addProjectMember({ projectId: scenario.project.id, userId: approver.id }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: requester.id, + approverIds: [approver.id], + }); + signedInAs(admin); + + const response = await callRoute( + cancelApproval, + apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }), + { requestId: request.id } + ); + + expect(response.status).toBe(200); + expect( + (await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).canceledById + ).toBe(admin.id); + }); + + it.each([['APPROVED'], ['REJECTED'], ['CANCELED']] as const)( + 'returns 409 for a request already %s', + async (status) => { + const scenario = await seedVersion(); + const approver = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: approver.id }); + const request = await createApprovalRequest({ + versionId: scenario.version.id, + requestedById: scenario.owner.id, + approverIds: [approver.id], + status, + resolvedAt: new Date(), + }); + signedInAs(scenario.owner); + + const response = await callRoute( + cancelApproval, + apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }), + { requestId: request.id } + ); + + expect(response.status).toBe(409); + const stored = await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } }); + expect(stored.status).toBe(status); + expect(stored.canceledById).toBeNull(); + } + ); +}); diff --git a/tests/api/assets-authz.test.ts b/tests/api/assets-authz.test.ts new file mode 100644 index 0000000..9bc4358 --- /dev/null +++ b/tests/api/assets-authz.test.ts @@ -0,0 +1,669 @@ +// Authorization tests for the /api/videos/[videoId]/assets/* family, from callers +// who are signed in but not entitled. +// +// Every route in this family authorizes through one helper, +// `getVideoAssetAccessContext()` in lib/video-assets.ts, and then reads one of +// three flags off it: `hasViewAccess` to list, `canUploadAssets` to write, and +// `canDownloadAssets` to export. Before this file the only thing standing behind +// those flags was the anonymous sweep in tests/api/auth-matrix.test.ts, so +// collapsing all three onto `hasViewAccess`, or returning a context that is +// simply `{ hasViewAccess: true, canUploadAssets: true, ... }` for any signed-in +// caller, would not have failed a single test in the suite. +// +// Two details make these cases land on the guard rather than short of it. +// +// - Each route checks the access context *before* it parses the body. So an +// unauthorized caller gets 403 and an authorized caller sending the same +// payload gets a 400 from the validation underneath. The positive controls +// below deliberately stop on that 400: it is a status no unauthorized caller +// can reach, which is what makes the 403 next to it mean something. +// +// - The assets are YOUTUBE-provider rows. Deleting an R2 or Bunny asset sends +// the handler off to object storage, and downloading one proxies the bytes; +// a YouTube asset exercises the identical authorization path with no network +// underneath it. + +import { describe, expect, it } from 'vitest'; +import type { Project, User, Video, VideoAsset, Workspace } from '@prisma/client'; +import { db } from '@/lib/db'; +import { GET as listAssets, POST as createAsset } from '@/app/api/videos/[videoId]/assets/route'; +import { DELETE as deleteAsset } from '@/app/api/videos/[videoId]/assets/[assetId]/route'; +import { GET as downloadAsset } from '@/app/api/videos/[videoId]/assets/[assetId]/download/route'; +import { POST as initAssetBunnyUpload } from '@/app/api/videos/[videoId]/assets/bunny-init/route'; +import { POST as initAssetR2Upload } from '@/app/api/videos/[videoId]/assets/r2-init/route'; +import { apiRequest, callRoute, readData, readError } from '../helpers/request'; +import { signedInAs } from '../helpers/session'; +import { + addProjectMember, + addWorkspaceMember, + createExpiredUser, + createUser, + createVideo, + createVideoAsset, + nextSeq, + seedProject, +} from '../factories'; + +const SEEDED_ASSET_NAME = 'Seeded b-roll'; + +interface AssetFixture { + owner: User; + workspace: Workspace; + project: Project; + video: Video; + /** Uploaded by the project owner, so a COMMENTATOR is not its author. */ + asset: VideoAsset; +} + +async function seedAsset( + input: { allowDownloads: boolean; ownerUser?: User } = { allowDownloads: false } +): Promise { + const { owner, workspace, project } = await seedProject({ + ownerUser: input.ownerUser, + visibility: 'PRIVATE', + allowDownloads: input.allowDownloads, + }); + const video = await createVideo({ projectId: project.id, title: 'Video with assets' }); + const asset = await createVideoAsset({ + videoId: video.id, + billedUserId: owner.id, + kind: 'VIDEO', + provider: 'YOUTUBE', + displayName: SEEDED_ASSET_NAME, + sourceUrl: `https://www.youtube.com/watch?v=asset${nextSeq()}`, + providerVideoId: `asset-provider-${nextSeq()}`, + uploadedByUserId: owner.id, + }); + + return { owner, workspace, project, video, asset }; +} + +function assetsUrl(videoId: string): string { + return `/api/videos/${videoId}/assets`; +} + +function assetUrl(videoId: string, assetId: string): string { + return `${assetsUrl(videoId)}/${assetId}`; +} + +// --------------------------------------------------------------------------- +// GET /api/videos/[videoId]/assets +// --------------------------------------------------------------------------- +describe('GET /api/videos/[videoId]/assets', () => { + it('returns 403 to a signed-in stranger with their own unrelated workspace', async () => { + const fixture = await seedAsset(); + await seedProject(); + const stranger = await createUser(); + signedInAs(stranger); + + const response = await callRoute(listAssets, apiRequest(assetsUrl(fixture.video.id)), { + videoId: fixture.video.id, + }); + + expect(response.status).toBe(403); + }); + + it('returns 403 to a project COMMENTATOR once the workspace owner loses billing', async () => { + const expiredOwner = await createExpiredUser(); + const fixture = await seedAsset({ allowDownloads: true, ownerUser: expiredOwner }); + const commentator = await createUser(); + await addProjectMember({ + projectId: fixture.project.id, + userId: commentator.id, + role: 'COMMENTATOR', + }); + signedInAs(commentator); + + const response = await callRoute(listAssets, apiRequest(assetsUrl(fixture.video.id)), { + videoId: fixture.video.id, + }); + + expect(response.status).toBe(403); + }); + + it('lets a project COMMENTATOR list the assets', async () => { + const fixture = await seedAsset(); + const commentator = await createUser(); + await addProjectMember({ + projectId: fixture.project.id, + userId: commentator.id, + role: 'COMMENTATOR', + }); + signedInAs(commentator); + + const response = await callRoute(listAssets, apiRequest(assetsUrl(fixture.video.id)), { + videoId: fixture.video.id, + }); + + expect(response.status).toBe(200); + const payload = await readData<{ assets: Array<{ id: string }> }>(response); + expect(payload.assets.map((asset) => asset.id)).toEqual([fixture.asset.id]); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/videos/[videoId]/assets +// --------------------------------------------------------------------------- +// `canUploadAssets` is intentionally generous: a COMMENTATOR is meant to be able +// to attach a reference clip. Generous is not the same as open, and the cases +// below are the difference. +describe('POST /api/videos/[videoId]/assets', () => { + it('returns 403 to a signed-in stranger and writes no asset', async () => { + const fixture = await seedAsset(); + await seedProject(); + const stranger = await createUser(); + signedInAs(stranger); + + const response = await callRoute( + createAsset, + apiRequest(assetsUrl(fixture.video.id), { + body: { provider: 'YOUTUBE', sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, + }), + { videoId: fixture.video.id } + ); + + expect(response.status).toBe(403); + expect(await db.videoAsset.count()).toBe(1); + }); + + it('returns 403 to a project COMMENTATOR once the workspace owner loses billing', async () => { + const expiredOwner = await createExpiredUser(); + const fixture = await seedAsset({ allowDownloads: false, ownerUser: expiredOwner }); + const commentator = await createUser(); + await addProjectMember({ + projectId: fixture.project.id, + userId: commentator.id, + role: 'COMMENTATOR', + }); + signedInAs(commentator); + + const response = await callRoute( + createAsset, + apiRequest(assetsUrl(fixture.video.id), { + body: { provider: 'YOUTUBE', sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, + }), + { videoId: fixture.video.id } + ); + + expect(response.status).toBe(403); + expect(await db.videoAsset.count()).toBe(1); + }); + + // The IDOR shape: a caller who legitimately uploads assets to their own video, + // aiming the same request at a video id out of another workspace. + it('returns 403 for a video id belonging to another workspace', async () => { + const mine = await seedAsset(); + const theirs = await seedAsset(); + signedInAs(mine.owner); + + const response = await callRoute( + createAsset, + apiRequest(assetsUrl(theirs.video.id), { + body: { provider: 'YOUTUBE', sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, + }), + { videoId: theirs.video.id } + ); + + expect(response.status).toBe(403); + expect(await db.videoAsset.count({ where: { videoId: theirs.video.id } })).toBe(1); + }); + + // The positive control. The access check runs before the body is parsed, so an + // authorized COMMENTATOR sending a deliberately bogus provider gets the 400 + // from the validation underneath. 400 is a status the three refusals above + // cannot produce, which is what proves they came from the guard. + it('gets a project COMMENTATOR past the access check and onto body validation', async () => { + const fixture = await seedAsset(); + const commentator = await createUser(); + await addProjectMember({ + projectId: fixture.project.id, + userId: commentator.id, + role: 'COMMENTATOR', + }); + signedInAs(commentator); + + const response = await callRoute( + createAsset, + apiRequest(assetsUrl(fixture.video.id), { body: { provider: 'NOT_A_REAL_PROVIDER' } }), + { videoId: fixture.video.id } + ); + + expect(response.status).toBe(400); + expect(await readError(response)).toContain('Invalid provider'); + expect(await db.videoAsset.count()).toBe(1); + }); + + // And the same probe from the stranger, to show the ordering is real: identical + // body, identical URL, and the guard answers first. + it('still returns 403 to a stranger sending the same invalid body', async () => { + const fixture = await seedAsset(); + const stranger = await createUser(); + signedInAs(stranger); + + const response = await callRoute( + createAsset, + apiRequest(assetsUrl(fixture.video.id), { body: { provider: 'NOT_A_REAL_PROVIDER' } }), + { videoId: fixture.video.id } + ); + + expect(response.status).toBe(403); + }); +}); + +// --------------------------------------------------------------------------- +// DELETE /api/videos/[videoId]/assets/[assetId] +// --------------------------------------------------------------------------- +// Two gates in sequence: `canUploadAssets` to be in the room at all, then +// `canDeleteAssetForViewer` which lets a COMMENTATOR remove only what they +// uploaded themselves. Both need their own negative case, because collapsing the +// second one is invisible from outside unless a test actually seeds an asset that +// belongs to somebody else. +describe('DELETE /api/videos/[videoId]/assets/[assetId]', () => { + it('returns 403 to a signed-in stranger and keeps the asset', async () => { + const fixture = await seedAsset(); + await seedProject(); + const stranger = await createUser(); + signedInAs(stranger); + + const response = await callRoute( + deleteAsset, + apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }), + { videoId: fixture.video.id, assetId: fixture.asset.id } + ); + + expect(response.status).toBe(403); + expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1); + }); + + // The second gate. This caller is a legitimate member who may upload assets of + // their own; what they may not do is delete the owner's. + it("returns 403 when a project COMMENTATOR deletes somebody else's asset", async () => { + const fixture = await seedAsset(); + const commentator = await createUser(); + await addProjectMember({ + projectId: fixture.project.id, + userId: commentator.id, + role: 'COMMENTATOR', + }); + signedInAs(commentator); + + const response = await callRoute( + deleteAsset, + apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }), + { videoId: fixture.video.id, assetId: fixture.asset.id } + ); + + expect(response.status).toBe(403); + expect(await readError(response)).toContain('only delete assets you uploaded'); + expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1); + }); + + it("returns 403 when a workspace COMMENTATOR deletes the owner's asset", async () => { + const fixture = await seedAsset(); + const workspaceCommentator = await createUser(); + await addWorkspaceMember({ + workspaceId: fixture.workspace.id, + userId: workspaceCommentator.id, + role: 'COMMENTATOR', + }); + signedInAs(workspaceCommentator); + + const response = await callRoute( + deleteAsset, + apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }), + { videoId: fixture.video.id, assetId: fixture.asset.id } + ); + + expect(response.status).toBe(403); + expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1); + }); + + it('returns 403 to the owner once their own billing access has lapsed', async () => { + const expiredOwner = await createExpiredUser(); + const fixture = await seedAsset({ allowDownloads: false, ownerUser: expiredOwner }); + signedInAs(expiredOwner); + + const response = await callRoute( + deleteAsset, + apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }), + { videoId: fixture.video.id, assetId: fixture.asset.id } + ); + + expect(response.status).toBe(403); + expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1); + }); + + // Identifier substitution against a route the caller does legitimately reach: + // their own videoId in the path, somebody else's assetId in the query. The + // lookup pairs the two, so it misses. + it('returns 404 for a foreign asset id pasted onto my own video', async () => { + const mine = await seedAsset(); + const theirs = await seedAsset(); + signedInAs(mine.owner); + + const response = await callRoute( + deleteAsset, + apiRequest(assetUrl(mine.video.id, theirs.asset.id), { method: 'DELETE' }), + { videoId: mine.video.id, assetId: theirs.asset.id } + ); + + expect(response.status).toBe(404); + expect(await db.videoAsset.count({ where: { id: theirs.asset.id } })).toBe(1); + expect(await db.videoAsset.count({ where: { id: mine.asset.id } })).toBe(1); + }); + + // The matching pair with both foreign ids, which is the request an attacker who + // has read an id out of a shared link would actually send. Here the row is + // found, so the refusal has to come from the access context. + it('returns 403 for a foreign asset reached through its own foreign video id', async () => { + const mine = await seedAsset(); + const theirs = await seedAsset(); + signedInAs(mine.owner); + + const response = await callRoute( + deleteAsset, + apiRequest(assetUrl(theirs.video.id, theirs.asset.id), { method: 'DELETE' }), + { videoId: theirs.video.id, assetId: theirs.asset.id } + ); + + expect(response.status).toBe(403); + expect(await db.videoAsset.count({ where: { id: theirs.asset.id } })).toBe(1); + }); + + it('lets the project owner delete the asset', async () => { + const fixture = await seedAsset(); + signedInAs(fixture.owner); + + const response = await callRoute( + deleteAsset, + apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }), + { videoId: fixture.video.id, assetId: fixture.asset.id } + ); + + expect(response.status).toBe(200); + expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(0); + }); + + // The positive control for the second gate specifically: same role, same route, + // and the only thing that changed is who uploaded the row. + it('lets a project COMMENTATOR delete an asset they uploaded themselves', async () => { + const fixture = await seedAsset(); + const commentator = await createUser(); + await addProjectMember({ + projectId: fixture.project.id, + userId: commentator.id, + role: 'COMMENTATOR', + }); + const own = await createVideoAsset({ + videoId: fixture.video.id, + billedUserId: fixture.owner.id, + kind: 'VIDEO', + provider: 'YOUTUBE', + displayName: 'Uploaded by the commentator', + sourceUrl: `https://www.youtube.com/watch?v=own${nextSeq()}`, + uploadedByUserId: commentator.id, + }); + signedInAs(commentator); + + const response = await callRoute( + deleteAsset, + apiRequest(assetUrl(fixture.video.id, own.id), { method: 'DELETE' }), + { videoId: fixture.video.id, assetId: own.id } + ); + + expect(response.status).toBe(200); + expect(await db.videoAsset.count({ where: { id: own.id } })).toBe(0); + // The owner's asset was never in scope and is still there. + expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1); + }); + + it("lets a project ADMIN delete the owner's asset", async () => { + const fixture = await seedAsset(); + const admin = await createUser(); + await addProjectMember({ projectId: fixture.project.id, userId: admin.id, role: 'ADMIN' }); + signedInAs(admin); + + const response = await callRoute( + deleteAsset, + apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }), + { videoId: fixture.video.id, assetId: fixture.asset.id } + ); + + expect(response.status).toBe(200); + expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// GET /api/videos/[videoId]/assets/[assetId]/download +// --------------------------------------------------------------------------- +// Two refusals with two different messages: `hasViewAccess` for people who should +// not see the video at all, and `canDownloadAssets` for members of a project whose +// owner has turned exports off. Both are pinned, because merging them would look +// like a tidy-up and would quietly hand the files to every viewer. +describe('GET /api/videos/[videoId]/assets/[assetId]/download', () => { + it('returns 403 to a signed-in stranger', async () => { + const fixture = await seedAsset({ allowDownloads: true }); + await seedProject(); + const stranger = await createUser(); + signedInAs(stranger); + + const response = await callRoute( + downloadAsset, + apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`), + { videoId: fixture.video.id, assetId: fixture.asset.id } + ); + + expect(response.status).toBe(403); + expect(await readError(response)).toContain('Access denied'); + }); + + it('returns 403 to a project COMMENTATOR when downloads are disabled', async () => { + const fixture = await seedAsset({ allowDownloads: false }); + const commentator = await createUser(); + await addProjectMember({ + projectId: fixture.project.id, + userId: commentator.id, + role: 'COMMENTATOR', + }); + signedInAs(commentator); + + const response = await callRoute( + downloadAsset, + apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`), + { videoId: fixture.video.id, assetId: fixture.asset.id } + ); + + expect(response.status).toBe(403); + expect(await readError(response)).toContain('Downloads are disabled'); + }); + + // Positive control: same COMMENTATOR, same asset, allowDownloads flipped on. + // The request now clears both gates and stops on the provider check, a 400 that + // neither refusal above can produce. + it('gets the same COMMENTATOR past both gates once allowDownloads is on', async () => { + const fixture = await seedAsset({ allowDownloads: true }); + const commentator = await createUser(); + await addProjectMember({ + projectId: fixture.project.id, + userId: commentator.id, + role: 'COMMENTATOR', + }); + signedInAs(commentator); + + const response = await callRoute( + downloadAsset, + apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`), + { videoId: fixture.video.id, assetId: fixture.asset.id } + ); + + expect(response.status).toBe(400); + expect(await readError(response)).toContain('YouTube assets cannot be downloaded'); + }); + + it('gets the owner past both gates even when allowDownloads is off', async () => { + const fixture = await seedAsset({ allowDownloads: false }); + signedInAs(fixture.owner); + + const response = await callRoute( + downloadAsset, + apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`), + { videoId: fixture.video.id, assetId: fixture.asset.id } + ); + + expect(response.status).toBe(400); + expect(await readError(response)).toContain('YouTube assets cannot be downloaded'); + }); + + it('returns 404 for a foreign asset id pasted onto my own video', async () => { + const mine = await seedAsset({ allowDownloads: true }); + const theirs = await seedAsset({ allowDownloads: true }); + signedInAs(mine.owner); + + const response = await callRoute( + downloadAsset, + apiRequest(`${assetUrl(mine.video.id, theirs.asset.id)}/download`), + { videoId: mine.video.id, assetId: theirs.asset.id } + ); + + expect(response.status).toBe(404); + }); + + it('returns 403 for a foreign asset reached through its own foreign video id', async () => { + const mine = await seedAsset({ allowDownloads: true }); + const theirs = await seedAsset({ allowDownloads: true }); + signedInAs(mine.owner); + + const response = await callRoute( + downloadAsset, + apiRequest(`${assetUrl(theirs.video.id, theirs.asset.id)}/download`), + { videoId: theirs.video.id, assetId: theirs.asset.id } + ); + + expect(response.status).toBe(403); + }); +}); + +// --------------------------------------------------------------------------- +// The two upload-init routes +// --------------------------------------------------------------------------- +// Both hand out an upload credential, so a caller who gets through them can spend +// the workspace owner's storage quota. Direct uploads are unconfigured in the test +// environment, which is what gives each of these a positive control that stops one +// step past the guard without touching a provider. +describe('POST /api/videos/[videoId]/assets/r2-init', () => { + it('returns 403 to a signed-in stranger and reserves nothing', async () => { + const fixture = await seedAsset(); + await seedProject(); + const stranger = await createUser(); + signedInAs(stranger); + + const response = await callRoute( + initAssetR2Upload, + apiRequest(`${assetsUrl(fixture.video.id)}/r2-init`, { + body: { fileName: 'clip.mp4', sizeBytes: '1024', contentType: 'video/mp4' }, + }), + { videoId: fixture.video.id } + ); + + expect(response.status).toBe(403); + expect(await db.uploadReservation.count()).toBe(0); + }); + + it('returns 403 for a video id belonging to another workspace', async () => { + const mine = await seedAsset(); + const theirs = await seedAsset(); + signedInAs(mine.owner); + + const response = await callRoute( + initAssetR2Upload, + apiRequest(`${assetsUrl(theirs.video.id)}/r2-init`, { + body: { fileName: 'clip.mp4', sizeBytes: '1024', contentType: 'video/mp4' }, + }), + { videoId: theirs.video.id } + ); + + expect(response.status).toBe(403); + expect(await db.uploadReservation.count()).toBe(0); + }); + + it('gets a project COMMENTATOR past the access check onto the disabled-feature check', async () => { + const fixture = await seedAsset(); + const commentator = await createUser(); + await addProjectMember({ + projectId: fixture.project.id, + userId: commentator.id, + role: 'COMMENTATOR', + }); + signedInAs(commentator); + + const response = await callRoute( + initAssetR2Upload, + apiRequest(`${assetsUrl(fixture.video.id)}/r2-init`, { + body: { fileName: 'clip.mp4', sizeBytes: '1024', contentType: 'video/mp4' }, + }), + { videoId: fixture.video.id } + ); + + expect(response.status).toBe(400); + expect(await readError(response)).toContain('S3 video uploads are disabled'); + expect(await db.uploadReservation.count()).toBe(0); + }); +}); + +describe('POST /api/videos/[videoId]/assets/bunny-init', () => { + it('returns 403 to a signed-in stranger', async () => { + const fixture = await seedAsset(); + await seedProject(); + const stranger = await createUser(); + signedInAs(stranger); + + const response = await callRoute( + initAssetBunnyUpload, + apiRequest(`${assetsUrl(fixture.video.id)}/bunny-init`, { body: { title: 'A clip' } }), + { videoId: fixture.video.id } + ); + + expect(response.status).toBe(403); + }); + + it('returns 403 to a project COMMENTATOR once the workspace owner loses billing', async () => { + const expiredOwner = await createExpiredUser(); + const fixture = await seedAsset({ allowDownloads: false, ownerUser: expiredOwner }); + const commentator = await createUser(); + await addProjectMember({ + projectId: fixture.project.id, + userId: commentator.id, + role: 'COMMENTATOR', + }); + signedInAs(commentator); + + const response = await callRoute( + initAssetBunnyUpload, + apiRequest(`${assetsUrl(fixture.video.id)}/bunny-init`, { body: { title: 'A clip' } }), + { videoId: fixture.video.id } + ); + + expect(response.status).toBe(403); + }); + + it('gets a project COMMENTATOR past the access check onto the disabled-feature check', async () => { + const fixture = await seedAsset(); + const commentator = await createUser(); + await addProjectMember({ + projectId: fixture.project.id, + userId: commentator.id, + role: 'COMMENTATOR', + }); + signedInAs(commentator); + + const response = await callRoute( + initAssetBunnyUpload, + apiRequest(`${assetsUrl(fixture.video.id)}/bunny-init`, { body: { title: 'A clip' } }), + { videoId: fixture.video.id } + ); + + expect(response.status).toBe(400); + expect(await readError(response)).toContain('Direct uploads are disabled'); + }); +}); diff --git a/tests/api/auth-matrix.test.ts b/tests/api/auth-matrix.test.ts new file mode 100644 index 0000000..2b8453c --- /dev/null +++ b/tests/api/auth-matrix.test.ts @@ -0,0 +1,820 @@ +// A sweep over every route module under app/api asserting that an +// unauthenticated caller can never reach a 2xx. +// +// Three properties make this more than a smoke test: +// +// 1. The routes are enumerated by walking app/api on disk and cross-checked +// against the table below. Add a route and this file fails until someone +// classifies it as guarded or public. That is the point: the classification +// is a reviewable diff, not an omission nobody notices. +// +// 2. Every id in the table is a real row, seeded per test. A matrix built on +// made-up ids passes even with the authorization deleted, because the route +// 404s before it ever checks anything. Here the project exists, the video +// exists, the comment exists, and the only reason the call fails is the +// access check. +// +// 3. A 500 counts as a failure. Rejecting an anonymous caller by crashing is +// not rejecting it. +// +// The project is PRIVATE and no share-session cookie is sent, so nothing here +// is legitimately reachable without a session. + +import fs from 'node:fs'; +import path from 'node:path'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { db } from '@/lib/db'; +import { REPO_ROOT } from '../helpers/env'; +import { apiRequest, callRoute, type RouteHandler } from '../helpers/request'; +import { signedInAs, signedOut } from '../helpers/session'; +import { + addProjectMember, + addWorkspaceMember, + createApprovalRequest, + createComment, + createCommentTag, + createProject, + createShareLink, + createUser, + createVersion, + createVideo, + createVideoAsset, + createWorkspace, + createInvitation, +} from '../factories'; + +import * as adminFeedbackRoute from '@/app/api/admin/feedback/[feedbackId]/route'; +import * as adminRefreshR2Route from '@/app/api/admin/stats/refresh-r2/route'; +import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/route'; +import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route'; +import * as billingCheckoutRoute from '@/app/api/billing/checkout/route'; +import * as billingPortalRoute from '@/app/api/billing/portal/route'; +import * as billingRoute from '@/app/api/billing/route'; +import * as commentRoute from '@/app/api/comments/[commentId]/route'; +import * as feedbackRoute from '@/app/api/feedback/route'; +import * as feedbackUploadRoute from '@/app/api/feedback/upload/route'; +import * as onboardingCompleteRoute from '@/app/api/onboarding/complete/route'; +import * as approvalCandidatesRoute from '@/app/api/projects/[projectId]/approval-candidates/route'; +import * as projectDownloadRoute from '@/app/api/projects/[projectId]/download/route'; +import * as projectInvitationRoute from '@/app/api/projects/[projectId]/members/invitations/[invitationId]/route'; +import * as projectMemberRoute from '@/app/api/projects/[projectId]/members/[memberId]/route'; +import * as projectMembersRoute from '@/app/api/projects/[projectId]/members/route'; +import * as projectRoute from '@/app/api/projects/[projectId]/route'; +import * as projectTagsRoute from '@/app/api/projects/[projectId]/tags/route'; +import * as projectTagRoute from '@/app/api/projects/[projectId]/tags/[tagId]/route'; +import * as videosBulkDeleteRoute from '@/app/api/projects/[projectId]/videos/bulk-delete/route'; +import * as videosBunnyInitRoute from '@/app/api/projects/[projectId]/videos/bunny-init/route'; +import * as videosMoveRoute from '@/app/api/projects/[projectId]/videos/move/route'; +import * as videosR2CompleteRoute from '@/app/api/projects/[projectId]/videos/r2-complete/route'; +import * as videosR2InitRoute from '@/app/api/projects/[projectId]/videos/r2-init/route'; +import * as projectVideosRoute from '@/app/api/projects/[projectId]/videos/route'; +import * as projectVideoRoute from '@/app/api/projects/[projectId]/videos/[videoId]/route'; +import * as videoShareRoute from '@/app/api/projects/[projectId]/videos/[videoId]/share/route'; +import * as videoVersionsRoute from '@/app/api/projects/[projectId]/videos/[videoId]/versions/route'; +import * as videoVersionRoute from '@/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route'; +import * as projectsRoute from '@/app/api/projects/route'; +import * as searchRoute from '@/app/api/search/route'; +import * as settingsNotificationsRoute from '@/app/api/settings/notifications/route'; +import * as settingsStorageRoute from '@/app/api/settings/storage/route'; +import * as uploadAudioFileRoute from '@/app/api/upload/audio/[filename]/route'; +import * as uploadAudioRoute from '@/app/api/upload/audio/route'; +import * as uploadImageFileRoute from '@/app/api/upload/image/[filename]/route'; +import * as uploadImageRoute from '@/app/api/upload/image/route'; +import * as uploadVideoFileRoute from '@/app/api/upload/video/[filename]/route'; +import * as versionApprovalsRoute from '@/app/api/versions/[versionId]/approvals/route'; +import * as commentsExportRoute from '@/app/api/versions/[versionId]/comments/export/route'; +import * as versionCommentsRoute from '@/app/api/versions/[versionId]/comments/route'; +import * as versionDownloadRoute from '@/app/api/versions/[versionId]/download/route'; +import * as assetDownloadRoute from '@/app/api/videos/[videoId]/assets/[assetId]/download/route'; +import * as assetRoute from '@/app/api/videos/[videoId]/assets/[assetId]/route'; +import * as assetsBunnyInitRoute from '@/app/api/videos/[videoId]/assets/bunny-init/route'; +import * as assetsR2InitRoute from '@/app/api/videos/[videoId]/assets/r2-init/route'; +import * as assetsRoute from '@/app/api/videos/[videoId]/assets/route'; +import * as watchProgressRoute from '@/app/api/watch/[videoId]/progress/route'; +import * as watchRoute from '@/app/api/watch/[videoId]/route'; +import * as watchUploadTokenRoute from '@/app/api/watch/[videoId]/upload-token/route'; +import * as workspacesRoute from '@/app/api/workspaces/route'; +import * as workspaceInvitationRoute from '@/app/api/workspaces/[workspaceId]/members/invitations/[invitationId]/route'; +import * as workspaceMemberRoute from '@/app/api/workspaces/[workspaceId]/members/[memberId]/route'; +import * as workspaceMembersRoute from '@/app/api/workspaces/[workspaceId]/members/route'; +import * as workspaceRoute from '@/app/api/workspaces/[workspaceId]/route'; + +// --------------------------------------------------------------------------- +// The count guard +// --------------------------------------------------------------------------- +// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES. +const EXPECTED_ROUTE_MODULE_COUNT = 60; + +/** + * Routes that are public by design, and why. Everything else must reject an + * anonymous caller. Moving a file into this set is the visible diff that says + * "this endpoint is now reachable without a session". + */ +const PUBLIC_ROUTES: ReadonlyMap = new Map([ + [ + 'auth/[...nextauth]/route.ts', + // The NextAuth handler itself: sign-in, callback and CSRF endpoints. It has + // to be reachable by a caller who has no session yet, by definition. + 'NextAuth sign-in/callback handler', + ], + [ + 'auth/register/route.ts', + // Account creation. Gated by OPENFRAME_REQUIRE_INVITE_CODE plus an IP rate + // limit rather than by a session. Covered in tests/api/register.test.ts. + 'account creation, gated by the invite code', + ], + [ + 'auth/verify-email/route.ts', + // Reached by clicking a link in an email, before the user can sign in. + // Authenticated by the one-time token in the query string. + 'email verification link, authenticated by a single-use token', + ], + [ + 'auth/verify-email/resend/route.ts', + // A user who cannot sign in because they are unverified has no session to + // present. Rate limited by IP, and answers identically for unknown emails + // so it cannot be used to enumerate accounts. + 'resend of the verification email, for users who cannot sign in yet', + ], + [ + 'stripe/webhook/route.ts', + // Called by Stripe, not by a browser. Authenticated by the HMAC signature + // in the stripe-signature header. Covered in + // tests/api/stripe-webhook.test.ts, including the rejection of a bad one. + 'Stripe webhook, authenticated by an HMAC signature', + ], +]); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const IMAGE_FILENAME = '11111111-1111-4111-8111-111111111111.png'; +const AUDIO_FILENAME = '22222222-2222-4222-8222-222222222222.webm'; +const VIDEO_FILENAME = '33333333-3333-4333-8333-333333333333.mp4'; + +interface Fixtures { + userId: string; + workspaceId: string; + workspaceMemberId: string; + workspaceInvitationId: string; + projectId: string; + projectMemberId: string; + projectInvitationId: string; + tagId: string; + videoId: string; + versionId: string; + commentId: string; + assetId: string; + approvalRequestId: string; + feedbackId: string; +} + +async function seedFixtures(): Promise { + const owner = await createUser(); + const collaborator = await createUser(); + + const workspace = await createWorkspace({ ownerId: owner.id }); + const workspaceMember = await addWorkspaceMember({ + workspaceId: workspace.id, + userId: collaborator.id, + }); + const workspaceInvitation = await createInvitation({ + invitedById: owner.id, + scope: 'WORKSPACE', + workspaceId: workspace.id, + }); + + // PRIVATE on purpose. A PUBLIC project grants anonymous read access through + // computeProjectAccess(), which would make several of the GET routes return + // 200 for entirely legitimate reasons and hide the ones that should not. + const project = await createProject({ + ownerId: owner.id, + workspaceId: workspace.id, + visibility: 'PRIVATE', + allowDownloads: true, + }); + const projectMember = await addProjectMember({ + projectId: project.id, + userId: collaborator.id, + }); + const projectInvitation = await createInvitation({ + invitedById: owner.id, + scope: 'PROJECT', + projectId: project.id, + }); + const tag = await createCommentTag({ projectId: project.id }); + + const video = await createVideo({ projectId: project.id }); + const version = await createVersion({ + videoParentId: video.id, + providerId: 'r2', + providerVideoId: `videos/${VIDEO_FILENAME}`, + originalUrl: `/api/upload/video/${VIDEO_FILENAME}`, + sizeBytes: BigInt(1024), + }); + const comment = await createComment({ versionId: version.id, authorId: owner.id }); + + const asset = await createVideoAsset({ + videoId: video.id, + billedUserId: owner.id, + sourceUrl: `/api/upload/image/${IMAGE_FILENAME}`, + }); + // A second asset so /api/upload/audio/[filename] resolves to a real row too. + await createVideoAsset({ + videoId: video.id, + billedUserId: owner.id, + kind: 'AUDIO', + provider: 'R2_AUDIO', + sourceUrl: `/api/upload/audio/${AUDIO_FILENAME}`, + }); + + await createShareLink({ projectId: project.id, videoId: video.id, permission: 'COMMENT' }); + + const approvalRequest = await createApprovalRequest({ + versionId: version.id, + requestedById: owner.id, + approverIds: [collaborator.id], + }); + + const feedback = await db.userFeedback.create({ + data: { + userId: owner.id, + type: 'FEEDBACK', + title: 'Matrix fixture feedback', + message: 'Seeded so the admin delete route has a real row to refuse.', + }, + }); + + return { + userId: owner.id, + workspaceId: workspace.id, + workspaceMemberId: workspaceMember.id, + workspaceInvitationId: workspaceInvitation.id, + projectId: project.id, + projectMemberId: projectMember.id, + projectInvitationId: projectInvitation.id, + tagId: tag.id, + videoId: video.id, + versionId: version.id, + commentId: comment.id, + assetId: asset.id, + approvalRequestId: approvalRequest.id, + feedbackId: feedback.id, + }; +} + +// --------------------------------------------------------------------------- +// The table +// --------------------------------------------------------------------------- + +type ParamRecord = Record; + +interface RouteCase { + /** Path of the route module relative to app/api. */ + file: string; + module: Record; + url: (fixtures: Fixtures) => string; + params?: (fixtures: Fixtures) => ParamRecord; + /** JSON body for the non-GET methods. A valid `{}` by default, so that a + * route which parses before authorizing rejects rather than crashes. */ + body?: unknown; + /** Replaces `body`, for the routes that read request.formData(). */ + rawBody?: (fixtures: Fixtures) => BodyInit; + headers?: Record; +} + +/** + * A multipart body that gets past the shape checks in the two upload routes and + * reaches their access check. + * + * This is not decoration. Both routes validate the request before they + * authorize: /api/upload/image bails with "Missing Content-Length header" at its + * first line, and /api/upload/audio bails with "No audio file provided" before + * checkProjectAccess() is ever called. An empty FormData therefore produced a + * 400 for an anonymous caller *and* an identical 400 for the workspace owner, + * which means the assertion below held with the authorization deleted. Sending a + * real file and a real videoId is what makes the 403 come from the access check. + */ +function uploadForm(field: 'image' | 'audio', fixtures: Fixtures): FormData { + const form = new FormData(); + form.append(field, new File([new Uint8Array([1, 2, 3, 4])], `anon.${field}`)); + form.append('videoId', fixtures.videoId); + return form; +} + +const ROUTE_CASES: readonly RouteCase[] = [ + { + file: 'admin/feedback/[feedbackId]/route.ts', + module: adminFeedbackRoute, + url: (f) => `/api/admin/feedback/${f.feedbackId}`, + params: (f) => ({ feedbackId: f.feedbackId }), + }, + { + file: 'admin/stats/refresh-r2/route.ts', + module: adminRefreshR2Route, + url: () => '/api/admin/stats/refresh-r2', + }, + { + file: 'approvals/[requestId]/cancel/route.ts', + module: approvalCancelRoute, + url: (f) => `/api/approvals/${f.approvalRequestId}/cancel`, + params: (f) => ({ requestId: f.approvalRequestId }), + }, + { + file: 'approvals/[requestId]/decision/route.ts', + module: approvalDecisionRoute, + url: (f) => `/api/approvals/${f.approvalRequestId}/decision`, + params: (f) => ({ requestId: f.approvalRequestId }), + body: { decision: 'APPROVED' }, + }, + { + file: 'billing/checkout/route.ts', + module: billingCheckoutRoute, + url: () => '/api/billing/checkout', + headers: { origin: 'http://localhost:3000' }, + }, + { + file: 'billing/portal/route.ts', + module: billingPortalRoute, + url: () => '/api/billing/portal', + headers: { origin: 'http://localhost:3000' }, + }, + { file: 'billing/route.ts', module: billingRoute, url: () => '/api/billing' }, + { + file: 'comments/[commentId]/route.ts', + module: commentRoute, + url: (f) => `/api/comments/${f.commentId}`, + params: (f) => ({ commentId: f.commentId }), + body: { content: 'edited by an anonymous caller' }, + }, + { + file: 'feedback/route.ts', + module: feedbackRoute, + url: () => '/api/feedback', + body: { type: 'FEEDBACK', title: 'anon', message: 'anon' }, + }, + { + file: 'feedback/upload/route.ts', + module: feedbackUploadRoute, + url: () => '/api/feedback/upload', + rawBody: () => new FormData(), + }, + { + file: 'onboarding/complete/route.ts', + module: onboardingCompleteRoute, + url: () => '/api/onboarding/complete', + }, + { + file: 'projects/[projectId]/approval-candidates/route.ts', + module: approvalCandidatesRoute, + url: (f) => `/api/projects/${f.projectId}/approval-candidates`, + params: (f) => ({ projectId: f.projectId }), + }, + { + file: 'projects/[projectId]/download/route.ts', + module: projectDownloadRoute, + url: (f) => `/api/projects/${f.projectId}/download`, + params: (f) => ({ projectId: f.projectId }), + }, + { + file: 'projects/[projectId]/members/invitations/[invitationId]/route.ts', + module: projectInvitationRoute, + url: (f) => `/api/projects/${f.projectId}/members/invitations/${f.projectInvitationId}`, + params: (f) => ({ projectId: f.projectId, invitationId: f.projectInvitationId }), + }, + { + file: 'projects/[projectId]/members/[memberId]/route.ts', + module: projectMemberRoute, + url: (f) => `/api/projects/${f.projectId}/members/${f.projectMemberId}`, + params: (f) => ({ projectId: f.projectId, memberId: f.projectMemberId }), + body: { role: 'ADMIN' }, + }, + { + file: 'projects/[projectId]/members/route.ts', + module: projectMembersRoute, + url: (f) => `/api/projects/${f.projectId}/members`, + params: (f) => ({ projectId: f.projectId }), + body: { email: 'anon@example.com', role: 'ADMIN' }, + }, + { + file: 'projects/[projectId]/route.ts', + module: projectRoute, + url: (f) => `/api/projects/${f.projectId}`, + params: (f) => ({ projectId: f.projectId }), + body: { name: 'renamed by an anonymous caller' }, + }, + { + file: 'projects/[projectId]/tags/route.ts', + module: projectTagsRoute, + url: (f) => `/api/projects/${f.projectId}/tags`, + params: (f) => ({ projectId: f.projectId }), + body: { name: 'Anon', color: '#ff0000' }, + }, + { + file: 'projects/[projectId]/tags/[tagId]/route.ts', + module: projectTagRoute, + url: (f) => `/api/projects/${f.projectId}/tags/${f.tagId}`, + params: (f) => ({ projectId: f.projectId, tagId: f.tagId }), + body: { name: 'Anon' }, + }, + { + file: 'projects/[projectId]/videos/bulk-delete/route.ts', + module: videosBulkDeleteRoute, + url: (f) => `/api/projects/${f.projectId}/videos/bulk-delete`, + params: (f) => ({ projectId: f.projectId }), + body: { videoIds: ['does-not-matter'] }, + }, + { + file: 'projects/[projectId]/videos/bunny-init/route.ts', + module: videosBunnyInitRoute, + url: (f) => `/api/projects/${f.projectId}/videos/bunny-init`, + params: (f) => ({ projectId: f.projectId }), + body: { title: 'anon' }, + }, + { + file: 'projects/[projectId]/videos/move/route.ts', + module: videosMoveRoute, + url: (f) => `/api/projects/${f.projectId}/videos/move`, + params: (f) => ({ projectId: f.projectId }), + body: { videoIds: ['x'], targetProjectId: 'y' }, + }, + { + file: 'projects/[projectId]/videos/r2-complete/route.ts', + module: videosR2CompleteRoute, + url: (f) => `/api/projects/${f.projectId}/videos/r2-complete`, + params: (f) => ({ projectId: f.projectId }), + body: { objectKey: 'x', uploadToken: 'y' }, + }, + { + file: 'projects/[projectId]/videos/r2-init/route.ts', + module: videosR2InitRoute, + url: (f) => `/api/projects/${f.projectId}/videos/r2-init`, + params: (f) => ({ projectId: f.projectId }), + body: { fileName: 'a.mp4', sizeBytes: '1024', contentType: 'video/mp4' }, + }, + { + file: 'projects/[projectId]/videos/route.ts', + module: projectVideosRoute, + url: (f) => `/api/projects/${f.projectId}/videos`, + params: (f) => ({ projectId: f.projectId }), + body: { title: 'anon', videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, + }, + { + file: 'projects/[projectId]/videos/[videoId]/route.ts', + module: projectVideoRoute, + url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}`, + params: (f) => ({ projectId: f.projectId, videoId: f.videoId }), + body: { title: 'renamed by an anonymous caller' }, + }, + { + file: 'projects/[projectId]/videos/[videoId]/share/route.ts', + module: videoShareRoute, + url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}/share`, + params: (f) => ({ projectId: f.projectId, videoId: f.videoId }), + body: { allowGuests: true }, + }, + { + file: 'projects/[projectId]/videos/[videoId]/versions/route.ts', + module: videoVersionsRoute, + url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}/versions`, + params: (f) => ({ projectId: f.projectId, videoId: f.videoId }), + body: { videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, + }, + { + file: 'projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts', + module: videoVersionRoute, + url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}/versions/${f.versionId}`, + params: (f) => ({ projectId: f.projectId, videoId: f.videoId, versionId: f.versionId }), + body: { versionLabel: 'anon' }, + }, + { + file: 'projects/route.ts', + module: projectsRoute, + url: () => '/api/projects', + body: { name: 'anon project', workspaceId: 'anything' }, + }, + { file: 'search/route.ts', module: searchRoute, url: () => '/api/search?q=test' }, + { + file: 'settings/notifications/route.ts', + module: settingsNotificationsRoute, + url: () => '/api/settings/notifications', + body: { emailEnabled: true }, + }, + { + file: 'settings/storage/route.ts', + module: settingsStorageRoute, + url: () => '/api/settings/storage', + }, + { + file: 'upload/audio/[filename]/route.ts', + module: uploadAudioFileRoute, + url: () => `/api/upload/audio/${AUDIO_FILENAME}`, + params: () => ({ filename: AUDIO_FILENAME }), + }, + { + file: 'upload/audio/route.ts', + module: uploadAudioRoute, + url: () => '/api/upload/audio', + rawBody: (f) => uploadForm('audio', f), + }, + { + file: 'upload/image/[filename]/route.ts', + module: uploadImageFileRoute, + url: () => `/api/upload/image/${IMAGE_FILENAME}`, + params: () => ({ filename: IMAGE_FILENAME }), + }, + { + file: 'upload/image/route.ts', + module: uploadImageRoute, + url: () => '/api/upload/image', + rawBody: (f) => uploadForm('image', f), + // The route rejects a missing Content-Length before it does anything else, + // and constructing a Request from a FormData does not set one. + headers: { 'content-length': '2048' }, + }, + { + file: 'upload/video/[filename]/route.ts', + module: uploadVideoFileRoute, + url: () => `/api/upload/video/${VIDEO_FILENAME}`, + params: () => ({ filename: VIDEO_FILENAME }), + }, + { + file: 'versions/[versionId]/approvals/route.ts', + module: versionApprovalsRoute, + url: (f) => `/api/versions/${f.versionId}/approvals`, + params: (f) => ({ versionId: f.versionId }), + body: { approverIds: ['someone'] }, + }, + { + file: 'versions/[versionId]/comments/export/route.ts', + module: commentsExportRoute, + url: (f) => `/api/versions/${f.versionId}/comments/export`, + params: (f) => ({ versionId: f.versionId }), + }, + { + file: 'versions/[versionId]/comments/route.ts', + module: versionCommentsRoute, + url: (f) => `/api/versions/${f.versionId}/comments`, + params: (f) => ({ versionId: f.versionId }), + body: { content: 'anonymous comment', timestamp: 1, guestName: 'Anon' }, + }, + { + file: 'versions/[versionId]/download/route.ts', + module: versionDownloadRoute, + url: (f) => `/api/versions/${f.versionId}/download`, + params: (f) => ({ versionId: f.versionId }), + }, + { + file: 'videos/[videoId]/assets/[assetId]/download/route.ts', + module: assetDownloadRoute, + url: (f) => `/api/videos/${f.videoId}/assets/${f.assetId}/download`, + params: (f) => ({ videoId: f.videoId, assetId: f.assetId }), + }, + { + file: 'videos/[videoId]/assets/[assetId]/route.ts', + module: assetRoute, + url: (f) => `/api/videos/${f.videoId}/assets/${f.assetId}`, + params: (f) => ({ videoId: f.videoId, assetId: f.assetId }), + }, + { + file: 'videos/[videoId]/assets/bunny-init/route.ts', + module: assetsBunnyInitRoute, + url: (f) => `/api/videos/${f.videoId}/assets/bunny-init`, + params: (f) => ({ videoId: f.videoId }), + // This entry cannot be made load-bearing here, and it was verified to hold + // with `if (!context.canUploadAssets)` replaced by `if (false)`: Bunny + // uploads are unconfigured in the test environment, so the route answers 400 + // one line below the guard whether or not the guard is there. The real + // coverage for it is in tests/api/assets-authz.test.ts, which asserts the + // exact 403 for a stranger next to the exact 400 for a member. + body: { fileName: 'a.mp4' }, + }, + { + file: 'videos/[videoId]/assets/r2-init/route.ts', + module: assetsR2InitRoute, + url: (f) => `/api/videos/${f.videoId}/assets/r2-init`, + params: (f) => ({ videoId: f.videoId }), + body: { fileName: 'a.mp4', sizeBytes: '1024', contentType: 'video/mp4' }, + }, + { + file: 'videos/[videoId]/assets/route.ts', + module: assetsRoute, + url: (f) => `/api/videos/${f.videoId}/assets`, + params: (f) => ({ videoId: f.videoId }), + // The body carries no `provider`, so POST answers 400 "Invalid provider" + // just below the access check. Verified: with + // `if (!context.canUploadAssets)` replaced by `if (false)` this entry still + // passes. Sending a real provider would not fix it, because every branch + // that could reach 201 needs a live R2 or YouTube call. The exact-status + // coverage lives in tests/api/assets-authz.test.ts instead. The GET half of + // this module is genuinely load-bearing here: it 403s on the access check. + body: { kind: 'IMAGE', sourceUrl: `/api/upload/image/${IMAGE_FILENAME}` }, + }, + { + file: 'watch/[videoId]/progress/route.ts', + module: watchProgressRoute, + url: (f) => `/api/watch/${f.videoId}/progress`, + params: (f) => ({ videoId: f.videoId }), + body: { progress: 10, duration: 100 }, + }, + { + file: 'watch/[videoId]/route.ts', + module: watchRoute, + url: (f) => `/api/watch/${f.videoId}`, + params: (f) => ({ videoId: f.videoId }), + }, + { + file: 'watch/[videoId]/upload-token/route.ts', + module: watchUploadTokenRoute, + url: (f) => `/api/watch/${f.videoId}/upload-token`, + params: (f) => ({ videoId: f.videoId }), + body: { intent: 'image' }, + headers: { origin: 'http://localhost:3000' }, + }, + { + file: 'workspaces/route.ts', + module: workspacesRoute, + url: () => '/api/workspaces', + body: { name: 'anon workspace' }, + }, + { + file: 'workspaces/[workspaceId]/members/invitations/[invitationId]/route.ts', + module: workspaceInvitationRoute, + url: (f) => `/api/workspaces/${f.workspaceId}/members/invitations/${f.workspaceInvitationId}`, + params: (f) => ({ workspaceId: f.workspaceId, invitationId: f.workspaceInvitationId }), + }, + { + file: 'workspaces/[workspaceId]/members/[memberId]/route.ts', + module: workspaceMemberRoute, + url: (f) => `/api/workspaces/${f.workspaceId}/members/${f.workspaceMemberId}`, + params: (f) => ({ workspaceId: f.workspaceId, memberId: f.workspaceMemberId }), + body: { role: 'ADMIN' }, + }, + { + file: 'workspaces/[workspaceId]/members/route.ts', + module: workspaceMembersRoute, + url: (f) => `/api/workspaces/${f.workspaceId}/members`, + params: (f) => ({ workspaceId: f.workspaceId }), + body: { email: 'anon@example.com', role: 'ADMIN' }, + }, + { + file: 'workspaces/[workspaceId]/route.ts', + module: workspaceRoute, + url: (f) => `/api/workspaces/${f.workspaceId}`, + params: (f) => ({ workspaceId: f.workspaceId }), + body: { name: 'renamed by an anonymous caller' }, + }, +]; + +const HTTP_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; + +function discoverRouteModules(): string[] { + const apiDir = path.join(REPO_ROOT, 'app', 'api'); + const found: string[] = []; + + const walk = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(absolute); + } else if (entry.name === 'route.ts') { + found.push(path.relative(apiDir, absolute)); + } + } + }; + + walk(apiDir); + return found.sort(); +} + +describe('auth matrix', () => { + const discovered = discoverRouteModules(); + + it('classifies every route module that exists on disk', () => { + const classified = new Set([ + ...ROUTE_CASES.map((entry) => entry.file), + ...PUBLIC_ROUTES.keys(), + ]); + + const unclassified = discovered.filter((file) => !classified.has(file)); + const stale = [...classified].filter((file) => !discovered.includes(file)).sort(); + + // The failure message is the whole value of this assertion: whoever added + // the route needs to know what to do about it. + expect( + { unclassified, stale }, + 'A route module under app/api is missing from tests/api/auth-matrix.test.ts. ' + + 'Add it to ROUTE_CASES (the normal case: it requires a session), or to ' + + 'PUBLIC_ROUTES with a comment saying why anonymous access is intended.' + ).toEqual({ unclassified: [], stale: [] }); + }); + + it('still has exactly the expected number of route modules', () => { + expect(discovered).toHaveLength(EXPECTED_ROUTE_MODULE_COUNT); + expect(ROUTE_CASES.length + PUBLIC_ROUTES.size).toBe(EXPECTED_ROUTE_MODULE_COUNT); + }); + + it('exports at least one HTTP method from every guarded route module', () => { + const withoutHandlers = ROUTE_CASES.filter( + (entry) => !HTTP_METHODS.some((method) => typeof entry.module[method] === 'function') + ).map((entry) => entry.file); + + expect(withoutHandlers).toEqual([]); + }); + + describe('unauthenticated callers', () => { + let fixtures: Fixtures; + + beforeEach(async () => { + signedOut(); + fixtures = await seedFixtures(); + }); + + for (const entry of ROUTE_CASES) { + it(`never returns 2xx for ${entry.file}`, async () => { + const methods = HTTP_METHODS.filter((method) => typeof entry.module[method] === 'function'); + expect(methods.length).toBeGreaterThan(0); + + const observed: Record = {}; + + for (const method of methods) { + const handler = entry.module[method] as RouteHandler; + const sendsBody = method !== 'GET' && method !== 'HEAD'; + + const request = apiRequest(entry.url(fixtures), { + method, + headers: entry.headers, + ...(sendsBody + ? entry.rawBody + ? { rawBody: entry.rawBody(fixtures) } + : { body: entry.body ?? {} } + : {}), + }); + + const response = await callRoute(handler, request, entry.params?.(fixtures) ?? {}); + observed[method] = response.status; + } + + for (const [method, status] of Object.entries(observed)) { + expect( + status >= 200 && status < 300, + `${method} ${entry.file} returned ${status} to an anonymous caller` + ).toBe(false); + + // A crash is not a rejection. If this trips, the route threw on the + // way to its access check instead of refusing cleanly. + expect(status, `${method} ${entry.file} crashed instead of refusing`).not.toBe(500); + } + }); + } + }); + + it('documents a reason for every public route, and each one still exists', () => { + for (const [file, reason] of PUBLIC_ROUTES) { + expect(reason.length, `${file} needs a reason`).toBeGreaterThan(10); + expect(fs.existsSync(path.join(REPO_ROOT, 'app', 'api', file))).toBe(true); + } + }); + + // ------------------------------------------------------------------------- + // Signed in, but not an admin + // ------------------------------------------------------------------------- + // The sweep above only proves that app/api/admin/** refuses a caller with no + // session, and `!session?.user?.isAdmin` is true for a null session for the + // wrong reason. Nothing else in the suite touches `isAdmin` at all, so + // rewriting that guard as `!session?.user?.id` would leave every one of these + // tests green while handing the admin endpoints to any signed-in user. These + // two cases are what separate "no session" from "not an admin". + describe('admin routes reject a signed-in non-admin', () => { + let fixtures: Fixtures; + + beforeEach(async () => { + fixtures = await seedFixtures(); + }); + + it('refuses DELETE /api/admin/feedback/[feedbackId] and keeps the row', async () => { + signedInAs({ id: fixtures.userId, isAdmin: false }); + + const response = await callRoute( + adminFeedbackRoute.DELETE as unknown as RouteHandler, + apiRequest(`/api/admin/feedback/${fixtures.feedbackId}`, { method: 'DELETE' }), + { feedbackId: fixtures.feedbackId } + ); + + expect(response.status).toBe(403); + expect(await db.userFeedback.count({ where: { id: fixtures.feedbackId } })).toBe(1); + }); + + it('refuses POST /api/admin/stats/refresh-r2', async () => { + signedInAs({ id: fixtures.userId, isAdmin: false }); + + const response = await callRoute( + adminRefreshR2Route.POST as RouteHandler, + apiRequest('/api/admin/stats/refresh-r2', { method: 'POST', body: {} }) + ); + + expect(response.status).toBe(403); + }); + }); +}); diff --git a/tests/api/comments.test.ts b/tests/api/comments.test.ts new file mode 100644 index 0000000..547683f --- /dev/null +++ b/tests/api/comments.test.ts @@ -0,0 +1,1009 @@ +import { describe, expect, it } from 'vitest'; +import { db } from '@/lib/db'; +import { createShareSessionValue, getShareSessionCookieName } from '@/lib/share-session'; +import { + GET as listComments, + POST as createCommentRoute, +} from '@/app/api/versions/[versionId]/comments/route'; +import { + DELETE as deleteCommentRoute, + GET as getCommentRoute, + PATCH as patchCommentRoute, +} from '@/app/api/comments/[commentId]/route'; +import { apiRequest, callRoute, readData, readError } from '../helpers/request'; +import { signedInAs, signedOut } from '../helpers/session'; +import { + addProjectMember, + addWorkspaceMember, + createComment, + createCommentTag, + createExpiredUser, + createProject, + createShareLink, + createUser, + createVersion, + createVideo, + createWorkspace, + seedVersion, +} from '../factories'; + +const VALID_STROKE = { + points: [ + { x: 0.1, y: 0.2 }, + { x: 0.3, y: 0.4 }, + ], + color: '#FF3B30', + width: 4, +}; + +function commentsUrl(versionId: string): string { + return `/api/versions/${versionId}/comments`; +} + +describe('GET /api/versions/[versionId]/comments', () => { + it('returns 404 for an unknown version', async () => { + const user = await createUser(); + signedInAs(user); + + const response = await callRoute(listComments, apiRequest(commentsUrl('nope')), { + versionId: 'nope', + }); + + expect(response.status).toBe(404); + }); + + it('returns 403 to an anonymous caller on a PRIVATE project', async () => { + const scenario = await seedVersion({ visibility: 'PRIVATE' }); + await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id }); + signedOut(); + + const response = await callRoute(listComments, apiRequest(commentsUrl(scenario.version.id)), { + versionId: scenario.version.id, + }); + + expect(response.status).toBe(403); + }); + + it('returns only top-level comments, with replies nested', async () => { + const scenario = await seedVersion(); + const parent = await createComment({ + versionId: scenario.version.id, + authorId: scenario.owner.id, + timestamp: 5, + }); + await createComment({ + versionId: scenario.version.id, + authorId: scenario.owner.id, + parentId: parent.id, + timestamp: 5, + }); + await createComment({ + versionId: scenario.version.id, + authorId: scenario.owner.id, + timestamp: 1, + }); + signedInAs(scenario.owner); + + const payload = await readData<{ + comments: Array<{ id: string; timestamp: number; replies: Array<{ id: string }> }>; + total: number; + hasMore: boolean; + }>( + await callRoute(listComments, apiRequest(commentsUrl(scenario.version.id)), { + versionId: scenario.version.id, + }) + ); + + expect(payload.comments).toHaveLength(2); + expect(payload.comments.map((entry) => entry.timestamp)).toEqual([1, 5]); + expect(payload.comments.find((entry) => entry.id === parent.id)?.replies).toHaveLength(1); + expect(payload.total).toBe(2); + expect(payload.hasMore).toBe(false); + }); + + it('omits resolved comments when includeResolved=false', async () => { + const scenario = await seedVersion(); + const open = await createComment({ + versionId: scenario.version.id, + authorId: scenario.owner.id, + }); + await createComment({ + versionId: scenario.version.id, + authorId: scenario.owner.id, + isResolved: true, + resolvedAt: new Date(), + }); + signedInAs(scenario.owner); + + const payload = await readData<{ comments: Array<{ id: string }> }>( + await callRoute( + listComments, + apiRequest(`${commentsUrl(scenario.version.id)}?includeResolved=false`), + { versionId: scenario.version.id } + ) + ); + + expect(payload.comments.map((entry) => entry.id)).toEqual([open.id]); + }); + + it('answers 304 when the caller presents the current ETag', async () => { + const scenario = await seedVersion(); + await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id }); + signedInAs(scenario.owner); + + const first = await callRoute(listComments, apiRequest(commentsUrl(scenario.version.id)), { + versionId: scenario.version.id, + }); + const etag = first.headers.get('etag'); + expect(etag).toBeTruthy(); + + const second = await callRoute( + listComments, + apiRequest(commentsUrl(scenario.version.id), { headers: { 'if-none-match': etag! } }), + { versionId: scenario.version.id } + ); + + expect(second.status).toBe(304); + }); + + it('lets a guest with a VIEW share session read the comments', async () => { + const scenario = await seedVersion({ visibility: 'PRIVATE' }); + const link = await createShareLink({ + projectId: scenario.project.id, + videoId: scenario.video.id, + permission: 'VIEW', + }); + await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id }); + signedOut(); + + const response = await callRoute( + listComments, + apiRequest(commentsUrl(scenario.version.id), { + cookies: { + [getShareSessionCookieName(scenario.video.id)]: createShareSessionValue( + link.token, + scenario.video.id, + false + ), + }, + }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(200); + }); + + it('refuses a share session signed for a different video', async () => { + const scenario = await seedVersion({ visibility: 'PRIVATE' }); + const otherVideo = await createVideo({ projectId: scenario.project.id }); + const link = await createShareLink({ + projectId: scenario.project.id, + videoId: otherVideo.id, + permission: 'VIEW', + }); + signedOut(); + + const response = await callRoute( + listComments, + apiRequest(commentsUrl(scenario.version.id), { + cookies: { + [getShareSessionCookieName(scenario.video.id)]: createShareSessionValue( + link.token, + otherVideo.id, + false + ), + }, + }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(403); + }); +}); + +describe('POST /api/versions/[versionId]/comments', () => { + it('returns 403 to an anonymous caller with no share session', async () => { + const scenario = await seedVersion(); + signedOut(); + + const response = await callRoute( + createCommentRoute, + apiRequest(commentsUrl(scenario.version.id), { + body: { content: 'hi', timestamp: 1, guestName: 'Anon' }, + }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(403); + expect(await db.comment.count()).toBe(0); + }); + + it('returns 403 to a signed-in stranger', async () => { + const scenario = await seedVersion(); + const stranger = await createUser(); + signedInAs(stranger); + + const response = await callRoute( + createCommentRoute, + apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp: 1 } }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(403); + expect(await db.comment.count()).toBe(0); + }); + + it('returns 403 once the workspace owner has lost billing access', async () => { + const expiredOwner = await createExpiredUser(); + const workspace = await createWorkspace({ ownerId: expiredOwner.id }); + const project = await createProject({ ownerId: expiredOwner.id, workspaceId: workspace.id }); + const video = await createVideo({ projectId: project.id }); + const version = await createVersion({ videoParentId: video.id }); + signedInAs(expiredOwner); + + const response = await callRoute( + createCommentRoute, + apiRequest(commentsUrl(version.id), { body: { content: 'hi', timestamp: 1 } }), + { versionId: version.id } + ); + + expect(response.status).toBe(403); + expect(await db.comment.count()).toBe(0); + }); + + it('returns 400 when the timestamp is missing', async () => { + const scenario = await seedVersion(); + signedInAs(scenario.owner); + + const response = await callRoute( + createCommentRoute, + apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi' } }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(400); + }); + + it.each([ + [-1, 'a negative timestamp'], + ['not-a-number', 'an unparseable timestamp'], + [Number.POSITIVE_INFINITY, 'a non-finite timestamp'], + [121, 'a timestamp past the version duration of 120'], + ])('rejects the timestamp %s with 400 (%s)', async (timestamp, label) => { + const scenario = await seedVersion({ duration: 120 }); + signedInAs(scenario.owner); + + const response = await callRoute( + createCommentRoute, + apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp } }), + { versionId: scenario.version.id } + ); + + expect(response.status, label).toBe(400); + expect(await db.comment.count()).toBe(0); + }); + + it('accepts a timestamp exactly equal to the duration', async () => { + const scenario = await seedVersion({ duration: 120 }); + signedInAs(scenario.owner); + + const response = await callRoute( + createCommentRoute, + apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp: 120 } }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(201); + expect((await db.comment.findFirstOrThrow()).timestamp).toBe(120); + }); + + it('rejects a timestampEnd below the timestamp', async () => { + const scenario = await seedVersion({ duration: 120 }); + signedInAs(scenario.owner); + + const response = await callRoute( + createCommentRoute, + apiRequest(commentsUrl(scenario.version.id), { + body: { content: 'hi', timestamp: 10, timestampEnd: 5 }, + }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(400); + expect(await readError(response)).toMatch(/greater than or equal/i); + }); + + it('rejects a comment with no content, voice, image or annotation', async () => { + const scenario = await seedVersion(); + signedInAs(scenario.owner); + + const response = await callRoute( + createCommentRoute, + apiRequest(commentsUrl(scenario.version.id), { body: { timestamp: 1 } }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(400); + }); + + it('rejects content longer than 10000 characters', async () => { + const scenario = await seedVersion(); + signedInAs(scenario.owner); + + const response = await callRoute( + createCommentRoute, + apiRequest(commentsUrl(scenario.version.id), { + body: { content: 'x'.repeat(10_001), timestamp: 1 }, + }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(400); + expect(await db.comment.count()).toBe(0); + }); + + it.each([ + ['a bare object', { color: '#FF3B30', width: 4, points: [] }], + ['a stroke with a 3-digit colour', [{ ...VALID_STROKE, color: '#f00' }]], + ['a stroke with a named colour', [{ ...VALID_STROKE, color: 'red' }]], + ['a stroke with width 0', [{ ...VALID_STROKE, width: 0 }]], + ['a stroke with width 21', [{ ...VALID_STROKE, width: 21 }]], + ['a stroke with a NaN coordinate', [{ ...VALID_STROKE, points: [{ x: 0, y: null }] }]], + ['a stroke whose points are not an array', [{ ...VALID_STROKE, points: 'nope' }]], + ['a double-encoded JSON string', JSON.stringify([VALID_STROKE])], + ['an array of arrays', [[VALID_STROKE]]], + ['an array containing null', [null]], + ])('rejects annotationData given as %s', async (_label, annotationData) => { + const scenario = await seedVersion(); + signedInAs(scenario.owner); + + const response = await callRoute( + createCommentRoute, + apiRequest(commentsUrl(scenario.version.id), { + body: { timestamp: 1, annotationData }, + }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(400); + expect(await db.comment.count()).toBe(0); + }); + + // Written as raw JSON on purpose. `{ __proto__: ... }` in an object literal + // sets the prototype rather than creating an own property, so JSON.stringify + // would silently drop it and the test would prove nothing. JSON.parse, by + // contrast, does create a real own "__proto__" property. + it('does not let a __proto__ key in annotationData reach the database or Object.prototype', async () => { + const scenario = await seedVersion(); + signedInAs(scenario.owner); + + const rawBody = JSON.stringify({ + timestamp: 1, + annotationData: [ + JSON.parse( + '{"points":[{"x":0,"y":0}],"color":"#FF3B30","width":4,' + + '"__proto__":{"polluted":"yes"},"constructor":{"prototype":{"polluted":"yes"}}}' + ), + ], + }); + + const response = await callRoute( + createCommentRoute, + apiRequest(commentsUrl(scenario.version.id), { + method: 'POST', + rawBody, + headers: { 'content-type': 'application/json' }, + }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(201); + const stored = await db.comment.findFirstOrThrow(); + expect(stored.annotationData).toBe( + JSON.stringify([{ points: [{ x: 0, y: 0 }], color: '#FF3B30', width: 4 }]) + ); + expect(stored.annotationData).not.toContain('polluted'); + expect(stored.annotationData).not.toContain('__proto__'); + expect(({} as Record).polluted).toBeUndefined(); + }); + + it('re-serialises accepted annotation strokes into canonical form', async () => { + const scenario = await seedVersion(); + signedInAs(scenario.owner); + + const response = await callRoute( + createCommentRoute, + apiRequest(commentsUrl(scenario.version.id), { + body: { + timestamp: 1, + annotationData: [{ ...VALID_STROKE, extraneous: 'dropped', tool: ''} /> + ); + + expect(container.querySelector('img')).toBeNull(); + expect(container.querySelector('script')).toBeNull(); + expect(container).toHaveTextContent(''); + }); + + it('is case sensitive about the scheme, so HTTPS:// is left as text', () => { + // Pinning current behaviour: the regex has no `i` flag, so an uppercase + // scheme is not linkified. Harmless, but worth knowing before someone + // "fixes" the regex and widens what becomes clickable. + const { container } = render(); + + expect(container.querySelector('a')).toBeNull(); + }); + + it('swallows trailing punctuation into the href', () => { + // Pinning current behaviour: `[^\s]+` is greedy to the next whitespace, so + // the sentence-ending period lands inside the link. + render(); + + expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com/pr/12.'); + }); +}); + +describe('CommentRichText asset mentions', () => { + it('renders a mention as a button labelled with the asset name', () => { + render(); + + expect(screen.getByRole('button', { name: '@Reference frame.png' })).toBeInTheDocument(); + }); + + it('reports the mentioned asset id when clicked', async () => { + const onAssetMentionClick = vi.fn(); + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: '@Reference frame.png' })); + + expect(onAssetMentionClick).toHaveBeenCalledWith('a1b2c3'); + }); + + it('prefers the current asset name over the name stored in the comment', () => { + render( + + ); + + expect(screen.getByRole('button', { name: '@Renamed.png' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '@old-name.png' })).not.toBeInTheDocument(); + }); + + it('falls back to the stored name when the asset is gone', () => { + render(); + + expect(screen.getByRole('button', { name: '@deleted.png' })).toBeInTheDocument(); + }); + + it('does not throw when clicked without a handler', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: '@Reference frame.png' })); + + expect(screen.getByRole('button', { name: '@Reference frame.png' })).toBeInTheDocument(); + }); + + it('renders text, mentions and links together in reading order', () => { + const { container } = render( + + ); + + expect(container).toHaveTextContent('Before @One middle https://example.com/x after'); + expect(screen.getByRole('button', { name: '@One' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'https://example.com/x' })).toBeInTheDocument(); + }); + + it('renders several mentions in one comment', () => { + render(); + + expect(screen.getAllByRole('button').map((b) => b.textContent)).toEqual(['@One', '@Two']); + }); + + it('accepts an uppercase asset id', () => { + const onAssetMentionClick = vi.fn(); + render( + + ); + + expect(screen.getByRole('button', { name: '@One' })).toBeInTheDocument(); + }); + + it('leaves a mention with a non-alphanumeric id as plain text', () => { + const { container } = render(); + + expect(screen.queryAllByRole('button')).toHaveLength(0); + expect(container).toHaveTextContent('@[One](asset:aa-11)'); + }); + + it('leaves a malformed mention as plain text', () => { + const { container } = render(); + + expect(screen.queryAllByRole('button')).toHaveLength(0); + expect(container).toHaveTextContent('@[One](assets:aaa111)'); + }); + + it('does not inject markup through the mention label', () => { + const { container } = render( + ](asset:aaa111)'} /> + ); + + expect(container.querySelector('img')).toBeNull(); + expect(container).toHaveTextContent('@'); + }); + + it('does not linkify a URL used as a mention label', () => { + const { container } = render(); + + expect(container.querySelector('a')).toBeNull(); + expect(screen.getByRole('button', { name: '@https://evil.test/x' })).toBeInTheDocument(); + }); + + // KNOWN BUG, pinned rather than fixed. `renderUrls` keys its fragments by the + // index within its own slice, and CommentRichText calls it once per gap + // between mentions, so the same key ("txt-0") is emitted for several + // siblings. React logs "Encountered two children with the same key" and warns + // that children may be duplicated or omitted. The output happens to be + // correct today; the text assertion locks that in, and the warning assertion + // is the thing to delete once the keys are made unique. + it('produces duplicate React keys when text surrounds a mention', () => { + const { container } = render( + + ); + + expect(container).toHaveTextContent('Before @One middle @Two after'); + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('same key'), 'txt-0'); + }); +}); diff --git a/tests/component/error-boundary.test.tsx b/tests/component/error-boundary.test.tsx new file mode 100644 index 0000000..7aa4685 --- /dev/null +++ b/tests/component/error-boundary.test.tsx @@ -0,0 +1,272 @@ +import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from 'vitest'; +import { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ErrorBoundary, withErrorBoundary } from '@/components/error-boundary'; + +function Boom({ message = 'render blew up' }: { message?: string }): never { + throw new Error(message); +} + +/** + * Throws while the shared flag is set. React retries a failed render before it + * gives up, so a counter would be consumed by the retry; a flag the test flips + * explicitly keeps "Try again" deterministic. + */ +function ConditionalBoom({ shouldThrow }: { shouldThrow: { value: boolean } }) { + if (shouldThrow.value) { + throw new Error('transient'); + } + return

Recovered content

; +} + +let consoleError: ReturnType; + +beforeEach(() => { + // React itself logs every caught error, on top of the boundary's own log. + // Silence both; the assertions below check the boundary's log explicitly. + consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('ErrorBoundary', () => { + it('renders its children while nothing throws', () => { + render( + +

Healthy content

+
+ ); + + expect(screen.getByText('Healthy content')).toBeInTheDocument(); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('replaces a crashed subtree with the recovery fallback', () => { + render( + + + + ); + + expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Reload page' })).toBeInTheDocument(); + }); + + it('names the crashed area when given a context', () => { + render( + + + + ); + + expect(screen.getByRole('heading', { name: 'Assets pane crashed' })).toBeInTheDocument(); + expect( + screen.getByText( + 'An unexpected error occurred. Try resetting the component or reload the page.' + ) + ).toBeInTheDocument(); + }); + + it('offers video-specific guidance for a video context', () => { + render( + + + + ); + + expect(screen.getByRole('heading', { name: 'VideoPlayer crashed' })).toBeInTheDocument(); + expect( + screen.getByText( + 'The video player encountered an error. Try reloading or go back to the project.' + ) + ).toBeInTheDocument(); + }); + + it('does not swallow the error: it reports it to onError', () => { + const onError = vi.fn(); + render( + + + + ); + + expect(onError).toHaveBeenCalledTimes(1); + const [error, errorInfo] = onError.mock.calls[0]; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('player adapter missing'); + expect(errorInfo).toHaveProperty('componentStack'); + expect(String((errorInfo as { componentStack: string }).componentStack)).toContain('Boom'); + }); + + it('does not swallow the error: it logs it with the context', () => { + render( + + + + ); + + expect(consoleError).toHaveBeenCalledWith( + 'ErrorBoundary [VideoPlayer] caught an error:', + expect.objectContaining({ message: 'player adapter missing' }), + expect.anything() + ); + }); + + it('logs without a context prefix when none was given', () => { + render( + + + + ); + + expect(consoleError).toHaveBeenCalledWith( + 'ErrorBoundary caught an error:', + expect.objectContaining({ message: 'nameless' }), + expect.anything() + ); + }); + + it('renders a custom fallback instead of the built-in one', () => { + const onError = vi.fn(); + render( + Could not load the timeline

} onError={onError}> + +
+ ); + + expect(screen.getByText('Could not load the timeline')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + // The error still propagates to the caller even with a custom fallback. + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('re-renders the children when Try again is pressed', async () => { + const shouldThrow = { value: true }; + render( + + + + ); + + expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument(); + expect(screen.queryByText('Recovered content')).not.toBeInTheDocument(); + + shouldThrow.value = false; + await userEvent.click(screen.getByRole('button', { name: 'Try again' })); + + expect(screen.getByText('Recovered content')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Something went wrong' })).not.toBeInTheDocument(); + }); + + it('shows the fallback again if the retry crashes too', async () => { + const shouldThrow = { value: true }; + render( + + + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Try again' })); + + expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument(); + expect(screen.queryByText('Recovered content')).not.toBeInTheDocument(); + }); + + it('reloads the page when Reload page is pressed', async () => { + const reload = vi.fn(); + // Restored by hand. vi.restoreAllMocks() undoes spies, not a + // defineProperty, so without this the whole file runs on a fake + // window.location from here on and the next test to touch it would be + // reading a stub left behind by this one. + const realLocation = Object.getOwnPropertyDescriptor(window, 'location'); + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...window.location, reload }, + }); + onTestFinished(() => { + if (realLocation) { + Object.defineProperty(window, 'location', realLocation); + } + }); + + render( + + + + ); + await userEvent.click(screen.getByRole('button', { name: 'Reload page' })); + + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('keeps a healthy sibling boundary mounted when one crashes', () => { + render( +
+ + + + +

Right pane still here

+
+
+ ); + + expect(screen.getByRole('heading', { name: 'Left crashed' })).toBeInTheDocument(); + expect(screen.getByText('Right pane still here')).toBeInTheDocument(); + }); + + it('catches an error thrown from a state updater, not just from render', async () => { + function ThrowOnClick() { + const [, setState] = useState(0); + return ( + + ); + } + + render( + + + + ); + await userEvent.click(screen.getByRole('button', { name: 'Break it' })); + + expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Break it' })).not.toBeInTheDocument(); + }); +}); + +describe('withErrorBoundary', () => { + it('wraps a component and forwards its props', () => { + function Panel({ label }: { label: string }) { + return

{label}

; + } + const Wrapped = withErrorBoundary(Panel); + + render(); + + expect(screen.getByText('Timeline')).toBeInTheDocument(); + }); + + it('applies the boundary options to a crash inside the wrapped component', () => { + const onError = vi.fn(); + const Wrapped = withErrorBoundary(Boom, { context: 'VideoPlayer', onError }); + + render(); + + expect(screen.getByRole('heading', { name: 'VideoPlayer crashed' })).toBeInTheDocument(); + expect(onError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/component/guest-gate.test.tsx b/tests/component/guest-gate.test.tsx new file mode 100644 index 0000000..f9553c8 --- /dev/null +++ b/tests/component/guest-gate.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { GuestGate } from '@/components/guest-gate'; + +// next/link needs the App Router context to mount. The sign-in link is +// incidental to the validation branches under test, so stub it to an anchor. +vi.mock('next/link', () => ({ + default: ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ), +})); + +const STORAGE_KEY = 'openframe_guest_name'; + +function renderGate() { + return render( + +

Gated video page

+
+ ); +} + +/** + * ACCESSIBILITY FINDING: the name field has no