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