diff --git a/.github/workflows/browser-nightly.yml b/.github/workflows/browser-nightly.yml
new file mode 100644
index 000000000..a189ae59e
--- /dev/null
+++ b/.github/workflows/browser-nightly.yml
@@ -0,0 +1,62 @@
+name: Browser nightly
+
+# The full credential-free browser suite on desktop and mobile Chromium. Pull
+# requests run only the @smoke subset (deploy.yml), so this catches what the
+# subset skips, at most a day late.
+on:
+ schedule:
+ - cron: "47 2 * * *"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: browser-nightly
+ cancel-in-progress: true
+
+jobs:
+ browser-tests:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Cache Playwright browsers
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
+
+ - name: Install Chromium
+ run: npx playwright install --with-deps chromium
+
+ - name: Browser tests
+ env:
+ CI: 1
+ PORT: 18080
+ PLAYWRIGHT_BASE_URL: http://127.0.0.1:18080
+ run: npm run test:e2e
+
+ - name: Upload browser failure artifacts
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: chromium-nightly-failure-${{ github.run_id }}
+ path: |
+ test-results/
+ playwright-report/
+ if-no-files-found: ignore
+ retention-days: 7
diff --git a/.github/workflows/database.yml b/.github/workflows/database.yml
new file mode 100644
index 000000000..313d4c64d
--- /dev/null
+++ b/.github/workflows/database.yml
@@ -0,0 +1,104 @@
+name: Database
+
+# Local Supabase, pgTAP, and the full-stack browser journeys. About 20 minutes,
+# so pull requests run it only when they touch the backend, the stores and
+# clients that talk to it, or the gate itself. Pushes to main use the same
+# filter, and the weekly run catches frontend-only regressions.
+on:
+ pull_request:
+ paths:
+ - "supabase/**"
+ - "services/collaboration/**"
+ - "src/lib/supabase/**"
+ - "src/lib/stores/**"
+ - "src/lib/ai/**"
+ - "e2e/full-stack/**"
+ - "e2e/workspace-collaboration.spec.ts"
+ - "playwright.workspace.config.ts"
+ - ".superset/db-gate.sh"
+ - ".superset/lib/**"
+ - ".superset/workspace-test.env"
+ - "scripts/test-*.sh"
+ - "scripts/workspace-test-env.mjs"
+ - "package.json"
+ - "package-lock.json"
+ - ".github/workflows/database.yml"
+ push:
+ branches: [ "main" ]
+ paths:
+ - "supabase/**"
+ - "services/collaboration/**"
+ - "src/lib/supabase/**"
+ - "src/lib/stores/**"
+ - "src/lib/ai/**"
+ - "e2e/full-stack/**"
+ - "e2e/workspace-collaboration.spec.ts"
+ - "playwright.workspace.config.ts"
+ - ".superset/db-gate.sh"
+ - ".superset/lib/**"
+ - ".superset/workspace-test.env"
+ - "scripts/test-*.sh"
+ - "scripts/workspace-test-env.mjs"
+ - "package.json"
+ - "package-lock.json"
+ - ".github/workflows/database.yml"
+ schedule:
+ - cron: "23 5 * * 0"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ database-tests:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 22
+
+ - name: Set up Supabase CLI
+ uses: supabase/setup-cli@46f7f98c7f948ad727d22c1e67fab04c223a0520 # v3.0.0
+ with:
+ version: 2.116.0
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Cache Playwright browsers
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
+
+ - name: Install Chromium
+ run: npx playwright install --with-deps chromium
+
+ - name: Database and real workspace journey
+ env:
+ # ghcr.io rate-limits shared runners; the same images are on ECR.
+ SUPABASE_INTERNAL_IMAGE_REGISTRY: public.ecr.aws
+ WORKSPACE_GATE_ARTIFACT_ROOT: ${{ runner.temp }}/workspace-gate
+ run: ./.superset/db-gate.sh
+
+ - name: Upload workspace failure artifacts
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: workspace-gate-failure-${{ github.run_id }}
+ path: |
+ ${{ runner.temp }}/workspace-gate/
+ test-results/
+ playwright-report/
+ if-no-files-found: ignore
+ retention-days: 7
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index cd3210fd5..851863857 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -4,6 +4,11 @@ on:
push:
branches: [ "main" ]
pull_request:
+ # Docs-only pull requests run docs.yml instead.
+ paths-ignore:
+ - "docs/**"
+ - "plans/**"
+ - "**/*.md"
workflow_dispatch:
permissions:
@@ -26,6 +31,7 @@ jobs:
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
+ cache: npm
- name: Install dependencies
run: npm ci
@@ -46,7 +52,7 @@ jobs:
run: npm run typecheck
- name: Test
- run: npm test -- --coverage
+ run: npm test
- name: Collaboration service tests
run: npm run test:collaboration
@@ -57,7 +63,9 @@ jobs:
- name: Build
run: npm run build
- database-tests:
+ # Runs beside quality so the pull-request check takes as long as the slower
+ # of the two. The full desktop and mobile suite runs in browser-nightly.yml.
+ browser-smoke:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
@@ -70,70 +78,32 @@ jobs:
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
-
- - name: Set up Supabase CLI
- uses: supabase/setup-cli@46f7f98c7f948ad727d22c1e67fab04c223a0520 # v3.0.0
- with:
- version: 2.116.0
+ cache: npm
- name: Install dependencies
run: npm ci
- - name: Install Chromium
- run: npx playwright install --with-deps chromium
-
- - name: Database and real workspace journey
- env:
- # ghcr.io rate-limits shared runners; the same images are on ECR.
- SUPABASE_INTERNAL_IMAGE_REGISTRY: public.ecr.aws
- WORKSPACE_GATE_ARTIFACT_ROOT: ${{ runner.temp }}/workspace-gate
- run: ./.superset/db-gate.sh
-
- - name: Upload workspace failure artifacts
- if: failure()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: workspace-gate-failure-${{ github.run_id }}
- path: |
- ${{ runner.temp }}/workspace-gate/
- test-results/
- playwright-report/
- if-no-files-found: ignore
- retention-days: 7
-
- browser-tests:
- if: github.event_name == 'pull_request'
- needs: quality
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Cache Playwright browsers
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
- persist-credentials: false
-
- - name: Set up Node
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: 22
-
- - name: Install dependencies
- run: npm ci
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install Chromium
run: npx playwright install --with-deps chromium
- - name: Browser tests
+ - name: Browser smoke tests
env:
CI: 1
PORT: 18080
PLAYWRIGHT_BASE_URL: http://127.0.0.1:18080
- run: npm run test:e2e
+ run: npm run test:e2e:smoke
- name: Upload browser failure artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
- name: chromium-browser-failure-${{ github.run_id }}
+ name: chromium-smoke-failure-${{ github.run_id }}
path: |
test-results/
playwright-report/
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 000000000..6808f91cd
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,29 @@
+name: Docs
+
+# Docs-only pull requests skip the Quality workflow, so they get this check.
+on:
+ pull_request:
+ paths:
+ - "docs/**"
+ - "plans/**"
+ - "**/*.md"
+
+permissions:
+ contents: read
+
+jobs:
+ docs:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 22
+
+ - name: Documentation
+ run: npm run docs:check
diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml
index 5ee031644..ecb81e64f 100644
--- a/.github/workflows/live-e2e.yml
+++ b/.github/workflows/live-e2e.yml
@@ -1,9 +1,17 @@
name: Live provider E2E
-# Real OpenRouter, Jev, and Exa behind the local stack. Runs on pull requests,
-# nightly to catch provider drift without code changes, and on demand.
+# Real OpenRouter, Jev, and Exa behind the local stack. Every run spends
+# provider credit, so pull requests run it only when AI or research code
+# changes. Nightly runs catch provider drift without code changes.
on:
pull_request:
+ paths:
+ - "supabase/functions/**"
+ - "src/lib/ai/**"
+ - "e2e/live/**"
+ - "e2e/full-stack/fake-openrouter.mjs"
+ - ".superset/db-gate.sh"
+ - ".github/workflows/live-e2e.yml"
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
diff --git a/AGENTS.md b/AGENTS.md
index 3c3ffbebd..37e1c1a71 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -42,14 +42,14 @@ Run the smallest relevant check first:
npm run docs:check
npm run lint
npm run typecheck
-npm test -- --coverage
+npm test
npm run test:collaboration
npm run test:edge
npm run build
P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0.0.1:$P npm run test:e2e
```
-`npm run verify` runs the complete credential-free ladder, including desktop and mobile Chromium against a fresh Vite server.
+`npm run verify` runs the credential-free ladder with the desktop Chromium `@smoke` browser set (about a minute). `npm run verify:full` adds the full desktop and mobile suite.
If GitHub Actions jobs fail within 2 to 5 seconds with no logs, they did not run: the account is out of Actions minutes (billing), not failing on the code. In that case run the same checks locally at the final head (`npm run verify` and the database gate) and record the exact results in the PR. A local pass is not a CI pass; say which one you have.
diff --git a/README.md b/README.md
index 88684a941..b8ebdd52e 100644
--- a/README.md
+++ b/README.md
@@ -41,9 +41,10 @@ Vite serves on port `8080` by default. In a Superset workspace or other linked w
```bash
npm run lint
npm run typecheck
-npm test -- --coverage
+npm test
npm run build
-npm run verify # full credential-free gate, including desktop and mobile Chromium
+npm run verify # credential-free gate with the desktop Chromium smoke set
+npm run verify:full # adds the full desktop and mobile browser suite
./.superset/db-gate.sh # migrations, pgTAP, and full-stack journeys on local Supabase
```
diff --git a/docs/operations.md b/docs/operations.md
index 9203a6cfa..41d896aaf 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -10,20 +10,23 @@ Never infer authorization from access. Never use production to replace missing l
## CI workflows
-[`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml) (the `Quality` workflow) owns credential-free quality plus pull-request database and browser tests. It does not deploy anything; Vercel does.
+[`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml) (the `Quality` workflow) owns credential-free quality and the pull-request browser smoke set. It does not deploy anything; Vercel does.
The quality job uses Node 22, `npm ci`, and Deno 2.9.6, then runs:
- `npm run docs:check`
- `npm run lint`
- `npm run typecheck`
-- `npm test -- --coverage`
+- `npm test`
+- `npm run test:collaboration`
- `npm run test:edge`
- `npm run build`
-Pull requests also start isolated local Supabase for pgTAP and run desktop plus mobile Chromium on port `18080`.
+On pull requests a parallel `browser-smoke` job runs the Playwright tests tagged `@smoke` (about 30) on desktop Chromium, port `18080`, with the browser download cached. [`.github/workflows/browser-nightly.yml`](../.github/workflows/browser-nightly.yml) runs the full desktop and mobile suite nightly and on demand. Pull requests that change only `docs/`, `plans/`, or Markdown skip `Quality` and run [`.github/workflows/docs.yml`](../.github/workflows/docs.yml), which runs `npm run docs:check` alone.
-[`.github/workflows/live-e2e.yml`](../.github/workflows/live-e2e.yml) runs the local stack against real providers on pull requests and nightly. [`.github/workflows/model-matrix.yml`](../.github/workflows/model-matrix.yml) generates a case with every catalog model weekly and when AI code changes. [`.github/workflows/prod-canary.yml`](../.github/workflows/prod-canary.yml) checks production after each Vercel production deployment and daily. See [testing.md](testing.md) for all three. Both Supabase-starting jobs pull images from `public.ecr.aws`, because ghcr.io rate-limits shared runners.
+[`.github/workflows/database.yml`](../.github/workflows/database.yml) starts isolated local Supabase for pgTAP and the full-stack browser journeys. It takes about 20 minutes, so it runs on pull requests and `main` pushes only when they change the backend, the stores or clients that talk to it, or the gate itself (the `paths` list in the file), plus weekly and on demand.
+
+[`.github/workflows/live-e2e.yml`](../.github/workflows/live-e2e.yml) runs the local stack against real providers nightly and on pull requests that change AI or research code. [`.github/workflows/model-matrix.yml`](../.github/workflows/model-matrix.yml) generates a case with every catalog model weekly and when AI code changes. [`.github/workflows/prod-canary.yml`](../.github/workflows/prod-canary.yml) checks production after each Vercel production deployment and daily. See [testing.md](testing.md) for all three. Both Supabase-starting jobs pull images from `public.ecr.aws`, because ghcr.io rate-limits shared runners.
[`.github/workflows/staging-e2e.yml`](../.github/workflows/staging-e2e.yml) owns protected staging. It accepts eligible successful main-branch `Quality` runs or a manual dispatch from `main`. It runs only when `STAGING_E2E_ENABLED=true` and all protected values exist.
@@ -33,7 +36,7 @@ If Actions jobs fail within 2 to 5 seconds with no runner and no logs, the accou
## Frontend hosting on Vercel
-Vercel builds and serves the frontend from the Git repository. Merging to `main` starts a production deployment to `https://debate.pi-stack.org`. Pull requests may get Vercel preview deployments, and those use the production Supabase project unless the in-app switcher picks staging, so treat a preview as production data. For a staging-pinned preview, use `npm run dev:preview` (see [development.md](development.md#preview-on-the-tailnet)). The production canary runs after each successful Vercel `Production` deployment.
+Vercel builds and serves the frontend from the Git repository. Merging to `main` starts a production deployment to `https://debate.pi-stack.org`; pull requests get two preview deployments (see [Per-PR previews](#per-pr-previews)). For a staging-pinned preview on the tailnet, use `npm run dev:preview` (see [development.md](development.md#preview-on-the-tailnet)). The production canary runs after each successful Vercel `Production` deployment.
[`vercel.json`](../vercel.json) owns:
@@ -90,8 +93,21 @@ Production then follows [Supabase changes](#supabase-changes) with the same list
Admins see a **Developer** card in Settings that signs out, sets the key, and reloads. The app's persisted stores are shared by both projects, so the switch also clears this project's stored session, its Google token, and all user-specific local data, including saved results and form drafts. It waits at most 4 seconds for the sign-out request, so an unresponsive project cannot block it. Other open tabs clear their own session storage, including their Google token, and reload as soon as the key changes, so no tab keeps talking to the old project while sharing stores with the new one. The strip publishes its height as `--staging-banner-height`, and the landing navigation and app header sit below it. While staging is active, every page shows a staging strip with **Switch to Production**, and the Settings card is shown to everyone. A new staging session starts signed out, and an admin on production may not be an admin on staging, so the way back cannot depend on that role. The switch is a convenience, not an access control. Staging RLS and its separate user accounts are the boundary.
+A build can pin one project with the `VITE_SUPABASE_ENV` build variable (`production` or `staging`; any other non-empty value fails the build). A pinned build ignores the localStorage key, hides the Settings card and the strip's **Switch to Production** button, and does not reload when another tab switches. The strip still shows on a pinned staging build. The staging Vercel project uses this so each PR gets a staging preview; see [Per-PR previews](#per-pr-previews).
+
The switch only works while the `connect-src` directive in [`vercel.json`](../vercel.json) allows the staging project origin. Today the `*.supabase.co` wildcard covers it. If you narrow that wildcard, list both project origins explicitly.
+## Per-PR previews
+
+Each pull request gets two Vercel previews, one per Supabase project, and both rebuild on every push:
+
+- `debate-navigator` (the production Vercel project) builds previews with no `VITE_SUPABASE_ENV`, so they use production unless the saved toggle says staging. Production's Auth redirect allowlist must include `https://debate-navigator-*-adam-gaborjanis-projects.vercel.app/**` for Google sign-in to return to the preview.
+- `debatelab-staging` builds the same repository with `VITE_SUPABASE_ENV=staging`. Staging's Auth redirect allowlist must include `https://debatelab-staging-*-adam-gaborjanis-projects.vercel.app/**`. Its production deployment tracks `main`.
+
+Edge Function CORS and the demo-code sign-in redirect accept these hosts through `isAllowedOrigin()` in [`supabase/functions/_shared/cors.ts`](../supabase/functions/_shared/cors.ts). Previews can call functions only after both projects run a function build that includes it.
+
+The staging project's name must not start with `debate-navigator-`, or production's allowlist pattern would also match it. A production preview runs unmerged frontend code with real production sessions and data, so use the staging preview unless the change needs production data. A staging preview runs against whatever backend staging has; if the PR changes migrations or Edge Functions, sync staging first (see [Staging sync](#staging-sync)).
+
## Backend drift check
`npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the project the Supabase CLI resolves, which is `SUPABASE_PROJECT_ID` when that is set and the linked project otherwise; `npm run backend:status -- --project-ref [` inspects another one. It runs `supabase migration list`, `supabase functions list` and `supabase secrets list`, reads the Auth site URL and redirect allowlist from the Management API with the CLI's access token (`SUPABASE_ACCESS_TOKEN`, or the `access-token` file in `SUPABASE_HOME`, default `~/.supabase`), and changes no schema, data, functions, or secrets. It prints secret names only, never values or digests. To connect to the database, the CLI creates its usual short-lived login role.
diff --git a/docs/testing.md b/docs/testing.md
index 0c446771b..c414f726a 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -35,10 +35,9 @@ Lint checks repository ESLint rules. Type checking uses `tsconfig.app.json` and
```bash
npm test
-npm test -- --coverage
```
-Vitest runs tests selected by [`vitest.config.ts`](../vitest.config.ts). The coverage gate is deliberately selected-scope coverage for audited runtime modules, not a claim that every maintained file is measured.
+Vitest runs tests selected by [`vitest.config.ts`](../vitest.config.ts). The suite is deliberately small: it keeps only logic that browser tests cannot reach cheaply, such as security boundaries, Current Debate isolation, project selection, stream parsing, source import, and the Crossfire and Sparring rules. There is no coverage gate. Add a unit test for a pure function with real branching or a bug that has a clear repro; prove page behavior with Playwright instead.
Run one test file while iterating:
@@ -111,6 +110,8 @@ Run this after `npm run build`. It serves `dist/` through Vite preview and prove
P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0.0.1:$P npm run test:e2e
```
+Pull requests run only tests tagged `@smoke` on desktop Chromium (`npm run test:e2e:smoke`, about 30 tests). Tag a test `@smoke` only when it covers a whole user path that would otherwise go unchecked until the nightly run.
+
`.superset/e2e-port.sh` gives each worktree its own port from `18100-18179`, recorded in `~/.superset/debatelab-ports/allocations.json`, so two worktrees can run the gate at once. Without `PORT`, Playwright falls back to `18080`. Playwright starts a new Vite server on that port and runs:
- desktop Chromium at 1280 by 800
@@ -141,12 +142,12 @@ The gate runs, in order:
1. documentation checks
2. lint
3. type checking
-4. selected-scope coverage
+4. unit tests
5. collaboration service tests
6. native Edge Function tests
7. production build
8. production service-worker offline smoke
-9. desktop and mobile Chromium
+9. the desktop Chromium `@smoke` set (`npm run verify:full` adds the full desktop and mobile suite)
It does not start local Supabase, run pgTAP, run WebKit, or use protected staging.
@@ -164,7 +165,7 @@ In Superset, run:
./.superset/db-gate.sh
```
-The wrapper acquires `~/.superset/debatelab-db.lock`, starts full local Supabase, runs `npm run test:db`, then holds the same lock while a controlled Mailpit relay, collaboration process, fake model provider, and Vite client run the real-Auth desktop/mobile workspace and full-stack feature journeys. Pull requests run this same gate in the `database-tests` job and retain its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. To run only the database portion under the same lock, use `./.superset/db-gate.sh --db-only`. Outside Superset, run it with:
+The wrapper acquires `~/.superset/debatelab-db.lock`, starts full local Supabase, runs `npm run test:db`, then holds the same lock while a controlled Mailpit relay, collaboration process, fake model provider, and Vite client run the real-Auth desktop/mobile workspace and full-stack feature journeys. The `Database` workflow runs this same gate on pull requests that touch its paths, and retains its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. To run only the database portion under the same lock, use `./.superset/db-gate.sh --db-only`. Outside Superset, run it with:
```bash
supabase db start
@@ -217,7 +218,7 @@ The specs cover web-researched case generation, verified Factoids citations, Ask
A live failure means the pipeline broke with this model. It does not prove how production models behave, because each model has its own latency and JSON quality.
-`.github/workflows/live-e2e.yml` runs this on pull requests, nightly at 03:17 UTC, and on demand, using the `LIVE_OPENROUTER_API_KEY` and `LIVE_EXA_API_KEY` repository secrets. The gate exits before starting anything when either key is missing, so a misconfigured job fails instead of skipping.
+`.github/workflows/live-e2e.yml` runs this nightly at 03:17 UTC, on demand, and on pull requests that change `supabase/functions/`, `src/lib/ai/`, `e2e/live/`, the fake provider, or the gate, using the `LIVE_OPENROUTER_API_KEY` and `LIVE_EXA_API_KEY` repository secrets. The gate exits before starting anything when either key is missing, so a misconfigured job fails instead of skipping.
### Model matrix
@@ -303,13 +304,20 @@ The GitHub staging workflow runs only after an eligible main-branch quality resu
A skipped staging workflow is not a pass. Missing credentials or provider access is a blocker, not permission to use production or mocks.
+## Checking a change by hand
+
+Tests are not the proof that a change works; using it is. Two local recipes that need no shared Supabase stack:
+
+- Built frontend: `npx vite build --outDir /tmp/` (put build variables such as `VITE_SUPABASE_ENV=staging` in front), then `npx vite preview --outDir /tmp/ --host 127.0.0.1 --port --strictPort`, and drive it with Playwright. Abort `supabase.co` requests with `context.route()` and record their hosts to see which project the app calls without touching it; the Google button on `/login` produces a safe `/auth/v1/authorize` request. `vite preview` returns HTML for `/_vercel/insights` and `/_vercel/speed-insights`, so their `Unexpected token '<'` page errors are expected locally.
+- One Edge Function over HTTP: functions that end with `if (import.meta.main) Deno.serve(handleRequest)`, such as `demo-code-login`, run alone with `DENO_SERVE_ADDRESS=tcp:127.0.0.1: SUPABASE_URL= SUPABASE_SERVICE_ROLE_KEY=test deno run --allow-net --allow-env --allow-read supabase/functions//index.ts`. Point `SUPABASE_URL` at a small fake that answers the paths in the function's `index_test.ts`, and check CORS with `curl -X OPTIONS -H "Origin: " -D -`.
+
## CI evidence
-[`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml), the `Quality` workflow, runs documentation, lint, type, coverage, collaboration, Edge Function, and build checks. Pull requests also run database and Chromium jobs.
+[`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml), the `Quality` workflow, runs documentation, lint, type, unit, collaboration, Edge Function, and build checks. On pull requests a parallel `browser-smoke` job runs the `@smoke` set on desktop Chromium. [`.github/workflows/browser-nightly.yml`](../.github/workflows/browser-nightly.yml) runs the full desktop and mobile suite nightly and on demand. [`.github/workflows/database.yml`](../.github/workflows/database.yml) runs the database gate when backend-facing paths change.
[`.github/workflows/staging-e2e.yml`](../.github/workflows/staging-e2e.yml) owns protected staging conditions.
-[`.github/workflows/live-e2e.yml`](../.github/workflows/live-e2e.yml) runs the live provider journeys, and [`.github/workflows/prod-canary.yml`](../.github/workflows/prod-canary.yml) runs the production canary. Branch protection on `main` requires `quality`, `database-tests`, `browser-tests`, and `live-e2e`. It also requires a pull request, and administrators are not exempt.
+[`.github/workflows/live-e2e.yml`](../.github/workflows/live-e2e.yml) runs the live provider journeys, and [`.github/workflows/prod-canary.yml`](../.github/workflows/prod-canary.yml) runs the production canary. `main` has no branch protection or rulesets (checked 2026-10-01; GitHub Free does not enforce them on private repositories), so no check is required. Path-filtered workflows that skip therefore don't block merges. Read the PR's checks before merging.
To see why a run failed, use `npm run ci:failures` (current branch's PR), `npm run ci:failures -- ` or `npm run ci:failures -- `. It works while the run is still going, as soon as a job fails. It lists failed jobs and steps, prints the Playwright or Vitest summary from each job log, downloads the `*-failure-` artifacts into ignored `.ci-failures/`, and prints each failed test's error plus the dialogs and headings from its `error-context.md` page snapshot. A dialog the spec never answered shows up there. Protected staging uploads no artifact, so only its job log is available.
diff --git a/e2e/admin-announcements.spec.ts b/e2e/admin-announcements.spec.ts
index 3e9089dfb..812cb7ccc 100644
--- a/e2e/admin-announcements.spec.ts
+++ b/e2e/admin-announcements.spec.ts
@@ -37,7 +37,7 @@ function countAnnouncementListRequests(page: Page) {
return () => count;
}
-test('user sees and permanently dismisses an active announcement', async ({ page }) => {
+test('user sees and permanently dismisses an active announcement', { tag: '@smoke' }, async ({ page }) => {
let dismissBody: Record | null = null;
await page.route('**/functions/v1/announcements*', async (route) => {
const request = route.request();
diff --git a/e2e/authed-pages.spec.ts b/e2e/authed-pages.spec.ts
index 4ef7d642e..d4d7f3741 100644
--- a/e2e/authed-pages.spec.ts
+++ b/e2e/authed-pages.spec.ts
@@ -19,7 +19,8 @@ const AUTH_ROUTES = [
] as const;
for (const locale of LOCALES) {
- test.describe(`authed pages [${locale}]`, () => {
+ // English runs in the pull-request smoke set; the other locales run nightly.
+ test.describe(`authed pages [${locale}]`, locale === 'en' ? { tag: '@smoke' } : {}, () => {
test.beforeEach(async ({ page }) => {
await setLocale(page, locale);
await stubSupabaseSession(page);
diff --git a/e2e/crossfire.spec.ts b/e2e/crossfire.spec.ts
index 4826c6cda..072c4adb5 100644
--- a/e2e/crossfire.spec.ts
+++ b/e2e/crossfire.spec.ts
@@ -127,7 +127,7 @@ async function prepare(page: Page, requests: CrossfireRequest[], failFirstAnswer
await page.getByTestId('crossfire-argument').fill('Private cars create congestion and unsafe streets.');
}
-test('Question the AI completes a grounded Crossfire debrief', async ({ page }, testInfo) => {
+test('Question the AI completes a grounded Crossfire debrief', { tag: '@smoke' }, async ({ page }, testInfo) => {
const requests: CrossfireRequest[] = [];
await prepare(page, requests);
await page.getByTestId('mode-question-ai').click();
diff --git a/e2e/first-run.spec.ts b/e2e/first-run.spec.ts
index 6818a2a97..1292bce80 100644
--- a/e2e/first-run.spec.ts
+++ b/e2e/first-run.spec.ts
@@ -117,7 +117,7 @@ async function freshStart(page: Page, locale: 'en' | 'hu', tourDone: boolean) {
for (const locale of ['en', 'hu'] as const) {
const text = copy[locale];
- test(`[${locale}] the first tour step starts the first debate, then shows Library and Generate`, async ({ page }) => {
+ test(`[${locale}] the first tour step starts the first debate, then shows Library and Generate`, locale === 'en' ? { tag: '@smoke' } : {}, async ({ page }) => {
const backend = await freshStart(page, locale, false);
await page.goto('/app', { waitUntil: 'domcontentloaded' });
@@ -161,7 +161,7 @@ for (const locale of ['en', 'hu'] as const) {
await expect(tooltip).toHaveCount(0);
});
- test(`[${locale}] a tool offers a first debate from the typed motion instead of the switch dialog`, async ({ page }) => {
+ test(`[${locale}] a tool offers a first debate from the typed motion instead of the switch dialog`, locale === 'en' ? { tag: '@smoke' } : {}, async ({ page }) => {
const backend = await freshStart(page, locale, true);
await page.goto('/app/generate', { waitUntil: 'domcontentloaded' });
diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts
index 19c804b73..659677ba1 100644
--- a/e2e/login.spec.ts
+++ b/e2e/login.spec.ts
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { setLocale } from './helpers/auth';
-test.describe('login passwordless flows', () => {
+test.describe('login passwordless flows', { tag: '@smoke' }, () => {
test.beforeEach(async ({ page }) => {
await setLocale(page, 'en');
});
diff --git a/e2e/model-matrix/generate.spec.ts b/e2e/model-matrix/generate.spec.ts
index 02ead3188..87e63171c 100644
--- a/e2e/model-matrix/generate.spec.ts
+++ b/e2e/model-matrix/generate.spec.ts
@@ -1,5 +1,5 @@
import { MODEL_CATALOG } from "../../supabase/functions/_shared/model-catalog";
-import { expect, fake, stackReady, test } from "../full-stack/stack";
+import { chooseForMotion, expect, fake, stackReady, test } from "../full-stack/stack";
import { aiResponse, LIVE_TIMEOUT } from "../live/live";
// Every model a user can pick must generate a case. The regular live suite pins
@@ -16,6 +16,7 @@ for (const model of MODEL_CATALOG) {
await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free at the point of use");
const response = aiResponse(page, "generate");
await page.getByRole("button", { name: "Generate Debate Case" }).click();
+ await chooseForMotion(page, "once");
const body = await response;
expect(body.output.contentions).toHaveLength(3);
diff --git a/e2e/production/canary.spec.ts b/e2e/production/canary.spec.ts
index 0a0e19241..b036e8c15 100644
--- a/e2e/production/canary.spec.ts
+++ b/e2e/production/canary.spec.ts
@@ -115,6 +115,10 @@ test("the canary account generates a case", async ({ page }) => {
await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free");
const generated = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai") && response.request().method() === "POST", { timeout: 150_000 });
await page.getByRole("button", { name: "Generate Debate Case" }).click();
+ // Use the motion once so the canary account does not collect production debates.
+ await page.getByRole("dialog", { name: /^(Start a debate with this motion\?|This is a different motion)$/ })
+ .getByRole("button", { name: /^(Not now, use it once|Use once without saving to a debate)$/ })
+ .click();
const response = await generated;
expect(response.status(), "ai generate status").toBe(200);
const body = await response.json() as { output: { contentions?: unknown[] } };
diff --git a/e2e/public-pages.spec.ts b/e2e/public-pages.spec.ts
index de28d6681..44178595f 100644
--- a/e2e/public-pages.spec.ts
+++ b/e2e/public-pages.spec.ts
@@ -10,7 +10,7 @@ const PUBLIC_ROUTES = [
] as const;
for (const locale of LOCALES) {
- test.describe(`public pages [${locale}]`, () => {
+ test.describe(`public pages [${locale}]`, locale === 'en' ? { tag: '@smoke' } : {}, () => {
test.beforeEach(async ({ page }) => {
await setLocale(page, locale);
});
diff --git a/e2e/sparring.spec.ts b/e2e/sparring.spec.ts
index 97f0fe3cd..f3db31788 100644
--- a/e2e/sparring.spec.ts
+++ b/e2e/sparring.spec.ts
@@ -41,7 +41,7 @@ test.beforeEach(async ({ page }) => {
await stubSupabaseSession(page);
});
-test('completes an LD sparring round through mocked opponent turns and judging', async ({ page }) => {
+test('completes an LD sparring round through mocked opponent turns and judging', { tag: '@smoke' }, async ({ page }) => {
let responseNumber = 0;
const actions: string[] = [];
diff --git a/e2e/startup-shell.spec.ts b/e2e/startup-shell.spec.ts
index 997d7d47e..ad252945b 100644
--- a/e2e/startup-shell.spec.ts
+++ b/e2e/startup-shell.spec.ts
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { setLocale, stubSupabaseSession } from './helpers/auth';
-test('shows a dark private startup shell while the entry module is delayed', async ({ page }) => {
+test('shows a dark private startup shell while the entry module is delayed', { tag: '@smoke' }, async ({ page }) => {
await setLocale(page, 'en');
await page.addInitScript(() => {
const settings = JSON.parse(window.localStorage.getItem('debate-trainer-settings') || '{}');
@@ -24,7 +24,7 @@ test('shows a dark private startup shell while the entry module is delayed', asy
}))).toEqual({ appShell: true, dark: true });
});
-test('shows the startup shell on login while the entry module is delayed', async ({ page }) => {
+test('shows the startup shell on login while the entry module is delayed', { tag: '@smoke' }, async ({ page }) => {
await setLocale(page, 'en');
await page.route('**/src/bootstrap.ts*', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1500));
@@ -37,7 +37,7 @@ test('shows the startup shell on login while the entry module is delayed', async
await expect.poll(() => page.evaluate(() => document.documentElement.classList.contains('app-shell'))).toBe(true);
});
-test('mounts the app before Jam recorder modules resolve', async ({ page }) => {
+test('mounts the app before Jam recorder modules resolve', { tag: '@smoke' }, async ({ page }) => {
await setLocale(page, 'en');
await stubSupabaseSession(page);
await page.addInitScript(() => {
@@ -59,7 +59,7 @@ test('mounts the app before Jam recorder modules resolve', async ({ page }) => {
})).toBe(true);
});
-test('shows a recoverable message when the static main entry fails', async ({ page }) => {
+test('shows a recoverable message when the static main entry fails', { tag: '@smoke' }, async ({ page }) => {
await page.route('**/src/main.tsx*', (route) => route.fulfill({ status: 503, body: 'entry unavailable' }));
await page.goto('/app', { waitUntil: 'domcontentloaded' });
@@ -68,7 +68,7 @@ test('shows a recoverable message when the static main entry fails', async ({ pa
await expect(page.getByRole('button', { name: 'Reload' })).toBeEnabled();
});
-test('keeps the branded shell visible before a delayed public entry module', async ({ page }) => {
+test('keeps the branded shell visible before a delayed public entry module', { tag: '@smoke' }, async ({ page }) => {
await setLocale(page, 'en');
await page.route('**/src/bootstrap.ts*', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1500));
diff --git a/package.json b/package.json
index 93f19b7da..88a6baa46 100644
--- a/package.json
+++ b/package.json
@@ -27,10 +27,12 @@
"test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/",
"test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh",
"test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium",
+ "test:e2e:smoke": "playwright test --project=desktop-chromium --grep @smoke",
"test:e2e:webkit": "playwright test --project=webkit",
"test:sw": "node scripts/test-service-worker-offline.mjs",
"test:e2e:staging": "playwright test --project=staging",
- "verify": "npm run docs:check && npm run lint && npm run typecheck && npm test -- --coverage && npm run test:collaboration && npm run test:edge && npm run build && npm run test:sw && P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0.0.1:$P npm run test:e2e"
+ "verify": "npm run docs:check && npm run lint && npm run typecheck && npm test && npm run test:collaboration && npm run test:edge && npm run build && npm run test:sw && P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0.0.1:$P npm run test:e2e:smoke",
+ "verify:full": "npm run verify && P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0.0.1:$P npm run test:e2e"
},
"dependencies": {
"@capacitor/app": "^8.1.0",
diff --git a/plans/2026-10-01-ci-and-preview-verification.md b/plans/2026-10-01-ci-and-preview-verification.md
new file mode 100644
index 000000000..cdade9528
--- /dev/null
+++ b/plans/2026-10-01-ci-and-preview-verification.md
@@ -0,0 +1,70 @@
+# Leaner CI and two previews per PR
+
+**Status:** In progress 2026-10-03. Code for all three parts is in PR #95. Part 1's Vercel project and Supabase allowlists were set up on 2026-10-02; the Edge Function redeploy waits for the merge. Part 2 item 3's worker change is not done. Numbers come from GitHub Actions runs 36903797758 and 36903797787 (PR #83) and a read-only query of both Supabase projects' Auth settings.
+
+## Goal
+
+Every PR push should give a fast yes or no in a few minutes and two links Adam or an agent can click to try the change signed in. Slow and paid checks run only when the PR touches what they test.
+
+## Part 1. Two previews per PR
+
+Each PR gets a production preview and a staging preview, and both rebuild on every push. Vercel builds take about 40 seconds, and no Actions minutes are used.
+
+Why sign-in on a preview landed on the live site: the app asks Supabase to return to `/app`. Supabase only accepts return addresses on the project's redirect allowlist, and falls back to the Site URL otherwise. Production's allowlist has no `vercel.app` host and its Site URL is `https://debate.pi-stack.org`.
+
+Done in this branch:
+
+- `VITE_SUPABASE_ENV` (`production` or `staging`) pins a build to one project. A pinned build ignores the saved toggle and hides the switch controls. Documented in `docs/operations.md` under "Per-PR previews".
+
+- Edge Function CORS and the demo-code sign-in redirect now accept both projects' preview hosts (`isAllowedOrigin()` in `supabase/functions/_shared/cors.ts`). Before this, previews could not call any function, so demo-code sign-in and AI failed there too.
+
+Outside the repo. Items 1 to 3 were done on 2026-10-02 with backups in `~/backups/supabase-auth/`; item 0 follows the merge:
+
+0. Merge, then redeploy the Edge Functions on production and staging so the CORS change is live.
+
+1. Create a second Vercel project, `debatelab-staging`, on the same repository with `VITE_SUPABASE_ENV=staging` for Production and Preview. Its name must not start with `debate-navigator-`, or production's allowlist pattern below would match it too.
+2. Add `https://debate-navigator-*-adam-gaborjanis-projects.vercel.app/**` to production's redirect allowlist.
+3. Add `https://debatelab-staging-*-adam-gaborjanis-projects.vercel.app/**` and `https://debatelab-staging.vercel.app/**` to staging's allowlist, and change staging's Site URL from `http://localhost:8080` to `https://debatelab-staging.vercel.app`.
+
+Open items:
+
+- Google sign-in on staging works only if the Google Cloud OAuth client lists `https://brifeyypjauyusmkptkx.supabase.co/auth/v1/callback`. Unchecked. Demo-code and email-code sign-in don't need it.
+- Adam's staging account is not an admin until someone sets the role on staging.
+- A production preview runs unmerged frontend code with real production sessions. That is the accepted trade for seeing real data.
+- A staging preview of a PR that changes migrations or Edge Functions needs staging synced first.
+
+## Part 2. Fewer, faster CI jobs
+
+Today one PR push runs four jobs, about 62 runner-minutes and 26 minutes of wall time:
+
+- `quality`, 5 minutes: lint, typecheck, Vitest with coverage (3 minutes), edge tests, build.
+- `browser-tests`, 21 minutes, after `quality`: up to 5 minutes installing Chromium, then 16 minutes of Playwright against the Vite dev server, which compiles each lazy route on first visit.
+- `database-tests`, 19.5 minutes: 2 minutes starting Supabase, 26 seconds of pgTAP, 14.6 minutes of full-stack Playwright on one worker.
+- `Live provider E2E`, 17 minutes: a second Supabase stack, using the real OpenRouter and Exa keys on every push.
+
+Changes:
+
+1. **Live provider E2E** runs nightly, on demand, and on PRs that touch `supabase/functions/**`, `src/lib/ai/**`, `e2e/live/**` or the fake provider, as `model-matrix.yml` already does.
+2. **Browser tests** run in the same job as lint and build, with the Playwright browser cache. One job, no second `npm ci` or runner setup. Done in this branch. Serving the built app instead of the dev server was tried and dropped: locally it took 9.3 minutes against 9.6, and two specs instrument dev-server source and cannot run on a build.
+3. **Database gate** runs the full-stack specs on 2 or 3 workers. Each test already uses its own demo-code user; specs that change shared rows (`admin.spec.ts` announcements, billing, daily quota) stay serial. Then it runs only on PRs that touch `supabase/**`, `e2e/full-stack/**`, `src/lib/stores/**`, `src/lib/supabase/**` or `src/lib/ai/**`, and on every push to `main`.
+4. **Docs-only PRs** run `npm run docs:check` and nothing else. `main` has no required status checks, so skipped jobs don't block merges.
+
+Items 1, 2 and 4 are done in this branch. Item 3's path filter is done; the worker change is not, because it needs the database gate run to prove the shared-row specs stay serial.
+
+The browser suite itself is the remaining cost. Locally it is 1,059 seconds of test time over 2 workers, and every spec runs on both desktop and mobile Chromium. `authed-pages.spec.ts` (227 seconds) renders every page in several locales to check for runtime errors, and `navigation-performance`, `quality-backlog` and `animation-polish` add another 350 seconds. Running mobile Chromium only on specs tagged `@mobile` would roughly halve the suite. That cuts coverage, so it needs Adam's yes.
+
+Estimate for a frontend-only PR push after items 1 to 4: one job of about 15 minutes on CI, down from 26 minutes of wall time and about 62 runner-minutes. With the mobile cut, about 9 minutes. Estimates; measure on the first PR.
+
+## Part 3. Three-minute PR check
+
+Done in this branch, at Adam's request (2026-10-02):
+
+- Unit tests cut from 112 files (1,140 tests) to 15 files (150 tests, 4 seconds). The kept files guard security boundaries, Current Debate isolation, project selection, stream parsing, source import, the Crossfire and Sparring rules, and the db-gate cleanup. The coverage gate is gone.
+- Pull requests run 31 Playwright tests tagged `@smoke` on desktop Chromium (36 seconds locally) in a `browser-smoke` job beside `quality`. The full desktop and mobile suite (about 10 minutes locally, 16 on CI) runs nightly in `browser-nightly.yml`.
+- `npm run verify` uses the smoke set and takes about 2 minutes locally; `npm run verify:full` adds the full suite.
+
+Still slow: PRs that touch the backend also run the Database workflow (about 20 minutes), and PRs that touch AI code run Live provider E2E (about 17 minutes). Those run beside the 3-minute check rather than inside it.
+
+## Separate bug
+
+`Protected staging E2E` failed on `main` twice on 2026-10-01 (runs 36904139300 and 36907695367). `e2e/staging/authed-flows.spec.ts` times out on a click after 180 seconds on every retry. Not investigated.
diff --git a/plans/README.md b/plans/README.md
index 4ad4eea3f..4c1068380 100644
--- a/plans/README.md
+++ b/plans/README.md
@@ -11,6 +11,7 @@ Nothing is in progress.
## Backlog
- **Next 15.** Security and data protection, operational safety, code health, and product polish, ordered by risk. Items 3, 5, 6, 8 and 13 have shipped; the plan lists the follow-ups they left. Plan: [2026-10-01 next 15](2026-10-01-next-15.md). Item 15 covers the PR #63 decision.
+- **Leaner CI and two previews per PR.** Three-minute PR check, a staging Vercel project for per-PR staging previews, and Edge Function CORS for preview hosts. Plan: [2026-10-01 leaner CI and previews](2026-10-01-ci-and-preview-verification.md).
## On hold
diff --git a/src/lib/supabase/env.ts b/src/lib/supabase/env.ts
index da9f081b4..9e41b643f 100644
--- a/src/lib/supabase/env.ts
+++ b/src/lib/supabase/env.ts
@@ -9,6 +9,7 @@ type EnvironmentInput = {
VITE_LOCAL_SUPABASE_ANON_KEY?: string;
VITE_LOCAL_SUPABASE_URL?: string;
VITE_STAGING_PREVIEW?: string;
+ VITE_SUPABASE_ENV?: string;
};
type StorageReader = { getItem(key: string): string | null };
@@ -54,6 +55,16 @@ export function resolveSupabaseConfig(
};
}
+ // Hosted builds can pin one project; the staging Vercel project sets
+ // VITE_SUPABASE_ENV=staging so each PR gets a staging preview.
+ const pinned = environment.VITE_SUPABASE_ENV?.trim();
+ if (pinned) {
+ if (pinned !== "production" && pinned !== "staging") {
+ throw new Error(`Unknown VITE_SUPABASE_ENV: ${pinned}`);
+ }
+ return { ...ENVS[pinned], env: pinned, pinned: true };
+ }
+
return storage.getItem(ENV_STORAGE_KEY) === "staging"
? { ...ENVS.staging, env: "staging" }
: { ...ENVS.production, env: "production" };
diff --git a/src/test/ai/client-analytics.test.ts b/src/test/ai/client-analytics.test.ts
deleted file mode 100644
index 7bfc58072..000000000
--- a/src/test/ai/client-analytics.test.ts
+++ /dev/null
@@ -1,262 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import type { ProviderConfig } from '@/lib/ai/providers/types';
-import type { GenerateInput, GenerateOutput } from '@/lib/ai/schemas/generate';
-import type { CrossfireAIInput } from '@/lib/crossfire/schemas';
-
-const mocks = vi.hoisted(() => ({
- getSession: vi.fn(),
- invoke: vi.fn(),
- captureProductEvent: vi.fn(),
- captureException: vi.fn(),
- settings: { customSystemPrompt: undefined as string | undefined, outputLanguage: 'en' },
- session: {
- sourceLibrary: [] as Array<{ title: string; content: string; origin: string; type: string; isActive: boolean }>,
- },
-}));
-
-vi.mock('@/lib/observability', () => ({
- captureProductEvent: mocks.captureProductEvent,
- captureException: mocks.captureException,
-}));
-
-vi.mock('@/lib/supabase/client', () => ({
- getActiveAccessToken: (): string | null => null,
- invokeAuthenticatedFunction: mocks.invoke,
- supabase: {
- auth: { getSession: mocks.getSession },
- functions: { invoke: mocks.invoke },
- },
-}));
-
-vi.mock('@/lib/stores/settings-store', () => ({
- useSettingsStore: { getState: () => mocks.settings },
-}));
-
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: { getState: () => mocks.session },
-}));
-
-import {
- AIAuthError,
- AIError,
- AIModelRestrictionError,
- AIProviderError,
- AIRateLimitError,
- AIResponseError,
- AISubscriptionError,
- TimeoutError,
- classifyAIError,
- crossfire,
- generateCase,
-} from '@/lib/ai/client';
-
-const config: ProviderConfig = {
- provider: 'openai',
- model: 'gpt-4o',
- temperature: 0.7,
-};
-
-const input: GenerateInput = {
- motion: 'USER MOTION SHOULD NEVER APPEAR IN TELEMETRY',
- side: 'pro',
- format: 'bp',
- role: 'intro',
- length: 'brief',
- audience: 'beginner',
- tone: 'formal',
-};
-
-const output: GenerateOutput = {
- caseStructure: {
- thesis: 'A thesis',
- framework: 'A framework',
- keyDefinitions: [],
- burdenOfProof: 'A burden',
- },
- contentions: [
- { id: '1', claim: 'Claim one', reasoning: 'Reasoning one', evidence: 'Evidence one', needsSource: false, impact: 'Impact one' },
- { id: '2', claim: 'Claim two', reasoning: 'Reasoning two', evidence: 'Evidence two', needsSource: false, impact: 'Impact two' },
- ],
- predictedOpponentCase: { likelyArguments: [], strategicWeaknesses: [], keyClashes: [] },
- counters: [],
- metadata: { side: 'pro', format: 'bp', generatedAt: '2026-01-01T00:00:00.000Z' },
-};
-
-function nextTurnInput(): CrossfireAIInput {
- return {
- operation: 'next-turn' as const,
- config: {
- resolution: 'A resolution',
- argument: 'An argument',
- mode: 'question-ai' as const,
- style: 'cooperative' as const,
- durationSeconds: 180 as const,
- useSourceLibrary: false,
- seedQuestions: [] as string[],
- },
- transcript: [{
- id: 'T1',
- sequence: 1,
- speaker: 'user' as const,
- kind: 'question' as const,
- text: 'A question',
- createdAt: 1,
- }],
- expectedReply: 'answer' as const,
- };
-}
-
-describe('non-streaming AI request analytics', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- mocks.getSession.mockResolvedValue({ data: { session: { access_token: 'token' } } });
- mocks.invoke.mockResolvedValue({ data: { output }, error: null });
- });
-
- it('emits started and succeeded events with only safe request metadata', async () => {
- await generateCase(input, config);
-
- expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(1, 'ai_request_started', {
- feature: 'generate',
- provider: 'openrouter',
- model: 'gpt-4o',
- });
- expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_succeeded', {
- feature: 'generate',
- provider: 'openrouter',
- model: 'gpt-4o',
- durationMs: expect.any(Number),
- });
-
- for (const [, properties] of mocks.captureProductEvent.mock.calls) {
- expect(JSON.stringify(properties)).not.toContain(input.motion);
- }
- });
-
- it('reports the concrete model selected by an automatic route', async () => {
- mocks.invoke.mockResolvedValueOnce({
- data: {
- output,
- meta: { requestedModel: 'auto-fast', resolvedModel: 'z-ai/glm-5.3-flash' },
- },
- error: null,
- });
-
- await generateCase(input, { ...config, model: 'auto-fast' });
-
- expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_succeeded', {
- feature: 'generate',
- provider: 'openrouter',
- model: 'z-ai/glm-5.3-flash',
- requestedModel: 'auto-fast',
- durationMs: expect.any(Number),
- });
- });
-
- it('emits a classified failure and captures provider exceptions without user content', async () => {
- mocks.invoke.mockResolvedValueOnce({ data: { error: 'OpenAI API unavailable' }, error: null });
-
- await expect(generateCase(input, config)).rejects.toMatchObject({ name: 'AIProviderError' });
-
- expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_failed', {
- feature: 'generate',
- provider: 'openrouter',
- model: 'gpt-4o',
- durationMs: expect.any(Number),
- errorName: 'AIProviderError',
- statusCode: undefined,
- });
- expect(mocks.captureException).toHaveBeenCalledWith(
- expect.objectContaining({ name: 'AIProviderError', message: 'AIProviderError' }),
- expect.objectContaining({ feature: 'generate', provider: 'openrouter', model: 'gpt-4o' }),
- );
- const [capturedError, capturedContext] = mocks.captureException.mock.calls[0];
- expect(capturedError).toMatchObject({ message: 'AIProviderError' });
- expect(JSON.stringify(capturedContext)).not.toContain(input.motion);
- });
-
- it('skips Sentry for expected auth failures', async () => {
- mocks.getSession.mockResolvedValueOnce({ data: { session: null } });
-
- await expect(generateCase(input, config)).rejects.toBeInstanceOf(AIAuthError);
- expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({
- errorName: 'AIAuthError',
- }));
- expect(mocks.captureException).not.toHaveBeenCalled();
- });
-
- it('reports malformed responses as AIResponseError failures', async () => {
- mocks.invoke.mockResolvedValueOnce({ data: { requestId: 'request-id-only' }, error: null });
-
- await expect(generateCase(input, config)).rejects.toBeInstanceOf(AIResponseError);
- expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({
- errorName: 'AIResponseError',
- }));
- expect(mocks.captureException).toHaveBeenCalledTimes(1);
- });
-
- it('does not count a non-empty malformed output as a successful request', async () => {
- mocks.invoke.mockResolvedValueOnce({ data: { output: {} }, error: null });
-
- await expect(generateCase(input, config)).rejects.toBeInstanceOf(AIResponseError);
- expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(1, 'ai_request_started', expect.objectContaining({
- feature: 'generate',
- }));
- expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({
- feature: 'generate',
- errorName: 'AIResponseError',
- }));
- expect(mocks.captureProductEvent).not.toHaveBeenCalledWith(
- 'ai_request_succeeded',
- expect.anything(),
- );
- expect(mocks.captureException).toHaveBeenCalledTimes(1);
- });
-
- it('reports unknown failures after the client wraps them', async () => {
- mocks.invoke.mockRejectedValueOnce(new Error(input.motion));
-
- await expect(generateCase(input, config)).rejects.toMatchObject({ name: 'AIError' });
- expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({
- errorName: 'AIError',
- }));
- expect(mocks.captureException).toHaveBeenCalledTimes(1);
- const [capturedError, capturedContext] = mocks.captureException.mock.calls[0];
- expect(capturedError).toMatchObject({ message: 'AIError' });
- expect(JSON.stringify(capturedContext)).not.toContain(input.motion);
- });
-
- it('does not send caller aborts to Sentry', async () => {
- const controller = new AbortController();
- controller.abort();
-
- await expect(crossfire(nextTurnInput(), config, { signal: controller.signal }))
- .rejects.toMatchObject({ name: 'AbortError' });
- expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({
- errorName: 'AbortError',
- }));
- expect(mocks.captureException).not.toHaveBeenCalled();
- });
-});
-
-describe('classifyAIError', () => {
- it.each([
- [new AIAuthError('auth', { statusCode: 401 }), 'AIAuthError', false, 401],
- [new AIRateLimitError('rate'), 'AIRateLimitError', false, 429],
- [new AISubscriptionError('subscription'), 'AISubscriptionError', false, 402],
- [new AIModelRestrictionError('upgrade'), 'AIModelRestrictionError', false, 403],
- [new TimeoutError('timeout'), 'TimeoutError', true, 408],
- [new AIProviderError('provider', { statusCode: 503 }), 'AIProviderError', true, 503],
- [new AIResponseError('response'), 'AIResponseError', true, undefined],
- [new AIError('unknown', { statusCode: 500 }), 'AIError', true, 500],
- ])('classifies %s', (error, errorName, shouldCaptureException, statusCode) => {
- expect(classifyAIError(error)).toEqual({ errorName, statusCode, shouldCaptureException });
- });
-
- it('suppresses Sentry for an explicitly user-initiated abort', () => {
- expect(classifyAIError(new Error('canceled'), true)).toEqual({
- errorName: 'AbortError',
- shouldCaptureException: false,
- });
- });
-});
diff --git a/src/test/ai/client-branches.test.ts b/src/test/ai/client-branches.test.ts
deleted file mode 100644
index fa4a46b80..000000000
--- a/src/test/ai/client-branches.test.ts
+++ /dev/null
@@ -1,961 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { seedCurrentDebate } from '@/test/helpers/debate';
-import type { ProviderConfig } from '@/lib/ai/providers/types';
-import type { WorkspaceToolContext } from '@/lib/workspace/tool-context';
-import type { GenerateInput, GenerateOutput } from '@/lib/ai/schemas/generate';
-
-const {
- getSessionMock,
- invokeMock,
- addSourceMock,
- sessionState,
- settingsState,
-} = vi.hoisted(() => {
- const getSessionMock = vi.fn();
- const invokeMock = vi.fn();
- const addSourceMock = vi.fn();
- const applySourceAssessmentsMock = vi.fn();
- const sessionState = {
- sourceLibrary: [
- { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', title: 'Active source', content: 'Useful evidence', origin: 'https://example.com', type: 'url', isActive: true, debateId: '11111111-2222-4333-8444-555555555555' },
- { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', title: 'Inactive source', content: 'Ignore me', origin: 'https://example.com/2', type: 'url', isActive: false, debateId: '11111111-2222-4333-8444-555555555555' },
- ],
- addSource: addSourceMock,
- applySourceAssessments: applySourceAssessmentsMock,
- };
- const settingsState = {
- customSystemPrompt: 'custom-system-prompt',
- };
-
- return { getSessionMock, invokeMock, addSourceMock, sessionState, settingsState };
-});
-
-vi.mock('@/lib/supabase/client', () => ({
- getActiveAccessToken: (): string | null => null,
- invokeAuthenticatedFunction: invokeMock,
- supabase: {
- auth: {
- getSession: getSessionMock,
- },
- functions: {
- invoke: invokeMock,
- },
- },
-}));
-
-vi.mock('@/lib/stores/settings-store', () => ({
- useSettingsStore: {
- getState: vi.fn(() => settingsState),
- },
-}));
-
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: {
- getState: vi.fn(() => sessionState),
- },
-}));
-
-import {
- AIAuthError,
- AIError,
- AIModelRestrictionError,
- AIProviderError,
- AIRateLimitError,
- AIResponseError,
- AISubscriptionError,
- TimeoutError,
- attackCase,
- askChat,
- brainstorm,
- factoids,
- generateCase,
- isAIAuthError,
- isAIModelRestrictionError,
- isAIProviderError,
- isAIRateLimitError,
- isAISubscriptionError,
- isTimeoutError,
-} from '@/lib/ai/client';
-
-const config: ProviderConfig = {
- provider: 'openai',
- model: 'gpt-4o',
- temperature: 0.6,
-};
-
-const validGenerateInput: GenerateInput = {
- motion: 'This house would adopt universal basic income',
- side: 'pro',
- format: 'bp',
- role: 'intro',
- length: 'brief',
- audience: 'beginner',
- tone: 'formal',
-};
-
-const validGenerateOutput: GenerateOutput = {
- caseStructure: {
- thesis: 'Universal basic income improves economic resilience',
- framework: 'Welfare economics',
- keyDefinitions: ['universal basic income'],
- burdenOfProof: 'Prove net societal benefits',
- },
- contentions: [
- { id: '1', claim: 'First argument', reasoning: 'Reasoning', evidence: 'Evidence', needsSource: false, sourceQuery: '', impact: 'Impact' },
- { id: '2', claim: 'Second argument', reasoning: 'Reasoning', evidence: 'Evidence', needsSource: false, sourceQuery: '', impact: 'Impact' },
- ],
- predictedOpponentCase: {
- likelyArguments: [],
- strategicWeaknesses: [],
- keyClashes: [],
- },
- counters: [],
- metadata: {
- side: 'pro',
- format: 'bp',
- generatedAt: '2026-01-01T00:00:00.000Z',
- },
-};
-
-describe('AI client branch coverage', () => {
- beforeEach(() => {
- seedCurrentDebate();
- vi.clearAllMocks();
- sessionState.sourceLibrary = [
- { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', title: 'Active source', content: 'Useful evidence', origin: 'https://example.com', type: 'url', isActive: true, debateId: '11111111-2222-4333-8444-555555555555' },
- { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', title: 'Inactive source', content: 'Ignore me', origin: 'https://example.com/2', type: 'url', isActive: false, debateId: '11111111-2222-4333-8444-555555555555' },
- ];
- getSessionMock.mockResolvedValue({
- data: { session: { access_token: 'token-123' } },
- });
- sessionState.addSource.mockResolvedValue(undefined);
- });
-
- it('throws AISubscriptionError for trial-expired response payload', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- error: 'Trial ended',
- code: 'trial_expired',
- limit: 5,
- current: 5,
- },
- error: null,
- });
-
- try {
- await generateCase(validGenerateInput, config);
- throw new Error('Expected generateCase to throw');
- } catch (error) {
- expect(error).toBeInstanceOf(AISubscriptionError);
- expect(error).toMatchObject({ code: 'trial_expired', limit: 5, current: 5 });
- }
- });
-
- it('throws AIModelRestrictionError for upgrade_required responses', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- error: 'This model requires upgrade',
- code: 'upgrade_required',
- },
- error: null,
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIModelRestrictionError);
- });
-
- it('throws AIRateLimitError when response indicates rate limits', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- error: 'rate limit exceeded',
- limit: 10,
- current: 10,
- },
- error: null,
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIRateLimitError);
- });
-
- it('throws AIProviderError when provider error text is returned', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- error: 'OpenAI API unavailable',
- },
- error: null,
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIProviderError);
- });
-
- it('throws AIProviderError when provider error code is returned without provider text', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- error: 'You exceeded your current quota',
- code: 'insufficient_quota',
- },
- error: null,
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIProviderError);
- });
-
- it('throws AIResponseError when output is missing', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- requestId: 'abc',
- },
- error: null,
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIResponseError);
- });
-
- it('transforms non-schema generate output into required structure', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output: {
- title: 'Fallback Thesis',
- side: 'con',
- contentions: [
- {
- title: 'Cost burden',
- analysis: 'Costs increase quickly',
- evidence: ['OECD report'],
- },
- {
- title: 'Implementation risk',
- analysis: 'Implementation takes planning',
- evidence: ['Pilot programs'],
- },
- ],
- rebuttals: [
- {
- attack: 'It is too expensive',
- response: 'Long-term gains offset initial costs',
- },
- ],
- potentialWeaknesses: ['Funding complexity'],
- },
- },
- error: null,
- });
-
- const { result } = await generateCase(validGenerateInput, config);
-
- expect(result.caseStructure.thesis).toBe('Fallback Thesis');
- expect(result.metadata.side).toBe('con');
- expect(result.contentions[0].claim).toBe('Cost burden');
- expect(result.contentions[0].evidence).toContain('OECD report');
- expect(result.counters[0].targetArgument).toBe('It is too expensive');
- expect(result.predictedOpponentCase.strategicWeaknesses).toContain('Funding complexity');
- });
-
- it('stores discovered sources for generate responses', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output: validGenerateOutput,
- discoveredSources: [
- { title: 'Source A', url: 'https://a.com', content: 'A content', qualityLabel: 'strong' },
- { title: 'Source B', url: 'https://b.com', content: 'B content' },
- ],
- },
- error: null,
- });
-
- await generateCase(validGenerateInput, config);
-
- expect(addSourceMock).toHaveBeenCalledTimes(2);
- expect(addSourceMock).toHaveBeenCalledWith({
- title: 'Source A',
- content: 'A content',
- type: 'url',
- origin: 'https://a.com/',
- folder: 'Auto-saved',
- isActive: true, debateId: '11111111-2222-4333-8444-555555555555',
- metadata: expect.objectContaining({ capturedVia: 'generate', url: 'https://a.com/' }),
- });
- });
-
- it('sends stable source IDs and applies returned semantic assessments', async () => {
- const sourceId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
- sessionState.sourceLibrary[0] = { ...sessionState.sourceLibrary[0], id: sourceId, metadata: { qualityLabel: 'strong' } } as any;
- invokeMock.mockResolvedValueOnce({
- data: {
- output: validGenerateOutput,
- sourceAssessments: [{ key: sourceId, id: sourceId, relevance: 0.9, importance: 3, strength: 2, evaluatedAt: '2026-09-21T00:00:00.000Z', retained: true, usedInCase: true }],
- },
- error: null,
- });
-
- await generateCase(validGenerateInput, config);
- expect(invokeMock).toHaveBeenCalledWith('ai', expect.objectContaining({ body: expect.objectContaining({ sourceIds: [sourceId] }) }));
- expect(sessionState.applySourceAssessments).toHaveBeenCalledWith([expect.objectContaining({ id: sourceId, usedInCase: true })]);
- });
-
- it('keeps workspace research out of the personal debate', async () => {
- invokeMock.mockResolvedValueOnce({
- data: { output: validGenerateOutput, discoveredSources: [{ title: 'Source A', url: 'https://a.com', content: 'A content', qualityLabel: 'strong' }] },
- error: null,
- });
- const workspaceContext: WorkspaceToolContext = {
- documentEpoch: 1,
- documentId: '11111111-1111-4111-8111-111111111111',
- documentIds: ['11111111-1111-4111-8111-111111111111'],
- documentText: 'Accepted text',
- documentTitle: 'R1',
- motion: 'Motion',
- requestId: '22222222-2222-4222-8222-222222222222',
- returnUrl: '/app/workspaces/33333333-3333-4333-8333-333333333333',
- side: 'pro' as const,
- sourceIds: [],
- threadRequest: null,
- threadId: null,
- workspaceId: '33333333-3333-4333-8333-333333333333',
- };
- await generateCase(validGenerateInput, config, workspaceContext);
- expect(addSourceMock).not.toHaveBeenCalled();
- });
-
- it('does not autosave a semantically irrelevant discovered source', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output: validGenerateOutput,
- discoveredSources: [{ title: 'Irrelevant', url: 'https://example.com/irrelevant', qualityLabel: 'strong', semantic: { relevance: 0.2, importance: 0, strength: 2, evaluatedAt: '2026-09-21T00:00:00.000Z', useCount: 1, caseUseCount: 0, lastUsedInCase: false } }],
- },
- error: null,
- });
- await generateCase(validGenerateInput, config);
- expect(addSourceMock).not.toHaveBeenCalled();
- });
-
- it('rejects malformed nested direct-format generate output before saving sources', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output: {
- ...validGenerateOutput,
- caseStructure: {
- ...validGenerateOutput.caseStructure,
- keyDefinitions: [123],
- },
- },
- discoveredSources: [{ title: 'Untrusted source', url: 'https://example.com/untrusted' }],
- },
- error: null,
- });
-
- try {
- await generateCase(validGenerateInput, config);
- throw new Error('Expected generateCase to throw');
- } catch (error) {
- expect(error).toBeInstanceOf(AIResponseError);
- expect(error).toMatchObject({
- details: {
- issues: expect.arrayContaining([
- expect.objectContaining({ path: ['caseStructure', 'keyDefinitions', 0] }),
- ]),
- },
- });
- }
- expect(addSourceMock).not.toHaveBeenCalled();
- });
-
- it('drops invalid optional source metadata at the response boundary', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output: validGenerateOutput,
- discoveredSources: [
- {
- title: 'Source with malformed metadata',
- url: ' https://example.com/source ',
- content: 123,
- sourceType: { nested: true },
- qualityScore: 'high',
- qualityLabel: 'unknown',
- query: 42,
- },
- { title: 'Protocol-less source', url: 'example.com/source-two' },
- { title: '', url: 'https://example.com/rejected' },
- { title: 'Unsafe protocol', url: 'httpx://example.com/rejected' },
- ],
- },
- error: null,
- });
-
- const outcome = await generateCase(validGenerateInput, config);
-
- expect(outcome.discoveredSources).toEqual([
- { title: 'Source with malformed metadata', url: 'https://example.com/source' },
- { title: 'Protocol-less source', url: 'https://example.com/source-two' },
- ]);
- expect(addSourceMock).toHaveBeenCalledWith(expect.objectContaining({
- title: 'Source with malformed metadata',
- origin: 'https://example.com/source',
- isActive: false, debateId: '11111111-2222-4333-8444-555555555555',
- metadata: { url: 'https://example.com/source', capturedVia: 'generate' },
- }));
- });
-
- it('saves weak discovered sources as inactive', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output: validGenerateOutput,
- discoveredSources: [{
- title: 'Weak fallback',
- url: 'https://example.com/fallback',
- qualityLabel: 'weak',
- qualityScore: 20,
- }],
- },
- error: null,
- });
-
- await generateCase(validGenerateInput, config);
-
- expect(addSourceMock).toHaveBeenCalledWith(expect.objectContaining({
- title: 'Weak fallback',
- isActive: false, debateId: '11111111-2222-4333-8444-555555555555',
- metadata: expect.objectContaining({ qualityLabel: 'weak', qualityScore: 20 }),
- }));
- });
-
- it('validates attack output before saving discovered sources', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output: { overallAssessment: { score: 80 } },
- discoveredSources: [{ title: 'Untrusted source', url: 'https://example.com/untrusted' }],
- },
- error: null,
- });
-
- await expect(attackCase({ caseText: 'Case text' } as any, config)).rejects.toBeInstanceOf(AIResponseError);
- expect(addSourceMock).not.toHaveBeenCalled();
- });
-
- it('normalizes brainstorm output for topFive, mindmap, and buckets', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output: {
- needsMoreInfo: 1,
- clarifyingQuestions: ['What audience?'],
- nextSteps: ['Pick strongest frame'],
- warningsAndTradeoffs: ['May trigger equity concerns'],
- topFive: [
- {
- rank: 1,
- idea: { title: 'Economic stability', side: 'pro', strength: 'strong' },
- whyTop: 'Most pragmatic',
- },
- null,
- ],
- mindmap: [
- {
- category: 'Economy',
- items: [{ title: 'Inflation risk', side: 'con' }],
- },
- ],
- buckets: [
- {
- name: 'Implementation',
- ideas: [{ title: 'Phased rollout', side: 'pro', tradeoffs: ['slower impact'] }],
- keyInsight: 'Start small',
- },
- ],
- },
- },
- error: null,
- });
-
- const { result } = await brainstorm(
- { motion: 'UBI', side: 'both', outputMode: 'topfive' },
- config,
- );
-
- expect(result.needsMoreInfo).toBe(true);
- expect(result.topFive).toHaveLength(1);
- expect(result.topFive?.[0].idea.title).toBe('Economic stability');
- expect(result.mindmap?.[0].items[0].title).toBe('Inflation risk');
- expect(result.buckets?.[0].ideas[0].title).toBe('Phased rollout');
- });
-
- it('stores discovered sources for brainstorm responses', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output: { needsMoreInfo: false, clarifyingQuestions: [], nextSteps: [], warningsAndTradeoffs: [] },
- discoveredSources: [{ title: 'Source C', url: 'https://c.com', content: 'C content', qualityLabel: 'strong' }],
- },
- error: null,
- });
-
- await brainstorm({ motion: 'Climate policy', side: 'pro', outputMode: 'mindmap' }, config);
-
- expect(addSourceMock).toHaveBeenCalledWith({
- title: 'Source C',
- content: 'C content',
- type: 'url',
- origin: 'https://c.com/',
- folder: 'Auto-saved',
- isActive: true, debateId: '11111111-2222-4333-8444-555555555555',
- metadata: expect.objectContaining({ capturedVia: 'brainstorm', url: 'https://c.com/' }),
- });
- });
-
- it('sends ask options defaults as false', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output: {
- response: 'Use clear weighing criteria.',
- followUpQuestions: [],
- uncertainties: [],
- },
- },
- error: null,
- });
-
- await askChat('How do I weigh impacts?', [], 'general', config);
-
- expect(invokeMock).toHaveBeenCalledWith('ai', expect.objectContaining({
- body: expect.objectContaining({
- feature: 'ask',
- input: expect.objectContaining({
- citationMode: false,
- webSearch: false,
- prosConsMode: false,
- argumentMapMode: false,
- }),
- }),
- }));
- });
-
- it('validates Ask responses before returning them', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output: {
- response: 42,
- followUpQuestions: [],
- uncertainties: [],
- },
- },
- error: null,
- });
-
- await expect(askChat('How do I weigh impacts?', [], 'general', config)).rejects.toBeInstanceOf(AIResponseError);
- });
-
- it('counts factoids removed by the source limit', async () => {
- const factoid = (id: string) => ({
- id,
- shortClaim: `Claim ${id}`,
- keyFigure: '50%',
- context: 'Context',
- limitations: 'Limitations',
- citation: { title: 'Source', publisher: 'Publisher', verified: 'verified' },
- howToUse: 'Use it',
- relevanceScore: 80,
- });
- invokeMock.mockResolvedValueOnce({
- data: {
- output: {
- factoids: [factoid('1'), factoid('2'), factoid('3'), factoid('4')],
- searchSuggestions: [],
- warnings: [],
- verificationStatus: 'complete',
- },
- },
- error: null,
- });
-
- const result = await factoids({ claim: 'Claim', direction: 'for', sourceType: 'any', sourceCount: 3 }, config);
-
- expect(result.result.factoids).toHaveLength(3);
- expect(result.result.filteredOutCount).toBe(1);
- });
-
- it('returns and autosaves discovered sources for factoids responses', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output: {
- factoids: [],
- searchSuggestions: [],
- warnings: [],
- verificationStatus: 'complete',
- },
- discoveredSources: [{
- title: 'Factoids source',
- url: 'https://example.com/factoids',
- content: 'Evidence content',
- sourceType: 'government',
- qualityLabel: 'moderate',
- qualityScore: 70,
- query: 'factoids query',
- }],
- },
- error: null,
- });
-
- const outcome = await factoids({ claim: 'Claim', direction: 'for', sourceType: 'any', sourceCount: 3 }, config);
-
- expect(outcome.discoveredSources).toEqual([{
- title: 'Factoids source',
- url: 'https://example.com/factoids',
- content: 'Evidence content',
- sourceType: 'government',
- qualityLabel: 'moderate',
- qualityScore: 70,
- query: 'factoids query',
- }]);
- expect(addSourceMock).toHaveBeenCalledWith(expect.objectContaining({
- title: 'Factoids source',
- origin: 'https://example.com/factoids',
- isActive: true, debateId: '11111111-2222-4333-8444-555555555555',
- metadata: expect.objectContaining({ capturedVia: 'factoids', qualityLabel: 'moderate' }),
- }));
- });
-
- it('maps invoke errors with 401 status to AIAuthError', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code',
- context: {
- status: 401,
- clone: () => ({
- json: async () => ({ error: 'Unauthorized' }),
- }),
- },
- },
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIAuthError);
- });
-
- it('maps invoke errors with 403 + upgrade_required payload to AIModelRestrictionError', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code',
- context: {
- status: 403,
- clone: () => ({
- json: async () => ({ error: 'Upgrade required', code: 'upgrade_required' }),
- }),
- },
- },
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIModelRestrictionError);
- });
-
- it('maps invoke errors with message-pattern 429 to AIRateLimitError', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code 429 rate limit',
- context: {
- clone: () => ({
- json: async () => {
- throw new Error('not json');
- },
- }),
- },
- },
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIRateLimitError);
- });
-
- it('maps invoke errors with 402 payload to AISubscriptionError', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code',
- context: {
- status: 402,
- json: async () => ({ error: 'Plan required', code: 'limit_reached', limit: 5, current: 6 }),
- },
- },
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toMatchObject({
- name: 'AISubscriptionError',
- code: 'limit_reached',
- limit: 5,
- current: 6,
- });
- });
-
- it('maps invoke errors with 403 forbidden to AIAuthError when not upgrade-related', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Forbidden',
- context: {
- status: 403,
- json: async () => ({ error: 'Forbidden' }),
- },
- },
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIAuthError);
- });
-
- it('maps invoke errors with status-code message hints when status is absent', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code 500 Internal',
- context: {
- json: async () => ({ error: 'Internal failure' }),
- },
- },
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIError);
- });
-
- it('maps 402 message hints without status to AISubscriptionError', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code 402',
- context: {
- json: async () => ({ detail: 'Subscription required' }),
- },
- },
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AISubscriptionError);
- });
-
- it('maps 403 message hints without status to AIAuthError for non-upgrade text', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code 403 Forbidden',
- context: {
- json: async () => ({ detail: 'Forbidden' }),
- },
- },
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIAuthError);
- });
-
- it('handles invoke errors where cloned payload has no json parser', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code 500 Internal',
- context: {
- clone: () => ({}),
- },
- },
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIError);
- });
-
- it('handles invoke errors where payload json is non-object', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code 500 Internal',
- context: {
- json: async () => 'not an object',
- },
- },
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIError);
- });
-
- it('maps unknown invoke errors to generic AIError with helpful prefix', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code',
- context: {
- status: 500,
- clone: () => ({
- json: async () => ({ error: 'Internal server error' }),
- }),
- },
- },
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toThrow('AI service error: Internal server error');
- });
-
- it('wraps unknown thrown errors in AIError', async () => {
- invokeMock.mockRejectedValueOnce(new Error('network down'));
-
- await expect(generateCase(validGenerateInput, config)).rejects.toMatchObject({
- name: 'AIError',
- message: 'network down',
- });
- });
-
- it('wraps non-Error thrown values in AIError with unknown message', async () => {
- invokeMock.mockRejectedValueOnce('fatal');
-
- await expect(generateCase(validGenerateInput, config)).rejects.toMatchObject({
- name: 'AIError',
- message: 'Unknown error occurred',
- });
- });
-
- it('throws TimeoutError when invoke never resolves', async () => {
- // Simulate the abort error that occurs when AbortController times out
- invokeMock.mockRejectedValueOnce(new DOMException('The operation was aborted', 'AbortError'));
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(TimeoutError);
- });
-
- it('fails when there is no authenticated session token', async () => {
- getSessionMock.mockResolvedValueOnce({ data: { session: null } });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIAuthError);
- });
-
- it('type guards detect expected AI error classes', () => {
- expect(isAIAuthError(new AIAuthError('auth'))).toBe(true);
- expect(isAIRateLimitError(new AIRateLimitError('rate'))).toBe(true);
- expect(isAIProviderError(new AIProviderError('provider'))).toBe(true);
- expect(isAISubscriptionError(new AISubscriptionError('subscription'))).toBe(true);
- expect(isAIModelRestrictionError(new AIModelRestrictionError('upgrade'))).toBe(true);
- expect(isTimeoutError(new TimeoutError('timeout'))).toBe(true);
-
- const authByName = new Error('x');
- authByName.name = 'AIAuthError';
- const rateByName = new Error('x');
- rateByName.name = 'AIRateLimitError';
- const providerByName = new Error('x');
- providerByName.name = 'AIProviderError';
- const subByName = new Error('x');
- subByName.name = 'AISubscriptionError';
- const modelByName = new Error('x');
- modelByName.name = 'AIModelRestrictionError';
- const timeoutByName = new Error('x');
- timeoutByName.name = 'TimeoutError';
-
- expect(isAIAuthError(authByName)).toBe(true);
- expect(isAIRateLimitError(rateByName)).toBe(true);
- expect(isAIProviderError(providerByName)).toBe(true);
- expect(isAISubscriptionError(subByName)).toBe(true);
- expect(isAIModelRestrictionError(modelByName)).toBe(true);
- expect(isTimeoutError(timeoutByName)).toBe(true);
-
- expect(isAIAuthError(new Error('plain'))).toBe(false);
- expect(isAIRateLimitError({ name: 'AIRateLimitError' })).toBe(false);
- expect(isTimeoutError({ name: 'TimeoutError' })).toBe(false);
- });
-
- it('passes only active sources and the OpenRouter provider in invoke payload', async () => {
- invokeMock.mockResolvedValueOnce({ data: { output: validGenerateOutput }, error: null });
-
- await generateCase(validGenerateInput, { ...config, provider: 'lovable' });
-
- expect(invokeMock).toHaveBeenCalledWith('ai', expect.objectContaining({
- body: expect.objectContaining({
- provider: 'openrouter',
- sourceIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
- debateContext: expect.objectContaining({ sourceIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'] }),
- }),
- }));
- // The retired per-user custom prompt never leaves the browser.
- expect(invokeMock.mock.calls[0][1].body).not.toHaveProperty('customSystemPrompt');
- });
-
- it('never sends another debate\'s sources', async () => {
- sessionState.sourceLibrary.push({ id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', title: 'Zoo source', content: 'Other debate', origin: 'https://zoo.example', type: 'url', isActive: true, debateId: 'other-debate' } as any);
- invokeMock.mockResolvedValueOnce({ data: { output: validGenerateOutput }, error: null });
-
- await generateCase(validGenerateInput, config);
-
- expect(invokeMock.mock.calls[0][1].body.sourceIds).toEqual(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa']);
- });
-
- it('uses no debate sources and keeps no research for one-off work', async () => {
- invokeMock.mockResolvedValueOnce({
- data: { output: validGenerateOutput, discoveredSources: [{ title: 'Source A', url: 'https://a.com', content: 'A content', qualityLabel: 'strong' }] },
- error: null,
- });
-
- await generateCase(validGenerateInput, config, undefined, null);
-
- expect(invokeMock.mock.calls[0][1].body.sourceIds).toEqual([]);
- expect(addSourceMock).not.toHaveBeenCalled();
- });
-
- it('throws generic AIError for edge payload errors that are not classified', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- error: 'Unexpected downstream failure',
- },
- error: null,
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIError);
- });
-
- it.each([
- ['empty', { title: 'Fallback title only' }],
- ['short', { title: 'Fallback title', contentions: [{ title: 'Only argument' }] }],
- ])('rejects %s malformed generate output instead of fabricating contentions', async (_label, output) => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output,
- },
- error: null,
- });
-
- await expect(generateCase(validGenerateInput, config)).rejects.toBeInstanceOf(AIResponseError);
- });
-
- it('normalizes partially empty generate contentions and rebuttals', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output: {
- thesis: 'Partial thesis',
- contentions: [
- { points: ['p1', 'p2'], evidence: [], analysis: undefined },
- { points: ['p3'], evidence: [], analysis: undefined },
- ],
- rebuttals: [{}],
- potentialWeaknesses: [],
- },
- },
- error: null,
- });
-
- const { result } = await generateCase(validGenerateInput, config);
-
- expect(result.contentions[0].claim).toBe('Argument');
- expect(result.contentions[0].reasoning).toContain('p1 p2');
- expect(result.contentions[0].evidence).toBe('');
- expect(result.contentions[0].impact).toBe('');
- expect(result.counters[0].targetArgument).toBe('');
- expect(result.counters[0].counterpoint).toBe('');
- expect(result.counters[0].rebuttal).toBe('');
- });
-
- it('normalizes brainstorm ideas with invalid side/strength and missing arrays', async () => {
- invokeMock.mockResolvedValueOnce({
- data: {
- output: {
- topFive: [{ idea: { id: 12, title: null, side: 'invalid', strength: 'invalid' } }],
- mindmap: [{ items: [{ title: 'Map node', side: 'invalid', strength: 'invalid' }] }],
- buckets: [{ ideas: [{ title: 'Bucket idea', side: 'invalid', strength: 'invalid' }] }],
- },
- },
- error: null,
- });
-
- const { result } = await brainstorm({ motion: 'M', side: 'both', outputMode: 'mindmap' }, config);
-
- expect(result.topFive?.[0].idea.side).toBe('neutral');
- expect(result.topFive?.[0].idea.strength).toBe('moderate');
- expect(result.mindmap?.[0].category).toBe('Uncategorized');
- expect(result.buckets?.[0].name).toBe('Unnamed Bucket');
- });
-});
diff --git a/src/test/ai/client.test.ts b/src/test/ai/client.test.ts
deleted file mode 100644
index 470bfb0aa..000000000
--- a/src/test/ai/client.test.ts
+++ /dev/null
@@ -1,506 +0,0 @@
-
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { supabaseMock, setMockInvokeResponse, resetMockState, setMockSessionToken } from '../mocks/supabase';
-import { generateCase, attackCase, brainstorm, factoids, askChat, AIError, AIResponseError } from '@/lib/ai/client';
-import type { ProviderConfig } from '@/lib/ai/providers/types';
-import type { GenerateInput, GenerateOutput } from '@/lib/ai/schemas/generate';
-import type { AttackInput, AttackOutput } from '@/lib/ai/schemas/attack';
-import type { BrainstormInput } from '@/lib/ai/schemas/brainstorm';
-import i18n from '@/i18n';
-import { seedCurrentDebate } from '@/test/helpers/debate';
-import type { WorkspaceToolContext } from '@/lib/workspace/tool-context';
-
-const clientMocks = vi.hoisted(() => ({
- toast: vi.fn(),
- settings: {
- customSystemPrompt: 'test-system-prompt',
- language: 'en',
- outputLanguage: 'en',
- },
- session: {
- sourceLibrary: [
- { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', title: 'Source 1', content: 'Content 1', origin: 'url1', type: 'web', isActive: true, debateId: '11111111-2222-4333-8444-555555555555' },
- { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', title: 'Source 2', content: 'Content 2', origin: 'url2', type: 'web', isActive: false, debateId: '11111111-2222-4333-8444-555555555555' },
- ],
- addSource: vi.fn(),
- },
-}));
-
-// Mock Supabase client - Importing the mock object directly
-// We must use vi.hoisted or just inline for variables used in vi.mock
-// But for imported modules like supabaseMock, valid in Vitest if module has no side effects?
-// actually it's better to use doMock or inline return.
-
-vi.mock('@/lib/supabase/client', () => ({
- getActiveAccessToken: (): string | null => null,
- invokeAuthenticatedFunction: supabaseMock.functions.invoke,
- supabase: supabaseMock
-}));
-
-// Mock Stores - Inline the implementation to avoid ReferenceError
-vi.mock('@/lib/stores/settings-store', () => ({
- useSettingsStore: {
- getState: vi.fn(() => clientMocks.settings),
- },
-}));
-
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: {
- getState: vi.fn(() => clientMocks.session),
- },
-}));
-
-vi.mock('@/hooks/use-toast', () => ({ toast: clientMocks.toast }));
-
-describe('AI Client Integration', () => {
- const mockConfig: ProviderConfig = {
- provider: 'openrouter',
- model: 'gpt-4o',
- temperature: 0.8,
- };
-
- beforeEach(async () => {
- seedCurrentDebate();
- resetMockState();
- setMockSessionToken('valid-token');
- vi.clearAllMocks();
- clientMocks.settings.language = 'en';
- clientMocks.settings.outputLanguage = 'en';
- clientMocks.session.addSource.mockResolvedValue(undefined);
- await i18n.changeLanguage('en');
- });
-
- afterEach(() => {
- vi.clearAllMocks();
- });
-
- describe('generateCase', () => {
- it('should call supabase.functions.invoke with correct parameters', async () => {
- // Mock successful response
- const mockResponse: { output: GenerateOutput } = {
- output: {
- caseStructure: {
- thesis: 'Test Thesis',
- framework: 'Framework',
- keyDefinitions: [],
- burdenOfProof: 'Burden',
- },
- contentions: [
- { id: '1', claim: 'Claim 1', reasoning: 'Reasoning 1', evidence: 'Evidence 1', needsSource: false, impact: 'Impact 1' },
- { id: '2', claim: 'Claim 2', reasoning: 'Reasoning 2', evidence: 'Evidence 2', needsSource: false, impact: 'Impact 2' },
- ],
- predictedOpponentCase: {
- likelyArguments: [],
- strategicWeaknesses: [],
- keyClashes: [],
- },
- counters: [],
- metadata: { side: 'pro', format: 'bp', generatedAt: '2026-01-01T00:00:00.000Z' },
- }
- };
- setMockInvokeResponse(mockResponse);
-
- const input: GenerateInput = {
- motion: 'Test Motion',
- side: 'pro',
- format: 'bp',
- role: 'intro',
- length: 'brief',
- audience: 'advanced',
- tone: 'formal',
- };
-
- await generateCase(input, mockConfig);
-
- expect(supabaseMock.functions.invoke).toHaveBeenCalledTimes(1);
- expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({
- headers: { Authorization: expect.stringContaining('Bearer') },
- body: expect.objectContaining({
- feature: 'generate',
- provider: 'openrouter',
- model: 'gpt-4o',
- temperature: 0.8,
- input: expect.objectContaining(input),
- sourceIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
- debateContext: expect.objectContaining({ sourceIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'] }),
- }),
- }));
-
- const callArgs = (supabaseMock.functions.invoke.mock.calls[0] as any[])[1];
- expect(callArgs.body.sources).toBeUndefined();
- expect(callArgs.body).not.toHaveProperty('customSystemPrompt');
- });
-
- it('sends the configured reasoning effort in the non-streaming request', async () => {
- const mockResponse: { output: GenerateOutput } = {
- output: {
- caseStructure: {
- thesis: 'Test Thesis',
- framework: 'Framework',
- keyDefinitions: [],
- burdenOfProof: 'Burden',
- },
- contentions: [
- { id: '1', claim: 'Claim 1', reasoning: 'Reasoning 1', evidence: 'Evidence 1', needsSource: false, impact: 'Impact 1' },
- { id: '2', claim: 'Claim 2', reasoning: 'Reasoning 2', evidence: 'Evidence 2', needsSource: false, impact: 'Impact 2' },
- ],
- predictedOpponentCase: {
- likelyArguments: [],
- strategicWeaknesses: [],
- keyClashes: [],
- },
- counters: [],
- metadata: { side: 'pro', format: 'bp', generatedAt: '2026-01-01T00:00:00.000Z' },
- },
- };
- setMockInvokeResponse(mockResponse);
-
- await generateCase(
- { motion: 'Test Motion', side: 'pro', format: 'bp', role: 'intro', length: 'brief', audience: 'advanced', tone: 'formal' },
- { ...mockConfig, reasoningEffort: 'high' },
- );
-
- expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({
- body: expect.objectContaining({ reasoningEffort: 'high' }),
- }));
- });
-
- it('canonicalizes legacy providers to OpenRouter', async () => {
- const lovableConfig: ProviderConfig = { ...mockConfig, provider: 'lovable' };
- // Mock response
- const mockResponse: { output: GenerateOutput } = {
- output: {
- caseStructure: {
- thesis: 'Test Thesis',
- framework: 'Framework',
- keyDefinitions: [],
- burdenOfProof: 'Burden',
- },
- contentions: [
- { id: '1', claim: 'Claim 1', reasoning: 'Reasoning 1', evidence: 'Evidence 1', needsSource: false, impact: 'Impact 1' },
- { id: '2', claim: 'Claim 2', reasoning: 'Reasoning 2', evidence: 'Evidence 2', needsSource: false, impact: 'Impact 2' },
- ],
- predictedOpponentCase: {
- likelyArguments: [],
- strategicWeaknesses: [],
- keyClashes: [],
- },
- counters: [],
- metadata: { side: 'pro', format: 'bp', generatedAt: '2026-01-01T00:00:00.000Z' },
- }
- };
- setMockInvokeResponse(mockResponse);
-
- await generateCase({ motion: 'test', side: 'pro' } as GenerateInput, lovableConfig);
-
- expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({
- body: expect.objectContaining({
- provider: 'openrouter'
- })
- }));
- });
-
- it.each([
- ['brief', 2],
- ['standard', 3],
- ['comprehensive', 4],
- ] as const)('accepts %s output with its expected %d contentions', async (length, expectedCount) => {
- const contention = (id: number) => ({
- id: String(id), claim: `Claim ${id}`, reasoning: `Reasoning ${id}`,
- evidence: `Evidence ${id}`, needsSource: false, impact: `Impact ${id}`,
- });
- setMockInvokeResponse({
- output: {
- caseStructure: { thesis: 'Thesis', framework: 'Framework', keyDefinitions: [], burdenOfProof: 'Burden' },
- contentions: Array.from({ length: expectedCount }, (_, index) => contention(index + 1)),
- predictedOpponentCase: { likelyArguments: [], strategicWeaknesses: [], keyClashes: [] },
- counters: [],
- metadata: { side: 'pro', format: 'bp', generatedAt: '2026-01-01T00:00:00.000Z' },
- } satisfies GenerateOutput,
- });
-
- const { result } = await generateCase({
- motion: 'Test Motion', side: 'pro', format: 'bp', role: 'intro',
- length, audience: 'beginner', tone: 'formal',
- }, mockConfig);
-
- expect(result.contentions).toHaveLength(expectedCount);
- }, 15000);
-
- it('rejects a response whose contention count does not match the requested length', async () => {
- setMockInvokeResponse({
- output: {
- caseStructure: { thesis: 'Thesis', framework: 'Framework', keyDefinitions: [], burdenOfProof: 'Burden' },
- contentions: [{ id: '1', claim: 'Claim', reasoning: 'Reasoning', evidence: 'Evidence', needsSource: false, impact: 'Impact' }],
- predictedOpponentCase: { likelyArguments: [], strategicWeaknesses: [], keyClashes: [] },
- counters: [],
- metadata: { side: 'pro', format: 'bp', generatedAt: '2026-01-01T00:00:00.000Z' },
- } satisfies GenerateOutput,
- });
-
- await expect(generateCase({
- motion: 'Test Motion', side: 'pro', format: 'bp', role: 'intro',
- length: 'comprehensive', audience: 'beginner', tone: 'formal',
- }, mockConfig)).rejects.toBeInstanceOf(AIResponseError);
- });
-
- it('localizes the truncated-input notification in Hungarian', async () => {
- clientMocks.settings.language = 'hu';
- clientMocks.settings.outputLanguage = 'hu';
- await i18n.changeLanguage('hu');
- setMockInvokeResponse({
- output: {
- caseStructure: { thesis: 'T', framework: 'F', keyDefinitions: [], burdenOfProof: 'B' },
- contentions: [
- { id: '1', claim: 'A', reasoning: 'R', evidence: 'E', needsSource: false, impact: 'I' },
- { id: '2', claim: 'B', reasoning: 'R', evidence: 'E', needsSource: false, impact: 'I' },
- ],
- predictedOpponentCase: { likelyArguments: [], strategicWeaknesses: [], keyClashes: [] },
- counters: [],
- metadata: { side: 'pro', format: 'custom', generatedAt: '2026-01-01T00:00:00.000Z' },
- },
- meta: { truncated: true },
- });
-
- await generateCase({ motion: 'M', side: 'pro', length: 'brief' } as GenerateInput, mockConfig);
-
- expect(clientMocks.toast).toHaveBeenCalledWith({
- title: 'A bemenet rövidítve lett',
- description: 'A kérés túl nagy volt, ezért csak az elküldhető rész került az MI-hez.',
- });
- });
- });
-
- it('keeps private sources and personal prompts out of workspace requests', async () => {
- setMockInvokeResponse({ output: { response: 'Workspace answer', followUpQuestions: [], uncertainties: [] } });
- const workspaceContext: WorkspaceToolContext = {
- documentEpoch: 1,
- documentId: '11111111-1111-4111-8111-111111111111',
- documentIds: ['11111111-1111-4111-8111-111111111111'],
- documentText: 'Accepted text',
- documentTitle: 'R1',
- motion: 'Motion',
- requestId: '22222222-2222-4222-8222-222222222222',
- returnUrl: '/app/workspaces/33333333-3333-4333-8333-333333333333',
- side: 'pro' as const,
- sourceIds: [],
- threadRequest: null,
- threadId: null,
- workspaceId: '33333333-3333-4333-8333-333333333333',
- };
-
- await askChat('Question', [], 'general', mockConfig, { workspaceContext });
-
- expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({
- body: expect.objectContaining({ sourceIds: [], workspaceContext }),
- }));
- const workspaceBody = (supabaseMock.functions.invoke.mock.calls[0] as any[])[1].body;
- expect(workspaceBody).not.toHaveProperty('customSystemPrompt');
- expect(workspaceBody).not.toHaveProperty('debateContext');
- });
-
- describe('Error Handling', () => {
- it('should throw AIAuthError when no session token exists', async () => {
- setMockSessionToken(null);
- // Supabase getUser might return null session in getSession
- supabaseMock.auth.getSession.mockResolvedValueOnce({ data: { session: null }, error: null } as any);
-
- await expect(generateCase({} as GenerateInput, mockConfig))
- .rejects
- .toThrow('Please sign in to use AI features');
- });
-
- it('should throw AIError when Supabase function returns an error', async () => {
- setMockInvokeResponse(null, { message: 'Function error' });
-
- await expect(generateCase({} as GenerateInput, mockConfig))
- .rejects
- .toThrow('Function error');
- });
-
- it('should throw correct error when backend returns specific error structure', async () => {
- // Backend returns data: { error: "Some error" } even if http 200 sometimes,
- // but typically mapped to client error.
- // client.ts handles `data?.error`.
-
- setMockInvokeResponse({ error: 'Rate limit exceeded' });
-
- await expect(generateCase({} as GenerateInput, mockConfig))
- .rejects
- .toThrow('Rate limit exceeded');
- });
- });
-
- describe('attackCase', () => {
- const validAttackOutput: AttackOutput = {
- overallAssessment: { score: 80, summary: 'Solid case', strengthAreas: ['evidence'] },
- weaknesses: [],
- fallaciesDetected: [],
- crossExQuestions: [],
- weighingAnalysis: { currentWeighing: 'none', missingComparisons: [], improvementSuggestions: [] },
- };
-
- it('should pass correct params for attackCase', async () => {
- const mockResponse = { output: validAttackOutput };
- setMockInvokeResponse(mockResponse);
-
- const input = {
- caseText: 'test',
- aggressiveness: 'moderate',
- judgeParadigm: 'logic',
- detectFallacies: true,
- analyzeWeighing: true
- } as AttackInput;
-
- await attackCase(input, mockConfig);
-
- expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({
- body: expect.objectContaining({
- feature: 'attack',
- provider: 'openrouter',
- model: 'gpt-4o',
- input: expect.objectContaining(input),
- })
- }));
- });
-
- it('returns discovered sources alongside the validated attack result', async () => {
- const discoveredSources = [{
- title: 'Study',
- url: 'https://edu.example/study',
- content: 'x',
- sourceType: 'academic',
- qualityLabel: 'strong' as const,
- qualityScore: 85,
- query: 'study query',
- }];
- setMockInvokeResponse({ output: validAttackOutput, discoveredSources });
-
- const outcome = await attackCase({ caseText: 'c' } as AttackInput, mockConfig);
-
- expect(outcome.result.overallAssessment.score).toBe(80);
- expect(outcome.discoveredSources).toEqual(discoveredSources);
- });
-
- it('localizes new auto-saved sources and save failures in Hungarian', async () => {
- clientMocks.settings.language = 'hu';
- clientMocks.settings.outputLanguage = 'hu';
- await i18n.changeLanguage('hu');
- const discoveredSources = [{
- title: 'Study',
- url: 'https://edu.example/study',
- content: 'Provider content',
- sourceType: 'academic',
- qualityLabel: 'strong' as const,
- qualityScore: 85,
- query: 'study query',
- }];
- setMockInvokeResponse({ output: validAttackOutput, discoveredSources });
-
- await attackCase({ caseText: 'c' } as AttackInput, mockConfig);
- expect(clientMocks.session.addSource).toHaveBeenCalledWith(expect.objectContaining({
- title: 'Study',
- content: 'Provider content',
- folder: 'Auto-saved',
- }));
-
- clientMocks.session.addSource.mockRejectedValueOnce(new Error('save failed'));
- await attackCase({ caseText: 'c' } as AttackInput, mockConfig);
- expect(clientMocks.toast).toHaveBeenCalledWith({
- title: 'A forrás mentése sikertelen',
- description: 'Nem sikerült a(z) „Study” forrást a könyvtárba menteni.',
- variant: 'destructive',
- });
- });
-
- it('throws AIResponseError when attack output does not match the schema', async () => {
- setMockInvokeResponse({ output: { overallAssessment: { score: 80 } } });
-
- await expect(attackCase({ caseText: 'c' } as AttackInput, mockConfig))
- .rejects
- .toThrow(AIResponseError);
- });
- });
-
- // Basic check for other functions to ensure they wired up correctly
- describe('brainstorm', () => {
- it('should pass correct params', async () => {
- setMockInvokeResponse({ output: { ideas: [] } });
- await brainstorm({ motion: 'm', side: 'pro', outputMode: 'buckets' } as BrainstormInput, mockConfig);
- expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({
- body: expect.objectContaining({ feature: 'brainstorm' })
- }));
- });
- });
-
- describe('askChat', () => {
- it('passes citation and web search flags to the ask payload', async () => {
- setMockInvokeResponse({
- output: {
- response: 'Test response',
- followUpQuestions: [] as string[],
- uncertainties: [] as string[],
- }
- });
-
- await askChat(
- 'What are recent examples?',
- [],
- 'general',
- mockConfig,
- { citationMode: true, webSearch: true, prosConsMode: true, argumentMapMode: true }
- );
-
- expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({
- body: expect.objectContaining({
- feature: 'ask',
- input: expect.objectContaining({
- message: 'What are recent examples?',
- mode: 'general',
- citationMode: true,
- webSearch: true,
- prosConsMode: true,
- argumentMapMode: true,
- }),
- }),
- }));
- });
- });
-
- describe('factoids', () => {
- it('passes source filters to the factoids payload', async () => {
- setMockInvokeResponse({
- output: {
- factoids: [] as any[],
- searchSuggestions: [] as string[],
- warnings: [] as string[],
- verificationStatus: 'complete',
- }
- });
-
- await factoids({
- claim: 'Solar is cheaper than coal',
- direction: 'for',
- region: 'United States',
- sourceType: 'government',
- sourceCount: 5,
- timeframe: '2020-2025',
- }, mockConfig);
-
- expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({
- body: expect.objectContaining({
- feature: 'factoids',
- input: expect.objectContaining({
- claim: 'Solar is cheaper than coal',
- region: 'United States',
- sourceType: 'government',
- timeframe: '2020-2025',
- language: 'en',
- }),
- }),
- }));
- });
- });
-
-});
diff --git a/src/test/ai/control-payload-matrix.test.ts b/src/test/ai/control-payload-matrix.test.ts
deleted file mode 100644
index 2544bfa25..000000000
--- a/src/test/ai/control-payload-matrix.test.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-// Control-to-payload regression matrix (frontend half).
-// Every visible AI control must reach the edge-function request payload with
-// the exact selected value. The backend half of this matrix lives in
-// supabase/functions/ai/control-matrix.test.ts (prompt markers +
-// post-processing enforcement).
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { supabaseMock, resetMockState, setMockSessionToken } from '../mocks/supabase';
-
-vi.mock('@/lib/supabase/client', () => ({
- getActiveAccessToken: (): string | null => null,
- invokeAuthenticatedFunction: supabaseMock.functions.invoke,
- supabase: supabaseMock,
-}));
-
-import { generateCase, attackCase, brainstorm, factoids, askChat } from '@/lib/ai/client';
-import type { ProviderConfig } from '@/lib/ai/providers/types';
-import { setMockInvokeResponse } from '../mocks/supabase';
-import type { AttackOutput } from '@/lib/ai/schemas/attack';
-import type { FactoidsOutput } from '@/lib/ai/schemas/factoids';
-import type { AskOutput } from '@/lib/ai/schemas/ask';
-import { useSettingsStore } from '@/lib/stores/settings-store';
-
-const config: ProviderConfig = { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 };
-
-const validAttackOutput: AttackOutput = {
- overallAssessment: { score: 50, summary: 's', strengthAreas: [] },
- weaknesses: [],
- fallaciesDetected: [],
- crossExQuestions: [],
- weighingAnalysis: { currentWeighing: 'w', missingComparisons: [], improvementSuggestions: [] },
-};
-
-const validFactoidsOutput: FactoidsOutput = {
- factoids: [],
- searchSuggestions: [],
- warnings: [],
- verificationStatus: 'complete',
-};
-
-const validAskOutput: AskOutput = {
- response: 'r',
- followUpQuestions: ['q'],
- uncertainties: [],
-};
-
-async function lastInvokeBody() {
- const call = supabaseMock.functions.invoke.mock.calls.at(-1) as unknown[] | undefined;
- const options = call?.[1] as { body?: Record } | undefined;
- return options?.body ?? {};
-}
-
-describe('control-to-payload regression matrix', () => {
- beforeEach(() => {
- resetMockState();
- setMockSessionToken('valid-token');
- useSettingsStore.setState({ outputLanguage: 'hu' });
- vi.clearAllMocks();
- setMockSessionToken('valid-token');
- });
-
- it('generate sends side, role, length, audience, and tone verbatim', async () => {
- setMockInvokeResponse({
- output: {
- caseStructure: { thesis: 'T', framework: 'F', keyDefinitions: [], burdenOfProof: 'B' },
- contentions: [
- { id: '1', claim: 'A', reasoning: 'R', evidence: 'E', needsSource: false, impact: 'I' },
- { id: '2', claim: 'B', reasoning: 'R', evidence: 'E', needsSource: false, impact: 'I' },
- { id: '3', claim: 'C', reasoning: 'R', evidence: 'E', needsSource: false, impact: 'I' },
- { id: '4', claim: 'D', reasoning: 'R', evidence: 'E', needsSource: false, impact: 'I' },
- ],
- predictedOpponentCase: { likelyArguments: [], strategicWeaknesses: [], keyClashes: [] },
- counters: [],
- metadata: { side: 'con', format: 'custom', generatedAt: '2026-01-01T00:00:00.000Z' },
- },
- });
- await generateCase(
- { motion: 'M', side: 'con', role: 'r2', length: 'comprehensive', audience: 'beginner', tone: 'conversational', format: 'custom' } as never,
- config,
- );
- const body = await lastInvokeBody();
- expect(body.feature).toBe('generate');
- expect(body.input).toMatchObject({
- side: 'con',
- role: 'r2',
- length: 'comprehensive',
- audience: 'beginner',
- tone: 'conversational',
- format: 'custom',
- language: 'hu',
- });
- });
-
- it('attack sends intensity, paradigm, and both toggles verbatim', async () => {
- setMockInvokeResponse({ output: validAttackOutput });
- await attackCase(
- { caseText: 'C', aggressiveness: 'ruthless', judgeParadigm: 'policy', detectFallacies: false, analyzeWeighing: true } as never,
- config,
- );
- const body = await lastInvokeBody();
- expect(body.input).toMatchObject({
- caseText: 'C',
- aggressiveness: 'ruthless',
- judgeParadigm: 'policy',
- detectFallacies: false,
- analyzeWeighing: true,
- language: 'hu',
- });
- });
-
- it('brainstorm sends side and output mode verbatim', async () => {
- await brainstorm({ motion: 'M', side: 'both', outputMode: 'buckets', context: 'ctx' } as never, config);
- const body = await lastInvokeBody();
- expect(body.input).toMatchObject({ motion: 'M', side: 'both', outputMode: 'buckets', context: 'ctx', language: 'hu' });
- });
-
- it('factoids sends direction, source type, timeframe, region, and source count verbatim', async () => {
- setMockInvokeResponse({ output: validFactoidsOutput });
- await factoids(
- { claim: 'C', direction: 'against', sourceType: 'academic', timeframe: '2020-2026', region: 'EU', sourceCount: 9 } as never,
- config,
- );
- const body = await lastInvokeBody();
- expect(body.input).toMatchObject({
- claim: 'C',
- direction: 'against',
- sourceType: 'academic',
- timeframe: '2020-2026',
- region: 'EU',
- sourceCount: 9,
- language: 'hu',
- });
- });
-
- it('ask sends mode plus all four toggles verbatim', async () => {
- setMockInvokeResponse({ output: validAskOutput });
- await askChat('Hello', [], 'clarify', config, {
- citationMode: true,
- webSearch: true,
- prosConsMode: false,
- argumentMapMode: true,
- });
- const body = await lastInvokeBody();
- expect(body.input).toMatchObject({
- message: 'Hello',
- mode: 'clarify',
- citationMode: true,
- webSearch: true,
- prosConsMode: false,
- argumentMapMode: true,
- language: 'hu',
- });
- });
-
- it('ask defaults every toggle to false when unset so backend enforcement stays deterministic', async () => {
- setMockInvokeResponse({ output: validAskOutput });
- await askChat('Hello', [], 'general', config);
- const body = await lastInvokeBody();
- expect(body.input).toMatchObject({
- citationMode: false,
- webSearch: false,
- prosConsMode: false,
- argumentMapMode: false,
- language: 'hu',
- });
- });
-});
diff --git a/src/test/ai/crossfire-client.test.ts b/src/test/ai/crossfire-client.test.ts
deleted file mode 100644
index 8049cb5d3..000000000
--- a/src/test/ai/crossfire-client.test.ts
+++ /dev/null
@@ -1,311 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { seedCurrentDebate } from '@/test/helpers/debate';
-import type { ProviderConfig } from '@/lib/ai/providers/types';
-import type { CrossfireAIInput, CrossfireDebrief } from '@/lib/crossfire/schemas';
-
-const mocks = vi.hoisted(() => ({
- invoke: vi.fn(),
- getSession: vi.fn(),
- settings: {
- customSystemPrompt: undefined as string | undefined,
- outputLanguage: 'en',
- },
- session: {
- sourceLibrary: [
- { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', title: 'Active source', content: 'Grounded evidence', origin: 'https://example.com', type: 'url', isActive: true, debateId: '11111111-2222-4333-8444-555555555555' },
- { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', title: 'Inactive source', content: 'Do not send', origin: 'https://inactive.example', type: 'url', isActive: false, debateId: '11111111-2222-4333-8444-555555555555' },
- ],
- },
-}));
-
-vi.mock('@/lib/supabase/client', () => ({
- getActiveAccessToken: (): string | null => null,
- invokeAuthenticatedFunction: mocks.invoke,
- supabase: {
- auth: { getSession: mocks.getSession },
- functions: { invoke: mocks.invoke },
- },
-}));
-
-vi.mock('@/lib/stores/settings-store', () => ({
- useSettingsStore: { getState: () => mocks.settings },
-}));
-
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: { getState: () => mocks.session },
-}));
-
-vi.mock('@/hooks/use-toast', () => ({ toast: vi.fn() }));
-
-import { AIResponseError, crossfire, crossfireSignal } from '@/lib/ai/client';
-import { useDebateContextStore } from '@/lib/stores/debate-context-store';
-
-const config: ProviderConfig = {
- provider: 'google',
- model: 'gemini-3-flash-preview',
- temperature: 0.5,
-};
-
-function nextTurnInput(useSourceLibrary = false): CrossfireAIInput {
- return {
- operation: 'next-turn',
- config: {
- resolution: 'Resolved: cities should ban private cars downtown.',
- argument: 'Private cars create congestion and unsafe streets.',
- mode: 'question-ai',
- style: 'cooperative',
- durationSeconds: 180,
- useSourceLibrary,
- seedQuestions: [],
- },
- transcript: [{
- id: 'T1',
- sequence: 1,
- speaker: 'user',
- kind: 'question',
- text: 'What evidence supports the congestion claim?',
- createdAt: 1,
- }],
- expectedReply: 'answer',
- };
-}
-
-function debriefInput(): CrossfireAIInput {
- const base = nextTurnInput(false);
- if (base.operation !== 'next-turn') throw new Error('Unexpected test fixture');
- return {
- operation: 'debrief',
- config: base.config,
- transcript: [
- ...base.transcript,
- { id: 'T2', sequence: 2, speaker: 'ai', kind: 'answer', text: 'Traffic counts show fewer vehicle trips.', createdAt: 2 },
- ],
- };
-}
-
-const validDebrief: CrossfireDebrief = {
- concessionsSecured: [],
- controlledQuestions: [{ turnIds: ['T1', 'T2'], claim: 'The question demands evidence for the central claim.' }],
- weakOrRepetitiveQuestions: [],
- evasiveOrIncompleteAnswers: [],
- missedFollowUps: [{ turnIds: ['T2'], claim: 'The answer does not identify a specific traffic count.' }],
- betterFollowUpQuestions: [{ turnIds: ['T2'], question: 'Which traffic count are you relying on?', reason: 'It tests the answer against a named source.' }],
- strongestExchange: { turnIds: ['T1', 'T2'], claim: 'The exchange focuses on the main empirical warrant.' },
-};
-
-describe('Crossfire AI client', () => {
- beforeEach(() => {
- seedCurrentDebate();
- vi.clearAllMocks();
- mocks.settings.outputLanguage = 'hu';
- mocks.session.sourceLibrary.splice(0, mocks.session.sourceLibrary.length,
- { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', title: 'Active source', content: 'Grounded evidence', origin: 'https://example.com', type: 'url', isActive: true, debateId: '11111111-2222-4333-8444-555555555555' },
- { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', title: 'Inactive source', content: 'Do not send', origin: 'https://inactive.example', type: 'url', isActive: false, debateId: '11111111-2222-4333-8444-555555555555' },
- );
- mocks.getSession.mockResolvedValue({ data: { session: { access_token: 'token' } } });
- });
-
- it('omits active sources unless the user enabled Source Library grounding', async () => {
- mocks.invoke.mockResolvedValue({ data: { output: { type: 'answer', text: 'A grounded answer.' } }, error: null });
- await crossfire(nextTurnInput(false), config);
- expect(mocks.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({
- body: expect.objectContaining({
- feature: 'crossfire',
- input: expect.objectContaining({ language: 'hu' }),
- sourceIds: [],
- debateContext: expect.objectContaining({ sourceIds: [] }),
- }),
- }));
- expect(mocks.invoke.mock.calls[0][1].body).not.toHaveProperty('customSystemPrompt');
-
- await crossfire(nextTurnInput(true), config);
- const secondCall = mocks.invoke.mock.calls[1][1];
- expect(secondCall.body.sourceIds).toEqual(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa']);
- expect(secondCall.body.debateContext.sourceIds).toEqual(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa']);
- });
-
- it('keeps Review choices for every turn of a round and clears them after the debrief', async () => {
- const debateId = '11111111-2222-4333-8444-555555555555';
- const excluded = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
- useDebateContextStore.getState().resetLocalData();
- useDebateContextStore.getState().setSourceChoice('crossfire', debateId, excluded, false);
- mocks.invoke.mockResolvedValue({ data: { output: { type: 'answer', text: 'A turn.' } }, error: null });
-
- await crossfire(nextTurnInput(true), config, { debateId });
- await crossfire(nextTurnInput(true), config, { debateId });
- expect(mocks.invoke.mock.calls[0][1].body.debateContext.sourceIds).toEqual([]);
- expect(mocks.invoke.mock.calls[1][1].body.debateContext.sourceIds).toEqual([]);
-
- mocks.invoke.mockResolvedValue({ data: { output: validDebrief }, error: null });
- const debrief = debriefInput();
- await crossfire({ ...debrief, config: { ...debrief.config, useSourceLibrary: true } }, config, { debateId });
- expect(mocks.invoke.mock.calls[2][1].body.debateContext.sourceIds).toEqual([]);
- expect(useDebateContextStore.getState().overrides.crossfire).toBeUndefined();
- });
-
- it('strips client-only turn signals from every transcript it sends', async () => {
- const signal = {
- answerTurnId: 'T2',
- answerQuality: 'direct',
- answerQualityConfidence: 0.9,
- concession: 'none',
- concessionConfidence: 0.8,
- followUpOpportunity: 0.7,
- } as const;
- const input = debriefInput();
- input.transcript[1] = { ...input.transcript[1], signal };
- mocks.invoke.mockResolvedValue({ data: { output: validDebrief }, error: null });
-
- await crossfire(input, config);
- await crossfireSignal({ config: input.config, transcript: input.transcript, reply: { type: 'question', text: 'Which count?' }, signalToken: 'token' }, config);
-
- // The Edge Function rejects any turn key beyond the ones it signs.
- for (const [, request] of mocks.invoke.mock.calls) {
- for (const turn of request.body.input.transcript) expect(turn).not.toHaveProperty('signal');
- }
- });
-
- it('asks for a spoken reply in a voice round, strips delivery facts, and returns the signed segments', async () => {
- const voice = { segments: [{ text: '[scoffs] Traffic counts show fewer trips.', token: 'signed-token' }] };
- mocks.invoke.mockResolvedValue({ data: { output: { type: 'answer', text: 'Traffic counts show fewer trips.' }, signalToken: 'signal-token', voice }, error: null });
- const input = nextTurnInput(false);
- input.transcript[0].delivery = { speakingSeconds: 4, wordCount: 6, wordsPerMinute: 90, fillerCount: 0, fillers: [], longPauses: 0, longestPauseSeconds: 0 };
-
- await expect(crossfire(input, config, { voice: true })).resolves.toEqual({
- type: 'answer',
- text: 'Traffic counts show fewer trips.',
- signalToken: 'signal-token',
- voice,
- });
- const body = mocks.invoke.mock.calls[0][1].body;
- expect(body.voice).toBe(true);
- // The server accepts only the turn fields it signs and prompts with.
- expect(body.input.transcript[0]).not.toHaveProperty('delivery');
- expect(body.input).not.toHaveProperty('voice');
-
- // Without a signal token the segments still come back.
- mocks.invoke.mockResolvedValue({ data: { output: { type: 'answer', text: 'Traffic counts show fewer trips.' }, voice }, error: null });
- await expect(crossfire(nextTurnInput(false), config, { voice: true })).resolves.toEqual({ type: 'answer', text: 'Traffic counts show fewer trips.', voice });
- });
-
- it('keeps typed rounds and debriefs free of the voice flag and of any voice payload', async () => {
- const voice = { segments: [{ text: 'Unrequested.', token: 'signed-token' }] };
- mocks.invoke.mockResolvedValue({ data: { output: { type: 'answer', text: 'A typed answer.' }, voice }, error: null });
- await expect(crossfire(nextTurnInput(false), config)).resolves.toEqual({ type: 'answer', text: 'A typed answer.' });
- expect(mocks.invoke.mock.calls[0][1].body).not.toHaveProperty('voice');
-
- // A debrief is never spoken, even at the end of a voice round.
- mocks.invoke.mockResolvedValue({ data: { output: validDebrief }, error: null });
- await crossfire(debriefInput(), config, { voice: true });
- expect(mocks.invoke.mock.calls[1][1].body).not.toHaveProperty('voice');
- });
-
- it('falls back to the plain reply when a voice round gets a malformed voice payload', async () => {
- mocks.invoke.mockResolvedValue({ data: { output: { type: 'answer', text: 'A plain answer.' }, voice: { segments: 'nope' } }, error: null });
- await expect(crossfire(nextTurnInput(false), config, { voice: true })).resolves.toEqual({ type: 'answer', text: 'A plain answer.' });
- });
-
- it('sends only bounded Crossfire source IDs when stored content is large', async () => {
- mocks.session.sourceLibrary.splice(0, mocks.session.sourceLibrary.length,
- ...Array.from({ length: 12 }, (_, index) => ({
- id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
- title: `Source ${index}`,
- content: '界'.repeat(9_000),
- origin: `https://example.com/${index}`,
- type: 'text',
- isActive: true, debateId: '11111111-2222-4333-8444-555555555555',
- })),
- );
- mocks.invoke.mockResolvedValue({ data: { output: { type: 'answer', text: 'A bounded answer.' } }, error: null });
-
- await crossfire(nextTurnInput(true), config);
-
- const sourceIds = mocks.invoke.mock.calls[0][1].body.sourceIds as string[];
- expect(sourceIds).toHaveLength(10);
- expect(new TextEncoder().encode(JSON.stringify(mocks.invoke.mock.calls[0][1].body)).byteLength).toBeLessThan(200_000);
- });
-
- it('rejects malformed and wrong-kind next-turn responses', async () => {
- mocks.invoke.mockResolvedValueOnce({ data: { output: { type: 'answer' } }, error: null });
- await expect(crossfire(nextTurnInput(), config)).rejects.toBeInstanceOf(AIResponseError);
-
- mocks.invoke.mockResolvedValueOnce({ data: { output: { type: 'question', text: 'Wrong role' } }, error: null });
- await expect(crossfire(nextTurnInput(), config)).rejects.toBeInstanceOf(AIResponseError);
- });
-
- it('parses a valid live signal and rejects unknown or non-answer targets', async () => {
- const signal = { answerTurnId: 'T2', answerQuality: 'direct', answerQualityConfidence: 0.9, concession: 'none', concessionConfidence: 0.8, followUpOpportunity: 0.75 };
- mocks.invoke.mockResolvedValueOnce({ data: { output: { type: 'answer', text: 'Answer.' }, signalToken: 'signed-token' }, error: null });
- await expect(crossfire(nextTurnInput(), config)).resolves.toEqual({ type: 'answer', text: 'Answer.', signalToken: 'signed-token' });
-
- const next = nextTurnInput();
- if (next.operation !== 'next-turn') throw new Error('Unexpected fixture');
- const signalInput = { config: next.config, transcript: next.transcript, reply: { type: 'answer' as const, text: 'Answer.' }, signalToken: 'signed-token' };
- mocks.invoke.mockResolvedValueOnce({ data: { output: { signal } }, error: null });
- await expect(crossfireSignal(signalInput, config)).resolves.toEqual(signal);
-
- mocks.invoke.mockResolvedValueOnce({ data: { output: { signal: { ...signal, answerTurnId: 'T99' } } }, error: null });
- await expect(crossfireSignal(signalInput, config)).rejects.toBeInstanceOf(AIResponseError);
-
- mocks.invoke.mockResolvedValueOnce({ data: { output: { signal: { ...signal, answerTurnId: 'T1' } } }, error: null });
- await expect(crossfireSignal(signalInput, config)).rejects.toBeInstanceOf(AIResponseError);
- });
-
- it('validates debrief shape and every transcript reference', async () => {
- mocks.invoke.mockResolvedValueOnce({ data: { output: validDebrief }, error: null });
- await expect(crossfire(debriefInput(), config)).resolves.toEqual(validDebrief);
-
- mocks.invoke.mockResolvedValueOnce({
- data: { output: { ...validDebrief, strongestExchange: { turnIds: ['T999'], claim: 'Invented reference' } } },
- error: null,
- });
- await expect(crossfire(debriefInput(), config)).rejects.toMatchObject({
- name: 'AIResponseError',
- message: expect.stringContaining('referenced transcript turns'),
- });
- });
-
- it('rejects invalid requests before invoking the edge function', async () => {
- const invalid = nextTurnInput() as CrossfireAIInput;
- invalid.config.argument = '';
- await expect(crossfire(invalid, config)).rejects.toBeInstanceOf(AIResponseError);
- expect(mocks.invoke).not.toHaveBeenCalled();
- });
-
- it('aborts while session resolution is pending without invoking the edge function', async () => {
- mocks.getSession.mockImplementationOnce(() => new Promise(() => undefined));
- const controller = new AbortController();
- const request = crossfire(nextTurnInput(), config, { signal: controller.signal });
- await Promise.resolve();
- controller.abort();
-
- await expect(request).rejects.toMatchObject({ name: 'AbortError' });
- expect(mocks.invoke).not.toHaveBeenCalled();
- });
-
- it('cancels an oversized Function error stream before buffering the full body', async () => {
- let pulls = 0;
- let canceled = false;
- const stream = new ReadableStream({
- pull(controller) {
- pulls += 1;
- controller.enqueue(new Uint8Array(1_024).fill(120));
- if (pulls >= 40) controller.close();
- },
- cancel() {
- canceled = true;
- },
- });
- mocks.invoke.mockResolvedValue({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code 500 Internal',
- context: { body: stream, status: 500 },
- },
- });
-
- await expect(crossfire(nextTurnInput(), config)).rejects.toMatchObject({ name: 'AIError' });
- expect(canceled).toBe(true);
- expect(pulls).toBeLessThan(40);
- });
-});
diff --git a/src/test/ai/error-display.test.ts b/src/test/ai/error-display.test.ts
deleted file mode 100644
index 295d69823..000000000
--- a/src/test/ai/error-display.test.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { AIProviderError } from '@/lib/ai/client';
-import { getUserFacingAIError } from '@/lib/ai/error-display';
-
-describe('getUserFacingAIError', () => {
- it('shows a concise provider quota message', () => {
- const error = new AIProviderError(
- 'OpenAI error: { "error": { "message": "You exceeded your current quota", "code": "insufficient_quota" } }',
- { provider: 'openai' }
- );
-
- expect(getUserFacingAIError(error)).toBe('OpenAI quota exceeded. Please inform the site admin.');
- });
-
- it('returns a safe generic message for unknown provider errors', () => {
- const error = new AIProviderError('OpenAI error: brand new provider failure', { provider: 'openai' });
-
- expect(getUserFacingAIError(error)).toBe('The AI provider returned an unexpected error. Please try again later.');
- });
-
- it('falls back to the original non-provider error message', () => {
- expect(getUserFacingAIError(new Error('Unexpected edge case'))).toBe('Unexpected edge case');
- });
-});
diff --git a/src/test/ai/request-dispatch.test.ts b/src/test/ai/request-dispatch.test.ts
deleted file mode 100644
index e671acf65..000000000
--- a/src/test/ai/request-dispatch.test.ts
+++ /dev/null
@@ -1,200 +0,0 @@
-import { createElement } from 'react';
-import { seedCurrentDebate } from '@/test/helpers/debate';
-import { render, screen, waitFor } from '@testing-library/react';
-import { afterEach, expect, it, vi } from 'vitest';
-
-const sessionState = {
- sourceLibrary: [] as unknown[],
- hydrateFromServer: vi.fn(),
- resetLocalData: vi.fn(),
-};
-
-const settingsState = {
- customSystemPrompt: '',
- outputLanguage: 'en',
- hydrateFromServer: vi.fn(),
- resetLocalData: vi.fn(),
-};
-
-vi.mock('@/lib/stores/settings-store', () => ({
- useSettingsStore: Object.assign(
- (selector: (state: typeof settingsState) => unknown) => selector(settingsState),
- { getState: () => settingsState },
- ),
-}));
-
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: Object.assign(
- (selector: (state: typeof sessionState) => unknown) => selector(sessionState),
- { getState: () => sessionState },
- ),
-}));
-
-vi.mock('@/lib/stores/sparring-store', () => {
- const sparringState = {
- hydrateSavedSessions: vi.fn(),
- prepareForUser: vi.fn(),
- resetLocalData: vi.fn(),
- savedSessions: [] as unknown[],
- };
- return {
- useSparringStore: Object.assign(
- (selector: (state: Record) => unknown) => selector(sparringState),
- { getState: () => sparringState },
- ),
- };
-});
-
-vi.mock('@/lib/observability', () => ({
- captureException: vi.fn(),
- captureProductEvent: vi.fn(),
-}));
-
-import { askChat } from '@/lib/ai/client';
-import { AuthProvider, useAuth } from '@/lib/supabase/AuthProvider';
-import { setActiveAccessToken, supabase } from '@/lib/supabase/client';
-
-afterEach(() => {
- setActiveAccessToken(null);
- sessionState.sourceLibrary = [];
- settingsState.customSystemPrompt = '';
- vi.restoreAllMocks();
-});
-
-function AuthState() {
- const { loading, user } = useAuth();
- return createElement('span', { 'data-testid': 'auth-state' }, loading ? 'loading' : user?.id ?? 'signed-out');
-}
-
-it('calls the AI fetch boundary without another session lookup', async () => {
- const getSession = vi.spyOn(supabase.auth, 'getSession')
- .mockResolvedValue({
- data: { session: { access_token: 'test-access-token', user: { id: 'user-1' } } },
- error: null,
- } as Awaited>);
- vi.spyOn(supabase.auth, 'onAuthStateChange').mockReturnValue({
- data: { subscription: { id: 'test', callback: vi.fn(), unsubscribe: vi.fn() } },
- } as ReturnType);
- const fetchRequest = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
- output: {
- response: 'Mock AI response.',
- followUpQuestions: [],
- uncertainties: [],
- },
- }), {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- }));
-
- render(createElement(AuthProvider, null, createElement(AuthState)));
- await waitFor(() => expect(screen.getByTestId('auth-state')).toHaveTextContent('user-1'));
- const authInitializationLookups = getSession.mock.calls.length;
-
- const result = await askChat('Hello', [], 'general', {
- provider: 'openrouter',
- model: 'openai/gpt-5.6-luna',
- temperature: 0.7,
- reasoningEffort: 'low',
- });
-
- expect(fetchRequest).toHaveBeenCalledOnce();
- expect(result).toMatchObject({ response: 'Mock AI response.' });
- expect(getSession).toHaveBeenCalledTimes(authInitializationLookups);
-});
-
-it('maps a rejected fetch through the Edge Function error contract', async () => {
- setActiveAccessToken('test-access-token');
- vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch'));
-
- await expect(askChat('Hello', [], 'general', {
- provider: 'openrouter',
- model: 'openai/gpt-5.6-luna',
- temperature: 0.7,
- reasoningEffort: 'low',
- })).rejects.toMatchObject({
- name: 'AIError',
- message: 'AI service error: Failed to send a request to the Edge Function. Please try again.',
- statusCode: 500,
- });
-});
-
-it('keeps large Ask context below the Edge Function request limit', async () => {
- seedCurrentDebate();
- setActiveAccessToken('test-access-token');
- sessionState.sourceLibrary = [{
- id: 'local_source_legacy',
- title: 'Local source',
- content: 'local only',
- type: 'text',
- isActive: true, debateId: '11111111-2222-4333-8444-555555555555',
- }, ...Array.from({ length: 12 }, (_, index) => ({
- id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
- title: `Source ${index}`,
- content: 'source context '.repeat(3_000),
- type: 'text',
- isActive: true, debateId: '11111111-2222-4333-8444-555555555555',
- }))];
- settingsState.customSystemPrompt = '界'.repeat(50_000);
- const history = Array.from({ length: 12 }, (_, index) => ({
- role: index % 2 === 0 ? 'user' as const : 'assistant' as const,
- content: '界'.repeat(20_000),
- }));
-
- let sentBody = '';
- vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
- sentBody = String(init?.body ?? '');
- if (new TextEncoder().encode(sentBody).byteLength > 200_000) {
- return new Response(JSON.stringify({ error: 'Request body exceeds 200000 bytes' }), {
- status: 400,
- headers: { 'Content-Type': 'application/json' },
- });
- }
- return new Response(JSON.stringify({
- output: { response: 'Mock AI response.', followUpQuestions: [], uncertainties: [] },
- }), { status: 200, headers: { 'Content-Type': 'application/json' } });
- });
-
- await expect(askChat('界'.repeat(20_000), history, 'clarify', {
- provider: 'openrouter',
- model: 'openai/gpt-5.6-luna',
- temperature: 0.7,
- reasoningEffort: 'low',
- })).resolves.toMatchObject({ response: 'Mock AI response.' });
-
- const body = JSON.parse(sentBody) as {
- customSystemPrompt?: string;
- debateContext?: { sourceIds: string[] };
- input: { history: Array<{ content: string }>; message: string };
- sourceIds: string[];
- sources?: unknown;
- };
- expect(new TextEncoder().encode(sentBody).byteLength).toBeLessThanOrEqual(200_000);
- expect(body.sourceIds).toHaveLength(10);
- expect(body.debateContext?.sourceIds).toEqual(body.sourceIds);
- expect(body.sourceIds).not.toContain('local_source_legacy');
- expect(body.sources).toBeUndefined();
- expect(body.input.history).toHaveLength(8);
- expect(body.input.history.every((message) => new TextEncoder().encode(message.content).byteLength <= 6_000)).toBe(true);
- expect(body.input.history.reduce((total, message) => total + new TextEncoder().encode(message.content).byteLength, 0)).toBeLessThanOrEqual(48_000);
- expect(new TextEncoder().encode(body.input.message).byteLength).toBeLessThanOrEqual(80_000);
- // The retired per-user custom prompt is no longer sent at all.
- expect(body.customSystemPrompt).toBeUndefined();
-});
-
-it('rejects an oversized multibyte Ask message instead of shortening it silently', async () => {
- setActiveAccessToken('test-access-token');
- const fetchRequest = vi.spyOn(globalThis, 'fetch');
-
- await expect(askChat('😀'.repeat(30_000), [], 'clarify', {
- provider: 'openrouter',
- model: 'openai/gpt-5.6-luna',
- temperature: 0.7,
- reasoningEffort: 'low',
- })).rejects.toMatchObject({
- name: 'AIError',
- message: 'Message is too large. Shorten it before sending.',
- statusCode: 413,
- });
-
- expect(fetchRequest).not.toHaveBeenCalled();
-});
diff --git a/src/test/ai/sparring.test.ts b/src/test/ai/sparring.test.ts
deleted file mode 100644
index 5f33b1893..000000000
--- a/src/test/ai/sparring.test.ts
+++ /dev/null
@@ -1,236 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { supabaseMock, resetMockState, setMockInvokeResponse } from '../mocks/supabase';
-import {
- AIResponseError,
- generateSparringTurn,
- judgeSparringSession,
-} from '@/lib/ai/client';
-import type { SparringRespondInput, SparringScoreInput } from '@/lib/ai/schemas/sparring';
-import type { ProviderConfig } from '@/lib/ai/providers/types';
-
-const authState = vi.hoisted(() => ({ token: 'cached-token', userId: 'user-1' }));
-
-vi.mock('@/lib/supabase/client', () => ({
- getActiveAccessToken: (): string | null => authState.token,
- getActiveUserId: (): string | null => authState.userId,
- invokeAuthenticatedFunction: supabaseMock.functions.invoke,
- supabase: supabaseMock,
-}));
-
-const CONFIG: ProviderConfig = {
- provider: 'openai',
- model: 'gpt-4o',
- temperature: 0.7,
-};
-
-const SESSION: Omit = {
- sessionId: '00000000-0000-4000-8000-000000000001',
- motion: 'This House would make public transport free at the point of use.',
- userSide: 'pro' as const,
- format: 'pf' as const,
- difficulty: 'hard' as const,
- speeches: [{
- id: 'speech-1',
- turnId: 'pf-1',
- speaker: 'user',
- side: 'pro',
- role: 'Pro constructive',
- content: 'Universal access removes the direct price barrier to participation.',
- createdAt: 1_000,
- timeLimitSeconds: 240,
- timeUsedSeconds: 80,
- }],
- language: 'en',
-};
-
-const RESPOND_INPUT: SparringRespondInput = {
- ...SESSION,
- action: 'respond',
- currentTurn: {
- id: 'pf-2',
- label: 'Con constructive',
- side: 'con',
- kind: 'constructive',
- timeLimitSeconds: 240,
- },
-};
-
-function firstInvokeBody(): { voice?: boolean; input: { speeches: Array> } & Record } {
- const call = supabaseMock.functions.invoke.mock.calls[0] as unknown as [string, { body: ReturnType }];
- return call[1].body;
-}
-
-const SCORE_INPUT: SparringScoreInput = {
- ...SESSION,
- action: 'score',
-};
-
-const TURN_RESPONSE = {
- action: 'respond' as const,
- speech: {
- content: 'Universal access removes a direct barrier to participation.',
- strategy: 'Answer the central claim and weigh the mechanism.',
- targetedPoints: ['The warrant needs a clearer causal link.'],
- },
-};
-
-const SCORE_RESPONSE = {
- action: 'score' as const,
- scorecard: {
- userScore: 78,
- aiScore: 74,
- categories: [
- { name: 'Logic' as const, userScore: 20, aiScore: 18, feedback: 'The causal chain was clear.' },
- { name: 'Evidence' as const, userScore: 18, aiScore: 18, feedback: 'The factual warrant needed more support.' },
- { name: 'Delivery' as const, userScore: 19, aiScore: 19, feedback: 'Judged from organization, not vocal performance.' },
- { name: 'Strategy' as const, userScore: 21, aiScore: 19, feedback: 'The user prioritized the central clash.' },
- ],
- overallFeedback: 'The user won a narrow edge by weighing the main mechanism.',
- keyMoments: ['The rebuttal answered the opponent’s strongest objection.'],
- improvementAreas: ['Compare the alternative policy explicitly.'],
- winner: 'user' as const,
- verdict: 'The user had the edge.',
- },
-};
-
-describe('sparring AI client', () => {
- beforeEach(() => {
- resetMockState();
- authState.token = 'cached-token';
- authState.userId = 'user-1';
- });
-
- it('sends a validated opponent-turn request with its full session context', async () => {
- setMockInvokeResponse({ output: TURN_RESPONSE });
-
- await expect(generateSparringTurn(RESPOND_INPUT, CONFIG)).resolves.toEqual(TURN_RESPONSE);
- expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({
- headers: { Authorization: expect.stringContaining('Bearer') },
- body: expect.objectContaining({
- feature: 'sparring',
- provider: 'openrouter',
- model: 'gpt-4o',
- input: expect.objectContaining({
- action: 'respond',
- sessionId: SESSION.sessionId,
- motion: SESSION.motion,
- format: 'pf',
- difficulty: 'hard',
- currentTurn: RESPOND_INPUT.currentTurn,
- }),
- }),
- }));
- });
-
- it('asks for a spoken reply in a voice round and returns its signed segments', async () => {
- const voice = { segments: [{ text: '[sighs] Universal access removes a direct barrier.', token: 'signed-token' }] };
- setMockInvokeResponse({ output: TURN_RESPONSE, voice });
-
- await expect(generateSparringTurn(RESPOND_INPUT, CONFIG, undefined, null, true)).resolves.toEqual({ ...TURN_RESPONSE, voice });
- const body = firstInvokeBody();
- // The flag sits beside the input, where an older server ignores it.
- expect(body.voice).toBe(true);
- expect(body.input).not.toHaveProperty('voice');
- });
-
- it('keeps typed rounds free of the voice flag and of any voice payload', async () => {
- setMockInvokeResponse({ output: TURN_RESPONSE, voice: { segments: [{ text: 'Unrequested.', token: 'signed-token' }] } });
-
- await expect(generateSparringTurn(RESPOND_INPUT, CONFIG)).resolves.toEqual(TURN_RESPONSE);
- expect(firstInvokeBody()).not.toHaveProperty('voice');
- });
-
- it('still returns the speech when a voice round gets no usable voice payload', async () => {
- // A server from before voice rounds, or a malformed payload: the browser voice reads the speech.
- for (const voice of [undefined, { segments: [] }, { segments: [{ text: 'No token.' }] }]) {
- setMockInvokeResponse({ output: TURN_RESPONSE, voice });
- const input = { ...RESPOND_INPUT, sessionId: crypto.randomUUID() };
- await expect(generateSparringTurn(input, CONFIG, undefined, null, true)).resolves.toEqual(TURN_RESPONSE);
- }
- });
-
- it('sends the measured delivery facts of spoken speeches to the judge', async () => {
- setMockInvokeResponse({ output: SCORE_RESPONSE });
- const delivery = { speakingSeconds: 75, wordCount: 160, wordsPerMinute: 128, fillerCount: 2, fillers: [{ word: 'um', count: 2 }], longPauses: 1, longestPauseSeconds: 4 };
- await judgeSparringSession({ ...SCORE_INPUT, speeches: [{ ...SESSION.speeches[0], delivery }] }, CONFIG);
- expect(firstInvokeBody().input.speeches[0].delivery).toEqual(delivery);
- });
-
- it('shares identical in-flight turn requests across remounts', async () => {
- let resolveInvoke!: (value: { data: { output: typeof TURN_RESPONSE }; error: null }) => void;
- const invokeResponse = new Promise<{ data: { output: typeof TURN_RESPONSE }; error: null }>((resolve) => {
- resolveInvoke = resolve;
- });
- supabaseMock.functions.invoke.mockImplementation(() => invokeResponse);
-
- const first = generateSparringTurn(RESPOND_INPUT, CONFIG);
- await vi.waitFor(() => expect(supabaseMock.functions.invoke).toHaveBeenCalledTimes(1));
- authState.token = 'refreshed-token';
- const second = generateSparringTurn(RESPOND_INPUT, CONFIG);
- await Promise.resolve();
- resolveInvoke({ data: { output: TURN_RESPONSE }, error: null });
-
- await expect(Promise.all([first, second])).resolves.toEqual([TURN_RESPONSE, TURN_RESPONSE]);
- expect(supabaseMock.functions.invoke).toHaveBeenCalledTimes(1);
- });
-
- it('sends turns and scores without reopening the Supabase session', async () => {
- setMockInvokeResponse({ output: TURN_RESPONSE });
- supabaseMock.auth.getSession.mockRejectedValue(new Error('session lookup must not run'));
-
- await expect(generateSparringTurn(RESPOND_INPUT, CONFIG)).resolves.toEqual(TURN_RESPONSE);
- setMockInvokeResponse({ output: SCORE_RESPONSE });
- await expect(judgeSparringSession(SCORE_INPUT, CONFIG)).resolves.toEqual(SCORE_RESPONSE);
- expect(supabaseMock.auth.getSession).not.toHaveBeenCalled();
- });
-
- it('sends a score request and validates the scorecard envelope', async () => {
- setMockInvokeResponse({ output: SCORE_RESPONSE });
-
- await expect(judgeSparringSession(SCORE_INPUT, CONFIG)).resolves.toEqual(SCORE_RESPONSE);
- expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({
- body: expect.objectContaining({
- feature: 'sparring',
- input: expect.objectContaining({
- action: 'score',
- sessionId: SESSION.sessionId,
- speeches: expect.arrayContaining([
- expect.objectContaining({ id: 'speech-1', speaker: 'user', content: expect.stringContaining('Universal access') }),
- ]),
- language: 'en',
- }),
- }),
- }));
- });
-
- it('rejects a malformed opponent response before the page can append it', async () => {
- setMockInvokeResponse({
- output: {
- action: 'respond',
- speech: { content: '', strategy: 42, targetedPoints: [''] },
- },
- });
-
- await expect(generateSparringTurn(RESPOND_INPUT, CONFIG))
- .rejects.toBeInstanceOf(AIResponseError);
- expect(supabaseMock.functions.invoke).toHaveBeenCalledTimes(1);
- });
-
- it('rejects a malformed scorecard rather than accepting partial judging', async () => {
- setMockInvokeResponse({
- output: {
- action: 'score',
- scorecard: { ...SCORE_RESPONSE.scorecard, categories: SCORE_RESPONSE.scorecard.categories.slice(0, 3) },
- },
- });
-
- await expect(judgeSparringSession(SCORE_INPUT, CONFIG))
- .rejects.toMatchObject({ name: 'AIResponseError' });
- });
-
- it('rejects a turn request passed to the scoring operation', async () => {
- await expect(judgeSparringSession(RESPOND_INPUT as unknown as SparringScoreInput, CONFIG))
- .rejects.toMatchObject({ name: 'AIResponseError' });
- expect(supabaseMock.functions.invoke).not.toHaveBeenCalled();
- });
-});
diff --git a/src/test/ai/streaming-integration.test.ts b/src/test/ai/streaming-integration.test.ts
deleted file mode 100644
index b05335619..000000000
--- a/src/test/ai/streaming-integration.test.ts
+++ /dev/null
@@ -1,386 +0,0 @@
-import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { supabaseMock, resetMockState, setMockSessionToken } from '../mocks/supabase';
-
-vi.mock('@/lib/supabase/client', () => ({
- getActiveAccessToken: (): string | null => null,
- supabase: supabaseMock,
- SUPABASE_URL: 'http://test-supabase.local',
-}));
-
-import { streamAskChat } from '@/lib/ai/streaming';
-import type { ProviderConfig } from '@/lib/ai/providers/types';
-
-// Helper: build a ReadableStream that emits the given chunks one at a time.
-function streamFromChunks(chunks: string[]): ReadableStream {
- const encoder = new TextEncoder();
- let i = 0;
- return new ReadableStream({
- pull(controller) {
- if (i >= chunks.length) {
- controller.close();
- return;
- }
- controller.enqueue(encoder.encode(chunks[i]));
- i += 1;
- },
- });
-}
-
-function mockFetchOnce(body: BodyInit | null, init: ResponseInit = { status: 200 }) {
- const fetchMock = vi.fn(async () => new Response(body, init));
- globalThis.fetch = fetchMock;
- return fetchMock;
-}
-
-const config: ProviderConfig = { provider: 'openrouter', model: 'gpt-5.4-mini', temperature: 0.7 };
-const options = {};
-
-describe('streamAskChat (integration with mocked fetch)', () => {
- beforeEach(() => {
- resetMockState();
- setMockSessionToken('test-token');
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it('streams deltas to onDelta and resolves with the final output', async () => {
- const chunks = [
- 'event: delta\ndata: {"text":"Hello"}\n\n',
- 'event: delta\ndata: {"text":" world"}\n\n',
- 'event: final\ndata: {"output":{"response":"Hello world","followUpQuestions":[],"uncertainties":[]}}\n\n',
- ];
- mockFetchOnce(streamFromChunks(chunks));
-
- const deltas: string[] = [];
- const result = await streamAskChat(
- 'hi',
- [],
- 'general',
- config,
- options,
- { onDelta: (text) => deltas.push(text) }
- );
-
- expect(deltas).toEqual(['Hello', ' world']);
- expect(result.response).toBe('Hello world');
- });
-
- it('accepts automatic route metadata from the final event', async () => {
- const chunks = [
- 'event: final\ndata: {"output":{"response":"Routed","followUpQuestions":[],"uncertainties":[]},"meta":{"requestedModel":"auto-fast","resolvedModel":"z-ai/glm-5.3-flash"}}\n\n',
- ];
- mockFetchOnce(streamFromChunks(chunks));
-
- const result = await streamAskChat(
- 'hi',
- [],
- 'general',
- { ...config, model: 'auto-fast' },
- options,
- { onDelta: () => undefined },
- );
-
- expect(result.model).toBe('z-ai/glm-5.3-flash');
- });
-
- it('sends the configured reasoning effort in the SSE request', async () => {
- const fetchMock = mockFetchOnce(streamFromChunks([
- 'event: final\ndata: {"output":{"response":"answer","followUpQuestions":[],"uncertainties":[]}}\n\n',
- ]));
-
- await streamAskChat(
- 'hi',
- [],
- 'general',
- { ...config, reasoningEffort: 'high' },
- options,
- { onDelta: () => undefined },
- );
-
- const requestInit = (fetchMock.mock.calls[0] as unknown[] | undefined)?.[1] as RequestInit | undefined;
- expect(JSON.parse(String(requestInit.body))).toMatchObject({
- provider: 'openrouter',
- model: 'gpt-5.4-mini',
- reasoningEffort: 'high',
- });
- });
-
- it('skips malformed JSON chunks and still delivers the final output', async () => {
- const chunks = [
- 'event: delta\ndata: {"text":"Hi"}\n\n',
- // Malformed: missing closing brace.
- 'event: delta\ndata: {"text":"corrupted\n\n',
- 'event: delta\ndata: {"text":"!"}\n\n',
- 'event: final\ndata: {"output":{"response":"Hi!","followUpQuestions":[],"uncertainties":[]}}\n\n',
- ];
- mockFetchOnce(streamFromChunks(chunks));
-
- const deltas: string[] = [];
- const result = await streamAskChat(
- 'hi',
- [],
- 'general',
- config,
- options,
- { onDelta: (text) => deltas.push(text) }
- );
-
- // Malformed chunk was parsed as a string (fallback) so it doesn't satisfy
- // the "data is an object with text" guard and is skipped silently.
- expect(deltas).toEqual(['Hi', '!']);
- expect(result.response).toBe('Hi!');
- });
-
- it('throws on server-emitted error events', async () => {
- const chunks = [
- 'event: delta\ndata: {"text":"partial"}\n\n',
- 'event: error\ndata: {"message":"OpenAI exploded"}\n\n',
- ];
- mockFetchOnce(streamFromChunks(chunks));
-
- const deltas: string[] = [];
- await expect(streamAskChat(
- 'hi',
- [],
- 'general',
- config,
- options,
- { onDelta: (text) => deltas.push(text) }
- )).rejects.toThrow(/OpenAI exploded/);
-
- // Partial deltas should still have been delivered before the error event.
- expect(deltas).toEqual(['partial']);
- });
-
- it('rejects when the response status is not ok', async () => {
- mockFetchOnce('Internal error', { status: 500 });
-
- await expect(streamAskChat(
- 'hi',
- [],
- 'general',
- config,
- options,
- { onDelta: () => undefined }
- )).rejects.toThrow(/Streaming request failed/);
- });
-
- it('rejects when stream ends without a final event', async () => {
- const chunks = [
- 'event: delta\ndata: {"text":"partial only"}\n\n',
- // No final event.
- ];
- mockFetchOnce(streamFromChunks(chunks));
-
- await expect(streamAskChat(
- 'hi',
- [],
- 'general',
- config,
- options,
- { onDelta: () => undefined }
- )).rejects.toThrow(/ended before a final response/);
- });
-
- it('uses only the first final event', async () => {
- const chunks = [
- 'event: final\ndata: {"output":{"response":"first","followUpQuestions":[],"uncertainties":[]}}\n\n',
- 'event: final\ndata: {"output":{"response":"second","followUpQuestions":[],"uncertainties":[]}}\n\n',
- ];
- mockFetchOnce(streamFromChunks(chunks));
- const finals: string[] = [];
-
- const result = await streamAskChat(
- 'hi',
- [],
- 'general',
- config,
- options,
- { onDelta: () => undefined, onFinal: (output) => finals.push(output.response) }
- );
-
- expect(result.response).toBe('first');
- expect(finals).toEqual(['first']);
- });
-
- it('rejects malformed final output without invoking onFinal', async () => {
- const chunks = [
- 'event: final\ndata: {"output":{"response":42,"followUpQuestions":[],"uncertainties":[]}}\n\n',
- ];
- mockFetchOnce(streamFromChunks(chunks));
- const onFinal = vi.fn();
-
- await expect(streamAskChat(
- 'hi',
- [],
- 'general',
- config,
- options,
- { onDelta: () => undefined, onFinal }
- )).rejects.toMatchObject({ name: 'AIResponseError' });
- expect(onFinal).not.toHaveBeenCalled();
- });
-
- it('maps non-OK streaming responses to AI error types', async () => {
- mockFetchOnce(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
-
- await expect(streamAskChat(
- 'hi',
- [],
- 'general',
- config,
- options,
- { onDelta: () => undefined }
- )).rejects.toMatchObject({ name: 'AIAuthError' });
- });
-
- it('times out a stalled stream without a caller signal', async () => {
- vi.useFakeTimers();
- globalThis.fetch = vi.fn((_url, init) => new Promise((_resolve, reject) => {
- const signal = (init as RequestInit).signal;
- signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
- })) as typeof fetch;
-
- try {
- const request = streamAskChat('hi', [], 'general', config, options, { onDelta: () => undefined });
- const expectation = expect(request).rejects.toMatchObject({ name: 'TimeoutError' });
- await vi.advanceTimersByTimeAsync(200_000);
- await expectation;
- } finally {
- vi.useRealTimers();
- }
- });
-
- it('does not start a request for a pre-aborted caller signal', async () => {
- const controller = new AbortController();
- controller.abort();
- const fetchMock = mockFetchOnce(streamFromChunks([]));
-
- await expect(streamAskChat(
- 'hi',
- [],
- 'general',
- config,
- options,
- { onDelta: () => undefined, signal: controller.signal }
- )).rejects.toMatchObject({ name: 'AbortError' });
-
- expect(fetchMock).not.toHaveBeenCalled();
- });
-
- it('does not start a request when the caller aborts while session resolution is pending', async () => {
- type SessionResult = { data: { session: { access_token: string } } };
- let resolveSession!: (result: SessionResult) => void;
- supabaseMock.auth.getSession.mockImplementationOnce(() => new Promise((resolve) => {
- resolveSession = resolve;
- }));
- const controller = new AbortController();
- const fetchMock = mockFetchOnce(streamFromChunks([]));
-
- const request = streamAskChat(
- 'hi',
- [],
- 'general',
- config,
- options,
- { onDelta: () => undefined, signal: controller.signal }
- );
- controller.abort();
- resolveSession({ data: { session: { access_token: 'test-token' } } });
-
- await expect(request).rejects.toMatchObject({ name: 'AbortError' });
- expect(fetchMock).not.toHaveBeenCalled();
- });
-
- it('aborts the stream when the signal fires', async () => {
- // Build a stream that emits one delta, then never emits anything else
- // until the test aborts the reader.
- const encoder = new TextEncoder();
- const stream = new ReadableStream({
- start(controller) {
- controller.enqueue(encoder.encode('event: delta\ndata: {"text":"opening"}\n\n'));
- // Intentionally never close — abort must cause the read to reject.
- },
- });
- mockFetchOnce(stream);
-
- const controller = new AbortController();
- const deltas: string[] = [];
-
- const promise = streamAskChat(
- 'hi',
- [],
- 'general',
- config,
- options,
- {
- onDelta: (text) => {
- deltas.push(text);
- // Fire the abort right after the first delta lands.
- if (deltas.length === 1) controller.abort();
- },
- signal: controller.signal,
- }
- );
-
- await expect(promise).rejects.toThrow();
- expect(deltas).toEqual(['opening']);
- });
-
- it('throws when there is no session token', async () => {
- setMockSessionToken(null);
- mockFetchOnce(streamFromChunks([]));
-
- await expect(streamAskChat(
- 'hi',
- [],
- 'general',
- config,
- options,
- { onDelta: () => undefined }
- )).rejects.toThrow(/sign in/i);
- });
-
- it('handles deltas and final arriving in a single chunk', async () => {
- const chunks = [
- 'event: delta\ndata: {"text":"A"}\n\nevent: delta\ndata: {"text":"B"}\n\nevent: final\ndata: {"output":{"response":"AB","followUpQuestions":[],"uncertainties":[]}}\n\n',
- ];
- mockFetchOnce(streamFromChunks(chunks));
-
- const deltas: string[] = [];
- const result = await streamAskChat(
- 'hi',
- [],
- 'general',
- config,
- options,
- { onDelta: (text) => deltas.push(text) }
- );
-
- expect(deltas).toEqual(['A', 'B']);
- expect(result.response).toBe('AB');
- });
-
- it('handles a delta split across two read() chunks', async () => {
- const chunks = [
- 'event: delta\ndata: {"text":"Hel',
- 'lo"}\n\nevent: final\ndata: {"output":{"response":"Hello","followUpQuestions":[],"uncertainties":[]}}\n\n',
- ];
- mockFetchOnce(streamFromChunks(chunks));
-
- const deltas: string[] = [];
- const result = await streamAskChat(
- 'hi',
- [],
- 'general',
- config,
- options,
- { onDelta: (text) => deltas.push(text) }
- );
-
- expect(deltas).toEqual(['Hello']);
- expect(result.response).toBe('Hello');
- });
-});
diff --git a/src/test/announcements.test.ts b/src/test/announcements.test.ts
deleted file mode 100644
index d3416cf31..000000000
--- a/src/test/announcements.test.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { createAnnouncement, deactivateAnnouncement } from '@/lib/announcements';
-
-const { mockSupabase } = vi.hoisted(() => ({
- mockSupabase: {
- auth: {
- getSession: vi.fn(),
- },
- },
-}));
-
-vi.mock('@/lib/supabase/client', () => ({
- supabase: mockSupabase,
- SUPABASE_URL: 'http://localhost:54321',
-}));
-
-describe('announcement API client', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- mockSupabase.auth.getSession.mockResolvedValue({
- data: { session: { access_token: 'test-token' } },
- });
- });
-
- it('passes invalid non-empty dates through for server-side validation', async () => {
- let requestBody: Record = {};
- vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
- requestBody = JSON.parse(String(init?.body ?? '{}'));
- return new Response(JSON.stringify({
- announcement: {
- id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
- title: 'Date validation',
- body: 'The server should reject invalid dates.',
- target_type: 'all',
- target_user_ids: null,
- starts_at: '2026-08-31T12:00:00+00:00',
- expires_at: null,
- deactivated_at: null,
- created_by: '99999999-9999-4999-8999-999999999999',
- created_at: '2026-08-31T12:00:00+00:00',
- },
- }), { status: 201, headers: { 'Content-Type': 'application/json' } });
- });
-
- await createAnnouncement({
- title: 'Date validation',
- body: 'The server should reject invalid dates.',
- targetType: 'all',
- startsAt: 'not-a-date',
- expiresAt: 'also-not-a-date',
- });
-
- expect(requestBody.startsAt).toBe('not-a-date');
- expect(requestBody.expiresAt).toBe('also-not-a-date');
- });
-
- it('uses the non-destructive deactivation action and parses the updated row', async () => {
- let requestUrl = '';
- let requestBody: Record = {};
- vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
- requestUrl = String(input);
- requestBody = JSON.parse(String(init?.body ?? '{}'));
- return new Response(JSON.stringify({
- announcement: {
- id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
- title: 'Scheduled maintenance',
- body: 'DebateLab will be unavailable briefly.',
- target_type: 'all',
- target_user_ids: null,
- starts_at: '2026-08-31T12:00:00+00:00',
- expires_at: null,
- deactivated_at: '2026-08-31T19:30:00+00:00',
- created_by: '99999999-9999-4999-8999-999999999999',
- created_at: '2026-08-31T12:00:00+00:00',
- },
- }), { headers: { 'Content-Type': 'application/json' } });
- });
-
- const result = await deactivateAnnouncement('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee');
-
- expect(requestUrl).toContain('?action=deactivate-announcement');
- expect(requestBody).toEqual({ announcementId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' });
- expect(result.deactivatedAt).toBe('2026-08-31T19:30:00+00:00');
- });
-});
diff --git a/src/test/app-route-analytics-error-boundary.test.tsx b/src/test/app-route-analytics-error-boundary.test.tsx
deleted file mode 100644
index 6ac0c0efd..000000000
--- a/src/test/app-route-analytics-error-boundary.test.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import type { ReactNode } from "react";
-import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { getRouteFallback, LocalizedRouteErrorBoundary, RouteErrorBoundary, ToolRouteAnalytics } from "@/App";
-import { RequireAuth } from "@/components/auth/RequireAuth";
-import { filterObservabilityBreadcrumb } from "@/lib/observability";
-import i18n from "@/i18n";
-
-const observability = vi.hoisted(() => ({
- captureException: vi.fn(),
- captureProductEvent: vi.fn(),
-}));
-
-const authState = vi.hoisted(() => ({
- loading: false,
- user: null as object | null,
-}));
-
-vi.mock("@/lib/observability", async (importOriginal) => ({
- ...(await importOriginal()),
- ...observability,
-}));
-vi.mock("@/lib/supabase/AuthProvider", async () => {
- const React = await import("react");
- return {
- AuthProvider: ({ children }: { children: ReactNode }) => React.createElement(React.Fragment, null, children),
- useAuth: () => authState,
- };
-});
-
-const toolRoutes = [
- ["/app/generate", "generate"],
- ["/app/attack", "attack"],
- ["/app/brainstorm", "brainstorm"],
- ["/app/factoids", "factoids"],
- ["/app/ask", "ask"],
- ["/app/crossfire", "crossfire"],
- ["/app/history", "history"],
- ["/app/library", "library"],
- ["/app/timer", "timer"],
- ["/app/sparring", "sparring"],
- ["/app/flowchart", "flowchart"],
- ["/app/settings", "settings"],
-] as const;
-
-const routeFallbacks = [
- ["/", "public", "DebateLab"],
- ["/app", "home-grid", "Home"],
- ["/app/attack", "tool-form", "Attack"],
- ["/app/ask", "chat", "Ask"],
- ["/app/settings", "settings/list", "Settings"],
- ["/app/admin", "admin/table", "Admin"],
- ["/share/demo-code", "public", "Shared debate"],
- ["/share/demo-code/", "public", "Shared debate"],
- ["/share/demo-code//", "public", "Shared debate"],
- ["/share/demo-code/extra", "public", "Page"],
- ["/share/", "public", "Page"],
- ["/share", "public", "Page"],
- ["/missing", "public", "Page"],
-] as const;
-
-describe("route fallback selection", () => {
- it.each(routeFallbacks)("maps %s to the %s skeleton with %s label", (pathname, variant, label) => {
- expect(getRouteFallback(pathname)).toMatchObject({ variant, label });
- });
-});
-
-describe("App route observability", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- authState.loading = false;
- authState.user = null;
- });
-
- it.each(toolRoutes)("tracks %s as the safe tool enum", (pathname, tool) => {
- render(
-
-
- ,
- );
-
- expect(observability.captureProductEvent).toHaveBeenCalledWith("tool_opened", { tool });
- });
-
- it.each(["/app", "/app/unknown", "/app/generate/private-case"])(
- "does not track %s as a tool",
- (pathname) => {
- render(
-
-
- ,
- );
-
- expect(observability.captureProductEvent).not.toHaveBeenCalled();
- },
- );
-
- it("tracks a recognized tool route with a trailing slash", () => {
- render(
-
-
- ,
- );
-
- expect(observability.captureProductEvent).toHaveBeenCalledWith("tool_opened", { tool: "generate" });
- });
-
- it("does not track a protected tool when authentication redirects to login", async () => {
- render(
-
-
-
-
-
- )}
- />
- Login page} />
-
- ,
- );
-
- await waitFor(() => expect(screen.getByText("Login page")).toBeInTheDocument());
- expect(observability.captureProductEvent).not.toHaveBeenCalled();
- });
-
- it("tracks tool openings from location changes without path data", () => {
- function Navigation() {
- const navigate = useNavigate();
-
- return (
- <>
-
-
-
- >
- );
- }
-
- render(
-
-
- } />
-
- ,
- );
-
- expect(observability.captureProductEvent).not.toHaveBeenCalled();
-
- fireEvent.click(screen.getByRole("button", { name: "Open generate" }));
- expect(observability.captureProductEvent).toHaveBeenLastCalledWith("tool_opened", { tool: "generate" });
-
- fireEvent.click(screen.getByRole("button", { name: "Open ask" }));
- expect(observability.captureProductEvent).toHaveBeenLastCalledWith("tool_opened", { tool: "ask" });
- expect(observability.captureProductEvent.mock.calls).toEqual([
- ["tool_opened", { tool: "generate" }],
- ["tool_opened", { tool: "ask" }],
- ]);
- });
-
- it("captures route errors with a fixed, non-sensitive boundary context", () => {
- let error = new Error("route failed");
- const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
- const suppressWindowError = (event: ErrorEvent) => event.preventDefault();
- window.addEventListener("error", suppressWindowError);
-
- function ExplodingRoute(): ReactNode {
- error = new Error("route failed");
- throw error;
- }
-
- try {
- render(
-
-
- ,
- );
-
- expect(screen.getByRole("heading", { name: "Unable to load this page" })).toBeInTheDocument();
- expect(observability.captureException).toHaveBeenCalledWith(
- expect.objectContaining({ name: "Error", message: "Route rendering failed" }),
- { boundary: "route" },
- );
- expect(JSON.stringify(observability.captureException.mock.calls)).not.toContain(error.message);
- const captured = observability.captureException.mock.calls[0][0] as Error;
- expect(captured.stack).toMatch(/^Error: Route rendering failed\n/);
- expect(captured.stack).toContain("ExplodingRoute");
- expect(captured.stack).not.toContain(error.message);
- expect(observability.captureException.mock.calls[0][1]).not.toHaveProperty("componentStack");
- expect(JSON.stringify(consoleError.mock.calls)).not.toContain(error.message);
- const retainedBreadcrumbs = consoleError.mock.calls
- .map((args) => filterObservabilityBreadcrumb({ category: "console", level: "error", data: { arguments: args } }))
- .filter((breadcrumb) => breadcrumb !== null);
- expect(retainedBreadcrumbs.flatMap((breadcrumb) => breadcrumb.data?.arguments ?? [])).not.toContain(error);
- expect(JSON.stringify(retainedBreadcrumbs)).not.toContain("at ExplodingRoute");
- } finally {
- window.removeEventListener("error", suppressWindowError);
- consoleError.mockRestore();
- }
- });
-
- it("renders localized Hungarian recovery actions", async () => {
- await i18n.changeLanguage('hu');
- const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
- const suppressWindowError = (event: ErrorEvent) => event.preventDefault();
- window.addEventListener("error", suppressWindowError);
-
- function ExplodingRoute(): ReactNode {
- throw new Error('route failed');
- }
-
- try {
- render(
-
-
- ,
- );
-
- expect(screen.getByRole("heading", { name: "Az oldal nem tölthető be" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Újratöltés" })).toBeInTheDocument();
- } finally {
- window.removeEventListener("error", suppressWindowError);
- consoleError.mockRestore();
- await i18n.changeLanguage('en');
- }
- });
-});
diff --git a/src/test/auth/AccountQueryCache.test.tsx b/src/test/auth/AccountQueryCache.test.tsx
deleted file mode 100644
index 640c3e09b..000000000
--- a/src/test/auth/AccountQueryCache.test.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { render, waitFor } from "@testing-library/react";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-const auth = vi.hoisted(() => ({ userId: "user-a" as string | null }));
-vi.mock("@/lib/supabase/AuthProvider", () => ({
- useAuth: () => ({ user: auth.userId ? { id: auth.userId } : null }),
-}));
-
-import { AccountQueryCache } from "@/components/auth/AccountQueryCache";
-
-describe("account query cache", () => {
- beforeEach(() => { auth.userId = "user-a"; });
-
- it("removes prior account data when the authenticated identity changes", async () => {
- const client = new QueryClient();
- client.setQueryData(["workspace", "user-a", "secret"], { motion: "private" });
- const view = render(
- ,
- );
-
- auth.userId = "user-b";
- view.rerender(
- ,
- );
-
- await waitFor(() => expect(client.getQueryCache().getAll()).toHaveLength(0));
- });
-});
diff --git a/src/test/auth/AuthFlow.test.tsx b/src/test/auth/AuthFlow.test.tsx
deleted file mode 100644
index e4d75e16e..000000000
--- a/src/test/auth/AuthFlow.test.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-
-import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
-import { MemoryRouter, Routes, Route } from 'react-router-dom';
-import { AuthProvider } from '@/lib/supabase/AuthProvider';
-import { RequireAuth } from '@/components/auth/RequireAuth';
-import { supabase } from '@/lib/supabase/client';
-
-// Mock Supabase client
-vi.mock('@/lib/supabase/client', () => ({
- setActiveAccessToken: vi.fn(),
- supabase: {
- auth: {
- getSession: vi.fn(),
- onAuthStateChange: vi.fn(() => ({ data: { subscription: { unsubscribe: vi.fn() } } })),
- signInWithOAuth: vi.fn(),
- signOut: vi.fn(),
- },
- },
-}));
-
-// Mock stores to prevent errors during hydration
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: vi.fn(() => vi.fn()),
-}));
-vi.mock('@/lib/stores/settings-store', () => ({
- useSettingsStore: vi.fn(() => vi.fn()),
-}));
-
-// Mock child components
-const ProtectedComponent = () => ]Protected Content
;
-const LoginComponent = () => Login Page
;
-
-describe('Auth Flow Integration', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it('redirects unauthenticated user to login', async () => {
- // Mock no session
- (supabase.auth.getSession as Mock).mockResolvedValue({ data: { session: null } });
-
- render(
-
-
-
- } />
-
-
-
- }
- />
-
-
-
- );
-
- // Initial loading state might be present, wait for redirection
- await waitFor(() => {
- expect(screen.getByText('Login Page')).toBeInTheDocument();
- });
- expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
- });
-
- it('allows authenticated user to access protected route', async () => {
- // Mock valid session
- const mockSession = { user: { id: 'user-123' }, access_token: 'fake-token' };
- (supabase.auth.getSession as Mock).mockResolvedValue({ data: { session: mockSession } });
-
- render(
-
-
-
- } />
-
-
-
- }
- />
-
-
-
- );
-
- // Wait for content to appear
- await waitFor(() => {
- expect(screen.getByText('Protected Content')).toBeInTheDocument();
- });
- expect(screen.queryByText('Login Page')).not.toBeInTheDocument();
- });
-});
diff --git a/src/test/auth/LoginPage.test.tsx b/src/test/auth/LoginPage.test.tsx
deleted file mode 100644
index c9429bd27..000000000
--- a/src/test/auth/LoginPage.test.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-type LoginAuthMock = {
- user: null;
- signInWithGoogle: ReturnType;
- signInWithEmailOtp: ReturnType;
- verifyEmailOtp: ReturnType;
- signInWithDemoCode: ReturnType;
-};
-
-const { authMock, toastMock } = vi.hoisted((): { authMock: LoginAuthMock; toastMock: ReturnType } => ({
- authMock: {
- user: null,
- signInWithGoogle: vi.fn(),
- signInWithEmailOtp: vi.fn(),
- verifyEmailOtp: vi.fn(),
- signInWithDemoCode: vi.fn(),
- },
- toastMock: vi.fn(),
-}));
-
-vi.mock('@/lib/supabase/AuthProvider', () => ({
- useAuth: () => authMock,
-}));
-
-vi.mock('@/hooks/use-toast', () => ({
- useToast: () => ({ toast: toastMock }),
-}));
-
-vi.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string, fallback?: string) => fallback || key,
- }),
-}));
-
-import LoginPage from '@/pages/LoginPage';
-
-function renderLogin() {
- return render(
-
-
- ,
- );
-}
-
-describe('LoginPage passwordless auth', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- authMock.user = null;
- authMock.signInWithEmailOtp.mockResolvedValue(undefined);
- authMock.verifyEmailOtp.mockResolvedValue(undefined);
- authMock.signInWithDemoCode.mockResolvedValue(undefined);
- });
-
- it('keeps Google as the primary sign-in action and offers email and demo code paths', () => {
- renderLogin();
-
- expect(screen.getByRole('button', { name: 'login.google' })).toBeInTheDocument();
- expect(screen.getByRole('tab', { name: 'Email' })).toBeInTheDocument();
- expect(screen.getByRole('tab', { name: 'Demo code' })).toBeInTheDocument();
- });
-
- it('sends an email OTP and moves focus to the verification code', async () => {
- renderLogin();
- fireEvent.click(screen.getByRole('tab', { name: 'Email' }));
- fireEvent.change(screen.getByLabelText('Email address'), { target: { value: 'student@example.com' } });
- fireEvent.click(screen.getByRole('button', { name: 'Send code' }));
-
- await waitFor(() => {
- expect(authMock.signInWithEmailOtp).toHaveBeenCalledWith('student@example.com');
- expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus();
- });
- expect(screen.getByText('Code sent')).toBeInTheDocument();
- });
-
- it('shows verifying and success states while checking an email OTP', async () => {
- let resolveVerification: (() => void) | undefined;
- authMock.verifyEmailOtp.mockReturnValue(new Promise((resolve) => {
- resolveVerification = resolve;
- }));
-
- renderLogin();
- fireEvent.click(screen.getByRole('tab', { name: 'Email' }));
- fireEvent.change(screen.getByLabelText('Email address'), { target: { value: 'student@example.com' } });
- fireEvent.click(screen.getByRole('button', { name: 'Send code' }));
- await screen.findByText('Code sent');
- fireEvent.change(screen.getByRole('textbox', { name: 'Verification code' }), { target: { value: '123456' } });
- fireEvent.click(screen.getByRole('button', { name: 'Verify code' }));
-
- expect(screen.getByText('Verifying...')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Verifying...' })).toBeDisabled();
-
- resolveVerification?.();
- await waitFor(() => expect(screen.getByText('Signed in')).toBeInTheDocument());
- });
-
- it('rejects malformed OTPs and supports resending', async () => {
- renderLogin();
- fireEvent.click(screen.getByRole('tab', { name: 'Email' }));
- fireEvent.change(screen.getByLabelText('Email address'), { target: { value: 'student@example.com' } });
- fireEvent.click(screen.getByRole('button', { name: 'Send code' }));
- await screen.findByText('Code sent');
-
- fireEvent.change(screen.getByRole('textbox', { name: 'Verification code' }), { target: { value: '123' } });
- fireEvent.click(screen.getByRole('button', { name: 'Verify code' }));
- expect(screen.getByRole('alert')).toHaveTextContent('Enter a valid six-digit code.');
- expect(authMock.verifyEmailOtp).not.toHaveBeenCalled();
-
- fireEvent.click(screen.getByRole('button', { name: 'Resend code' }));
- await waitFor(() => expect(authMock.signInWithEmailOtp).toHaveBeenCalledTimes(2));
- });
-
- it('shows demo code errors and returns to an editable idle form', async () => {
- authMock.signInWithDemoCode.mockRejectedValue(new Error('This demo code has expired.'));
-
- renderLogin();
- fireEvent.mouseDown(screen.getByRole('tab', { name: 'Demo code' }));
- await waitFor(() => expect(screen.getByRole('tab', { name: 'Demo code' })).toHaveAttribute('aria-selected', 'true'));
- fireEvent.change(screen.getByRole('textbox', { name: 'Demo code' }), { target: { value: ' demo-ab12-cd34 ' } });
- fireEvent.click(screen.getByRole('button', { name: 'Sign in with demo code' }));
-
- await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('This demo code has expired.'));
- expect(authMock.signInWithDemoCode).toHaveBeenCalledWith('DEMO-AB12-CD34');
- expect(screen.getByRole('textbox', { name: 'Demo code' })).toBeEnabled();
- });
-});
diff --git a/src/test/components/AdminAnnouncements.test.tsx b/src/test/components/AdminAnnouncements.test.tsx
deleted file mode 100644
index bd7d8f533..000000000
--- a/src/test/components/AdminAnnouncements.test.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { AdminAnnouncements } from '@/components/admin/AdminAnnouncements';
-import type { AdminAnnouncement } from '@/lib/announcements';
-import i18n from '@/i18n';
-
-const {
- mockCreateAnnouncement,
- mockDeactivateAnnouncement,
- mockDeleteAnnouncement,
- mockListAdminAnnouncements,
- mockToast,
-} = vi.hoisted(() => ({
- mockCreateAnnouncement: vi.fn(),
- mockDeactivateAnnouncement: vi.fn(),
- mockDeleteAnnouncement: vi.fn(),
- mockListAdminAnnouncements: vi.fn(),
- mockToast: vi.fn(),
-}));
-
-vi.mock('@/lib/announcements', async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- createAnnouncement: mockCreateAnnouncement,
- deactivateAnnouncement: mockDeactivateAnnouncement,
- deleteAnnouncement: mockDeleteAnnouncement,
- listAdminAnnouncements: mockListAdminAnnouncements,
- };
-});
-
-vi.mock('@/hooks/use-toast', () => ({
- useToast: () => ({ toast: mockToast }),
-}));
-
-vi.mock('@/components/ui/select', () => ({
- Select: ({
- children,
- onValueChange,
- value,
- }: {
- children: React.ReactNode;
- onValueChange: (value: string) => void;
- value: string;
- }) => (
-
- ),
- SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}>,
- SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => (
-
- ),
- SelectTrigger: (): null => null,
- SelectValue: (): null => null,
-}));
-
-describe('AdminAnnouncements', () => {
- beforeEach(async () => {
- vi.clearAllMocks();
- await i18n.changeLanguage('en');
- Element.prototype.scrollIntoView = vi.fn();
- mockListAdminAnnouncements.mockResolvedValue([]);
- mockCreateAnnouncement.mockResolvedValue({
- id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
- title: 'Targeted note',
- body: 'This message is for one user.',
- targetType: 'users',
- targetUserIds: ['11111111-2222-4333-8444-555555555555'],
- startsAt: '2026-08-31T12:00:00.000Z',
- expiresAt: null,
- deactivatedAt: null,
- createdBy: 'admin-id',
- createdAt: '2026-08-31T12:00:00.000Z',
- });
- });
-
- it('creates a targeted announcement with selected user IDs', async () => {
- render(
-
- );
-
- await screen.findByText('No announcements yet');
- fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Targeted note' } });
- fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'This message is for one user.' } });
- fireEvent.change(screen.getByLabelText('Audience'), { target: { value: 'users' } });
- fireEvent.click(screen.getByRole('checkbox', { name: 'Select selected@example.com' }));
- fireEvent.click(screen.getByRole('button', { name: 'Create announcement' }));
-
- await waitFor(() => expect(mockCreateAnnouncement).toHaveBeenCalledWith({
- title: 'Targeted note',
- body: 'This message is for one user.',
- targetType: 'users',
- targetUserIds: ['11111111-2222-4333-8444-555555555555'],
- startsAt: undefined,
- expiresAt: undefined,
- }));
- });
-
- it('localizes recipient roles in Hungarian', async () => {
- await i18n.changeLanguage('hu');
- render(
-
- );
-
- fireEvent.change(screen.getByLabelText('Audience'), { target: { value: 'users' } });
- expect(await screen.findByText('Felhasználó')).toBeInTheDocument();
- });
-
- it('deactivates an announcement without removing it from history', async () => {
- const activeAnnouncement: AdminAnnouncement = {
- id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
- title: 'Scheduled maintenance',
- body: 'DebateLab will be unavailable briefly.',
- targetType: 'all',
- targetUserIds: null,
- startsAt: '2026-08-31T12:00:00.000Z',
- expiresAt: null,
- deactivatedAt: null,
- createdBy: 'admin-id',
- createdAt: '2026-08-31T12:00:00.000Z',
- };
- mockListAdminAnnouncements.mockResolvedValue([activeAnnouncement]);
- let resolveDeactivation: (announcement: AdminAnnouncement) => void = () => undefined;
- mockDeactivateAnnouncement.mockImplementation(() => new Promise((resolve) => {
- resolveDeactivation = resolve;
- }));
-
- render();
-
- await screen.findByText('Scheduled maintenance');
- fireEvent.click(screen.getByRole('button', { name: 'Deactivate announcement: Scheduled maintenance' }));
- fireEvent.click(screen.getByRole('button', { name: 'Deactivate announcement' }));
-
- await waitFor(() => expect(mockDeactivateAnnouncement).toHaveBeenCalledWith(activeAnnouncement.id));
- expect(screen.getByRole('heading', { name: 'Deactivate this announcement?' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Deactivate announcement' })).toBeDisabled();
-
- resolveDeactivation({
- ...activeAnnouncement,
- deactivatedAt: '2026-08-31T19:30:00.000Z',
- });
-
- await waitFor(() => {
- expect(screen.queryByRole('heading', { name: 'Deactivate this announcement?' })).not.toBeInTheDocument();
- });
- expect(screen.getByText('Scheduled maintenance')).toBeInTheDocument();
- expect(screen.getByText('Deactivated')).toBeInTheDocument();
- });
-
- it('localizes both confirmation dialogs in Hungarian', async () => {
- await i18n.changeLanguage('hu');
- const announcement: AdminAnnouncement = {
- id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
- title: 'Karbantartás',
- body: 'A szolgáltatás rövid ideig nem lesz elérhető.',
- targetType: 'all',
- targetUserIds: null,
- startsAt: '2026-08-31T12:00:00.000Z',
- expiresAt: null,
- deactivatedAt: null,
- createdBy: 'admin-id',
- createdAt: '2026-08-31T12:00:00.000Z',
- };
- mockListAdminAnnouncements.mockResolvedValue([announcement]);
-
- render();
-
- await screen.findByText('Karbantartás');
- fireEvent.click(screen.getByRole('button', { name: 'Közlemény kikapcsolása: Karbantartás' }));
- expect(screen.getByRole('heading', { name: 'Kikapcsolod ezt a közleményt?' })).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Mégse' }));
-
- fireEvent.click(screen.getByRole('button', { name: 'Közlemény törlése: Karbantartás' }));
- expect(screen.getByRole('heading', { name: 'Törlöd ezt a közleményt?' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Közlemény törlése' })).toBeInTheDocument();
- });
-});
diff --git a/src/test/components/AnimatedSection.test.tsx b/src/test/components/AnimatedSection.test.tsx
deleted file mode 100644
index cc6483671..000000000
--- a/src/test/components/AnimatedSection.test.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen, act } from '@testing-library/react';
-import { AnimatedSection, StaggeredContainer } from '@/components/ui/animated-section';
-
-describe('AnimatedSection', () => {
- beforeEach(() => {
- vi.useFakeTimers();
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- it('renders children', () => {
- render(Hello);
- expect(screen.getByText('Hello')).toBeDefined();
- });
-
- it('starts with opacity-0 class', () => {
- const { container } = render(Test);
- const div = container.firstElementChild as HTMLElement;
- expect(div.className).toContain('opacity-0');
- });
-
- it('becomes visible after delay', () => {
- const { container } = render(Test);
- act(() => { vi.advanceTimersByTime(100); });
- const div = container.firstElementChild as HTMLElement;
- expect(div.className).toContain('opacity-100');
- });
-
- it('becomes visible immediately with 0 delay', () => {
- const { container } = render(Test);
- act(() => { vi.advanceTimersByTime(0); });
- const div = container.firstElementChild as HTMLElement;
- expect(div.className).toContain('opacity-100');
- });
-
- it('applies custom className', () => {
- const { container } = render(Test);
- const div = container.firstElementChild as HTMLElement;
- expect(div.className).toContain('my-class');
- });
-
- it('has transition classes', () => {
- const { container } = render(Test);
- const div = container.firstElementChild as HTMLElement;
- expect(div.className).toContain('transition-all');
- });
-
- it('has translate-y-2 before visible', () => {
- const { container } = render(Test);
- const div = container.firstElementChild as HTMLElement;
- expect(div.className).toContain('translate-y-2');
- });
-
- it('has translate-y-0 after visible', () => {
- const { container } = render(Test);
- act(() => { vi.advanceTimersByTime(100); });
- const div = container.firstElementChild as HTMLElement;
- expect(div.className).toContain('translate-y-0');
- });
-});
-
-describe('StaggeredContainer', () => {
- it('renders children', () => {
- render(Child 1Child 2);
- expect(screen.getByText('Child 1')).toBeDefined();
- expect(screen.getByText('Child 2')).toBeDefined();
- });
-
- it('applies staggered-container class', () => {
- const { container } = render(Test);
- expect(container.querySelector('.staggered-container')).not.toBeNull();
- });
-
- it('applies custom className', () => {
- const { container } = render(Test);
- const div = container.querySelector('.staggered-container') as HTMLElement;
- expect(div.className).toContain('extra');
- });
-
- it('sets --stagger-ms CSS variable', () => {
- const { container } = render(Test);
- const div = container.querySelector('.staggered-container') as HTMLElement;
- expect(div.style.getPropertyValue('--stagger-ms')).toBe('300ms');
- });
-
- it('uses a short default stagger for dense result lists', () => {
- const { container } = render(Test);
- const div = container.querySelector('.staggered-container') as HTMLElement;
- expect(div.style.getPropertyValue('--stagger-ms')).toBe('60ms');
- });
-
- it('renders style element', () => {
- const { container } = render(Test);
- const style = container.querySelector('style');
- expect(style).not.toBeNull();
- });
-});
diff --git a/src/test/components/AnnouncementBanner.test.tsx b/src/test/components/AnnouncementBanner.test.tsx
deleted file mode 100644
index 5b798b792..000000000
--- a/src/test/components/AnnouncementBanner.test.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import i18n from '@/i18n';
-import { AnnouncementBanner } from '@/components/layout/AnnouncementBanner';
-import type { UserAnnouncement } from '@/lib/announcements';
-
-const announcement: UserAnnouncement = {
- id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
- title: 'Karbantartás',
- body: 'A DebateLab rövid ideig nem lesz elérhető.',
- startsAt: '2026-08-31T12:00:00.000Z',
- expiresAt: null,
- createdAt: '2026-08-31T12:00:00.000Z',
-};
-
-describe('AnnouncementBanner localization', () => {
- afterEach(async () => {
- await i18n.changeLanguage('en');
- });
-
- it('localizes its region, position, and dismiss control', async () => {
- await i18n.changeLanguage('hu');
-
- render(
- ,
- );
-
- expect(screen.getByRole('region', { name: 'Közlemény' })).toBeInTheDocument();
- expect(screen.getByText('1 / 2')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Közlemény bezárása: Karbantartás' })).toBeInTheDocument();
- });
-});
diff --git a/src/test/components/AppLayout.test.tsx b/src/test/components/AppLayout.test.tsx
deleted file mode 100644
index b81632947..000000000
--- a/src/test/components/AppLayout.test.tsx
+++ /dev/null
@@ -1,326 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
-import { Link, MemoryRouter, Route, Routes } from 'react-router-dom';
-import { AppLayout } from '@/components/layout/AppLayout';
-import type { UserAnnouncement } from '@/lib/announcements';
-import i18n from '@/i18n';
-import { resetDebates, seedCurrentDebate } from '@/test/helpers/debate';
-
-const { mockAuth, mockDismissAnnouncement, mockListActiveAnnouncements } = vi.hoisted(() => ({
- mockAuth: { user: { id: 'user-a' } },
- mockDismissAnnouncement: vi.fn(),
- mockListActiveAnnouncements: vi.fn(),
-}));
-
-vi.mock('@/lib/supabase/AuthProvider', () => ({
- useAuth: () => mockAuth,
-}));
-
-vi.mock('@/lib/announcements', async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- dismissAnnouncement: mockDismissAnnouncement,
- listActiveAnnouncements: mockListActiveAnnouncements,
- };
-});
-
-const announcement: UserAnnouncement = {
- id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
- title: 'Scheduled maintenance',
- body: 'DebateLab will be unavailable for ten minutes.',
- startsAt: '2026-08-31T12:00:00.000Z',
- expiresAt: null,
- createdAt: '2026-08-31T11:00:00.000Z',
-};
-
-function layout() {
- return (
-
-
- }>
- Open next page} />
- Next page
} />
-
-
-
- );
-}
-
-function renderLayout() {
- return render(layout());
-}
-
-describe('AppLayout announcements', () => {
- beforeEach(async () => {
- vi.resetAllMocks();
- await i18n.changeLanguage('en');
- mockAuth.user = { id: 'user-a' };
- mockListActiveAnnouncements.mockResolvedValue([announcement]);
- mockDismissAnnouncement.mockResolvedValue(undefined);
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- it('shows the active announcement and persists dismissal', async () => {
- renderLayout();
-
- expect(await screen.findByText('Scheduled maintenance')).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Dismiss announcement: Scheduled maintenance' }));
-
- await waitFor(() => expect(mockDismissAnnouncement).toHaveBeenCalledWith(announcement.id));
- expect(screen.queryByText('Scheduled maintenance')).not.toBeInTheDocument();
- });
-
- it('keeps announcements stable across sibling routes', async () => {
- renderLayout();
- await screen.findByText('Scheduled maintenance');
-
- fireEvent.click(screen.getByRole('link', { name: 'Open next page' }));
-
- await screen.findByText('Next page');
- expect(mockListActiveAnnouncements).toHaveBeenCalledTimes(1);
- });
-
- it('retries announcements on navigation after an active load failure', async () => {
- const recoveredAnnouncement = { ...announcement, title: 'Recovered announcement' };
- mockListActiveAnnouncements
- .mockRejectedValueOnce(new Error('Network unavailable'))
- .mockResolvedValueOnce([recoveredAnnouncement]);
-
- renderLayout();
- await act(async () => {
- await Promise.resolve();
- });
- expect(mockListActiveAnnouncements).toHaveBeenCalledTimes(1);
-
- fireEvent.click(screen.getByRole('link', { name: 'Open next page' }));
-
- expect(await screen.findByText('Recovered announcement')).toBeInTheDocument();
- expect(mockListActiveAnnouncements).toHaveBeenCalledTimes(2);
- });
-
- it('keeps a dismissed announcement absent across sibling routes', async () => {
- renderLayout();
-
- await screen.findByText('Scheduled maintenance');
- fireEvent.click(screen.getByRole('button', { name: 'Dismiss announcement: Scheduled maintenance' }));
-
- await waitFor(() => expect(mockDismissAnnouncement).toHaveBeenCalledWith(announcement.id));
- fireEvent.click(screen.getByRole('link', { name: 'Open next page' }));
-
- await screen.findByText('Next page');
- expect(screen.queryByText('Scheduled maintenance')).not.toBeInTheDocument();
- expect(mockListActiveAnnouncements).toHaveBeenCalledTimes(1);
- });
-
- it('shows dismissal errors without dropping the announcement', async () => {
- mockDismissAnnouncement.mockRejectedValue(new Error('Could not dismiss the announcement'));
-
- renderLayout();
- await screen.findByText('Scheduled maintenance');
- fireEvent.click(screen.getByRole('button', { name: 'Dismiss announcement: Scheduled maintenance' }));
-
- expect(await screen.findByText('Could not dismiss the announcement')).toBeInTheDocument();
- expect(screen.getByText('Scheduled maintenance')).toBeInTheDocument();
- });
-
- it('loads current announcements again after a deliberate remount', async () => {
- const currentAnnouncement: UserAnnouncement = {
- ...announcement,
- id: 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff',
- title: 'Current announcement',
- body: 'This message is current after refresh.',
- };
- mockListActiveAnnouncements
- .mockResolvedValueOnce([announcement])
- .mockResolvedValueOnce([currentAnnouncement]);
-
- const firstRender = renderLayout();
- await screen.findByText('Scheduled maintenance');
- firstRender.unmount();
- renderLayout();
-
- expect(await screen.findByText('Current announcement')).toBeInTheDocument();
- expect(mockListActiveAnnouncements).toHaveBeenCalledTimes(2);
- });
-
- it('reloads announcements and clears dismissal state when the user changes', async () => {
- const userAnnouncement = { ...announcement, title: 'User A announcement' };
- const nextUserAnnouncement = { ...announcement, title: 'User B announcement' };
- mockListActiveAnnouncements
- .mockResolvedValueOnce([userAnnouncement])
- .mockResolvedValueOnce([nextUserAnnouncement]);
-
- const rendered = renderLayout();
- await screen.findByText('User A announcement');
- fireEvent.click(screen.getByRole('button', { name: 'Dismiss announcement: User A announcement' }));
- await waitFor(() => expect(mockDismissAnnouncement).toHaveBeenCalledWith(announcement.id));
-
- mockAuth.user = { id: 'user-b' };
- rendered.rerender(layout());
-
- expect(await screen.findByText('User B announcement')).toBeInTheDocument();
- expect(mockListActiveAnnouncements).toHaveBeenCalledTimes(2);
- });
-
- it('ignores a late announcement response from the previous user', async () => {
- let resolveUserA: (value: UserAnnouncement[]) => void = () => undefined;
- const userAResponse = new Promise((resolve) => {
- resolveUserA = resolve;
- });
- const userBAnnouncement = { ...announcement, title: 'User B announcement' };
- mockListActiveAnnouncements
- .mockReturnValueOnce(userAResponse)
- .mockResolvedValueOnce([userBAnnouncement]);
-
- const rendered = renderLayout();
- mockAuth.user = { id: 'user-b' };
- rendered.rerender(layout());
-
- expect(await screen.findByText('User B announcement')).toBeInTheDocument();
- await act(async () => {
- resolveUserA([{ ...announcement, title: 'User A announcement' }]);
- await userAResponse;
- });
-
- expect(screen.queryByText('User A announcement')).not.toBeInTheDocument();
- expect(screen.getByText('User B announcement')).toBeInTheDocument();
- expect(mockListActiveAnnouncements).toHaveBeenCalledTimes(2);
- });
-
- it('does not show the previous user announcement while the next user loads', async () => {
- let resolveUserB: (value: UserAnnouncement[]) => void = () => undefined;
- const userBResponse = new Promise((resolve) => {
- resolveUserB = resolve;
- });
- mockListActiveAnnouncements
- .mockResolvedValueOnce([{ ...announcement, title: 'User A announcement' }])
- .mockReturnValueOnce(userBResponse);
-
- const rendered = renderLayout();
- await screen.findByText('User A announcement');
- mockAuth.user = { id: 'user-b' };
- rendered.rerender(layout());
-
- expect(screen.queryByText('User A announcement')).not.toBeInTheDocument();
- await act(async () => {
- resolveUserB([{ ...announcement, title: 'User B announcement' }]);
- await userBResponse;
- });
- expect(screen.getByText('User B announcement')).toBeInTheDocument();
- });
-
- it('hides an expired announcement while the user stays on the same route', async () => {
- vi.useFakeTimers();
- vi.setSystemTime(new Date('2026-09-20T12:00:00.000Z'));
- const expiringAnnouncement = {
- ...announcement,
- expiresAt: '2026-09-20T12:00:10.000Z',
- };
- mockListActiveAnnouncements.mockResolvedValue([expiringAnnouncement]);
-
- renderLayout();
- await act(async () => {
- await Promise.resolve();
- });
- expect(screen.getByText('Scheduled maintenance')).toBeInTheDocument();
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(11_000);
- });
-
- expect(screen.queryByText('Scheduled maintenance')).not.toBeInTheDocument();
- expect(mockListActiveAnnouncements).toHaveBeenCalledTimes(1);
- });
-
- it('refetches after the stale window and removes a deactivated announcement', async () => {
- vi.useFakeTimers();
- vi.setSystemTime(new Date('2026-09-20T12:00:00.000Z'));
- mockListActiveAnnouncements
- .mockResolvedValueOnce([announcement])
- .mockResolvedValueOnce([]);
-
- renderLayout();
- await act(async () => {
- await Promise.resolve();
- });
- expect(screen.getByText('Scheduled maintenance')).toBeInTheDocument();
-
- vi.setSystemTime(new Date('2026-09-20T12:01:01.000Z'));
- fireEvent.click(screen.getByRole('link', { name: 'Open next page' }));
- await act(async () => {
- await Promise.resolve();
- });
-
- expect(screen.queryByText('Scheduled maintenance')).not.toBeInTheDocument();
- expect(mockListActiveAnnouncements).toHaveBeenCalledTimes(2);
- });
-
- it('keeps features out of the header and puts Current debate after Home', async () => {
- seedCurrentDebate({ motion: 'This House would ban homework' });
- renderLayout();
- await screen.findByText('Scheduled maintenance');
-
- const mainNavigation = screen.getByRole('navigation', { name: 'Main navigation' });
- const links = within(mainNavigation).getAllByRole('link').map((link) => [link.textContent?.trim(), link.getAttribute('href')]);
- expect(links).toEqual([['Home', '/app'], ['Library', '/app/library'], ['History', '/app/history']]);
- for (const feature of ['Generate', 'Attack', 'Ask', 'Crossfire', 'Sparring', 'More']) {
- expect(within(mainNavigation).queryByText(feature)).not.toBeInTheDocument();
- }
- const control = within(mainNavigation).getByRole('button', { name: 'Current debate: This House would ban homework' });
- expect(control).toHaveTextContent('Current debate');
- // Home, then Current debate, then Library.
- const order = Array.from(mainNavigation.querySelectorAll('a, button')).map((node) => node.textContent?.trim());
- expect(order.slice(0, 3)).toEqual(['Home', 'Current debate', 'Library']);
- expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/app/settings');
-
- fireEvent.click(screen.getByRole('button', { name: 'Open menu' }));
- const mobileNavigation = screen.getByRole('navigation', { name: 'Mobile navigation' });
- expect(within(mobileNavigation).getAllByRole('link').map((link) => link.getAttribute('href')))
- .toEqual(['/app', '/app/debate', '/app/library', '/app/history', '/app/settings']);
- });
-
- it('offers Start debate when the user has no debate', async () => {
- resetDebates();
- renderLayout();
- await screen.findByText('Scheduled maintenance');
- const mainNavigation = screen.getByRole('navigation', { name: 'Main navigation' });
- expect(within(mainNavigation).getByRole('button', { name: 'Start debate' })).toBeInTheDocument();
- });
-
- it('localizes desktop and mobile navigation labels in Hungarian', async () => {
- await i18n.changeLanguage('hu');
- renderLayout();
- await screen.findByText('Scheduled maintenance');
-
- expect(screen.getByRole('navigation', { name: 'Fő navigáció' })).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Menü megnyitása' }));
- expect(screen.getByRole('navigation', { name: 'Mobil navigáció' })).toBeInTheDocument();
- expect(screen.getByAltText('DebateLab logó')).toBeInTheDocument();
- });
-
- it('closes the mobile menu on Escape and returns focus to the trigger', async () => {
- renderLayout();
- await screen.findByText('Scheduled maintenance');
-
- const menuTrigger = screen.getByRole('button', { name: 'Open menu' });
- fireEvent.click(menuTrigger);
- const mobileNavigation = screen.getByRole('navigation', { name: 'Mobile navigation' });
- within(mobileNavigation).getByRole('link', { name: /^Library/ }).focus();
- fireEvent.keyDown(document, { key: 'Escape' });
-
- expect(screen.queryByRole('navigation', { name: 'Mobile navigation' })).not.toBeInTheDocument();
- expect(menuTrigger).toHaveFocus();
- });
-
- it('keeps the app usable when announcements fail to load', async () => {
- mockListActiveAnnouncements.mockRejectedValue(new Error('Network unavailable'));
- renderLayout();
-
- expect(await screen.findByRole('link', { name: 'Open next page' })).toBeInTheDocument();
- expect(screen.queryByLabelText('Announcement')).not.toBeInTheDocument();
- });
-});
diff --git a/src/test/components/AskChat.test.tsx b/src/test/components/AskChat.test.tsx
deleted file mode 100644
index cfde71ec9..000000000
--- a/src/test/components/AskChat.test.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { fireEvent, render, screen } from '@testing-library/react';
-import { AskChat } from '@/components/features/ask/AskChat';
-import i18n from '@/i18n';
-
-vi.mock('@/hooks/use-subscription', () => ({
- useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }),
-}));
-
-describe('AskChat', () => {
- beforeEach(async () => {
- await i18n.changeLanguage('en');
- });
-
- it('sends citation and web search toggles with the message', () => {
- const onSend = vi.fn();
-
- render(
-
- );
-
- fireEvent.click(screen.getByLabelText('Citations'));
- fireEvent.click(screen.getByLabelText('Web search'));
- fireEvent.click(screen.getByLabelText('Pros/Cons'));
- fireEvent.click(screen.getByLabelText('Arg Map'));
- fireEvent.change(screen.getByPlaceholderText('Ask your debate coach...'), {
- target: { value: 'Find current examples for school phone bans' },
- });
- fireEvent.click(screen.getByLabelText('Send message'));
-
- expect(onSend).toHaveBeenCalledWith(
- 'Find current examples for school phone bans',
- 'general',
- true,
- true,
- true,
- true,
- []
- );
- });
-
- it('renders citations, argument maps, pros and cons, and uncertainties', () => {
- render(
-
- );
-
- expect(screen.getByText('Citations:')).toBeDefined();
- expect(screen.getByText('Web research used:')).toBeDefined();
- expect(screen.getByText((content) => content.includes('school phone ban evidence'))).toBeDefined();
- expect(screen.getAllByText('Verified').length).toBeGreaterThan(0);
- expect(screen.getByText('Argument map:')).toBeDefined();
- expect(screen.getByText('Phone bans improve attention')).toBeDefined();
- expect(screen.getByText((content) => content.includes('Simple to explain'))).toBeDefined();
- expect(screen.getByText((content) => content.includes('Local policy varies'))).toBeDefined();
- });
-
- it('keeps saved chats collapsed by default and loads a selected chat', () => {
- const onLoadChat = vi.fn();
-
- render(
-
- );
-
- expect(screen.queryByText('School phone bans')).toBeNull();
- fireEvent.click(screen.getByLabelText('Open saved chats'));
- fireEvent.click(screen.getByText('School phone bans'));
-
- expect(onLoadChat).toHaveBeenCalledWith('chat-1');
- });
-
- it('localizes researched source metadata in Hungarian', async () => {
- await i18n.changeLanguage('hu');
- render(
-
- );
-
- expect(screen.getByText('Kormányzati')).toBeInTheDocument();
- expect(screen.getByText('Erős')).toBeInTheDocument();
- });
-});
diff --git a/src/test/components/AttackForm.test.tsx b/src/test/components/AttackForm.test.tsx
deleted file mode 100644
index 656c3d557..000000000
--- a/src/test/components/AttackForm.test.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { describe, expect, it, vi, beforeEach } from 'vitest';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { AttackForm } from '@/components/features/attack/AttackForm';
-
-const { mockSetAttackForm } = vi.hoisted(() => ({
- mockSetAttackForm: vi.fn(),
-}));
-
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: () => ({
- attackForm: {},
- setAttackForm: mockSetAttackForm,
- }),
-}));
-
-vi.mock('@/lib/supabase/AuthProvider', () => ({
- useAuth: () => ({ user: { id: 'test' }, session: { access_token: 'test' } }),
-}));
-
-vi.mock('@/hooks/use-subscription', () => ({
- useSubscription: () => ({ tier: 'pro', canAccessProModels: true }),
-}));
-
-describe('AttackForm', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it('collapses attack options behind the options menu', () => {
- const onSubmit = vi.fn();
- const { container } = render();
-
- const options = container.querySelector('#attack-options');
- const button = screen.getByRole('button', { name: /more options/i });
-
- expect(button).toHaveAttribute('aria-expanded', 'false');
- expect(options).toHaveAttribute('data-open', 'false');
-
- fireEvent.click(button);
-
- expect(button).toHaveAttribute('aria-expanded', 'true');
- expect(options).toHaveAttribute('data-open', 'true');
- });
-
- it('submits no mercy, logic-heavy, and both toggles on by default', async () => {
- const onSubmit = vi.fn();
- render();
-
- fireEvent.change(screen.getByLabelText(/your argument/i), {
- target: { value: 'Their case has a weak link between uniqueness and impact.' },
- });
- fireEvent.click(screen.getByRole('button', { name: /find weaknesses/i }));
-
- await waitFor(() => {
- expect(onSubmit).toHaveBeenCalledWith(
- expect.objectContaining({
- aggressiveness: 'ruthless',
- judgeParadigm: 'logic',
- detectFallacies: true,
- analyzeWeighing: true,
- }),
- expect.anything()
- );
- });
- });
-});
diff --git a/src/test/components/AttackResults.test.tsx b/src/test/components/AttackResults.test.tsx
deleted file mode 100644
index 330807c25..000000000
--- a/src/test/components/AttackResults.test.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { AttackResults } from '@/components/features/attack/AttackResults';
-import type { AttackOutput } from '@/lib/ai/schemas/attack';
-import i18n from '@/i18n';
-
-vi.mock('@/components/features/export/GoogleDriveAuthModal', () => ({
- GoogleDriveAuthModal: (): null => null,
-}));
-
-const result: AttackOutput = {
- overallAssessment: { score: 50, summary: 'Összegzés', strengthAreas: [] },
- weaknesses: [{
- id: 'weakness-1',
- severity: 'major',
- type: 'logical',
- description: 'Leírás',
- whyWeak: 'Indoklás',
- howOpponentAttacks: 'Támadás',
- improvedVersion: 'Javítás',
- }],
- fallaciesDetected: [],
- crossExQuestions: [],
- weighingAnalysis: { currentWeighing: '', missingComparisons: [], improvementSuggestions: [] },
-};
-
-describe('AttackResults', () => {
- beforeEach(async () => {
- await i18n.changeLanguage('hu');
- });
-
- it('localizes weakness types in Hungarian', () => {
- render();
- expect(screen.getByText('Logikai')).toBeInTheDocument();
- expect(screen.queryByText('logical')).not.toBeInTheDocument();
- });
-});
diff --git a/src/test/components/AutoSavedSourcesUndo.test.tsx b/src/test/components/AutoSavedSourcesUndo.test.tsx
deleted file mode 100644
index ae726991b..000000000
--- a/src/test/components/AutoSavedSourcesUndo.test.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-
-const { mockToastError, mockToastSuccess, mockToastWarning, mockRemoveSource } = vi.hoisted(() => ({
- mockToastError: vi.fn(),
- mockToastSuccess: vi.fn(),
- mockToastWarning: vi.fn(),
- mockRemoveSource: vi.fn(),
-}));
-
-const sessionState = {
- sourceLibrary: [
- { id: 'source-1', folder: 'Auto-saved', debateId: '11111111-2222-4333-8444-555555555555' },
- { id: 'source-2', folder: 'Auto-saved', debateId: '11111111-2222-4333-8444-555555555555', metadata: { capturedVia: 'attack' } },
- { id: 'source-3', folder: 'Curated', debateId: '11111111-2222-4333-8444-555555555555', metadata: { capturedVia: 'generate' } },
- // Another debate's research is never undone from this one.
- { id: 'source-4', folder: 'Auto-saved', debateId: 'other-debate' },
- ],
-};
-
-vi.mock('sonner', () => ({
- toast: {
- error: mockToastError,
- success: mockToastSuccess,
- warning: mockToastWarning,
- },
-}));
-
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: (selector: (state: typeof sessionState & { removeSource: typeof mockRemoveSource }) => unknown) => selector({
- ...sessionState,
- removeSource: mockRemoveSource,
- }),
-}));
-
-import { AutoSavedSourcesUndo } from '@/components/features/AutoSavedSourcesUndo';
-import { seedCurrentDebate } from '@/test/helpers/debate';
-
-describe('AutoSavedSourcesUndo', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- seedCurrentDebate();
- mockRemoveSource.mockRejectedValue(new Error('Removal failed'));
- });
-
- it('reports zero removed sources and all failed sources when every removal fails', async () => {
- render();
-
- fireEvent.click(screen.getByRole('button', { name: 'Undo auto-saved sources' }));
-
- await waitFor(() => {
- expect(mockToastError).toHaveBeenCalledWith('Could not remove auto-saved sources.', {
- description: '0 sources were removed. 2 sources could not be removed.',
- });
- });
- });
-
- it('preserves the success outcome when every removal succeeds', async () => {
- mockRemoveSource.mockResolvedValue(undefined);
- render();
-
- fireEvent.click(screen.getByRole('button', { name: 'Undo auto-saved sources' }));
-
- await waitFor(() => {
- expect(mockToastSuccess).toHaveBeenCalledWith('Auto-saved sources removed', {
- description: '2 auto-saved source removed from your library.',
- });
- });
- });
-
- it('preserves the partial outcome when only some removals fail', async () => {
- mockRemoveSource
- .mockResolvedValueOnce(undefined)
- .mockRejectedValueOnce(new Error('Removal failed'));
- render();
-
- fireEvent.click(screen.getByRole('button', { name: 'Undo auto-saved sources' }));
-
- await waitFor(() => {
- expect(mockToastWarning).toHaveBeenCalledWith('Some sources removed', {
- description: '1 removed, 1 failed to remove.',
- });
- });
- });
-});
diff --git a/src/test/components/ExportToolbar.test.tsx b/src/test/components/ExportToolbar.test.tsx
deleted file mode 100644
index f980558e3..000000000
--- a/src/test/components/ExportToolbar.test.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { render, within } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
-import { ExportToolbar } from '@/components/features/ExportToolbar';
-
-describe('ExportToolbar', () => {
- it('keeps actions in exactly two left-aligned rows', () => {
- const { container } = render(
- Save}
- />,
- );
-
- const toolbar = container.firstElementChild;
- expect(toolbar).toHaveClass('items-start');
- expect(toolbar?.children).toHaveLength(2);
-
- const [secondaryRow, exportRow] = Array.from(toolbar?.children ?? []);
- expect(secondaryRow).toHaveClass('justify-start');
- expect(exportRow).toHaveClass('justify-start');
- expect(within(secondaryRow as HTMLElement).getByText('Save')).toBeInTheDocument();
- expect(within(exportRow as HTMLElement).getByText('Export')).toBeInTheDocument();
- expect(within(exportRow as HTMLElement).getByText('PDF')).toBeInTheDocument();
- expect(within(exportRow as HTMLElement).getByText('DOCX')).toBeInTheDocument();
- expect(within(exportRow as HTMLElement).getByText('Google Docs')).toBeInTheDocument();
- });
-});
diff --git a/src/test/components/FactoidsResults.test.tsx b/src/test/components/FactoidsResults.test.tsx
deleted file mode 100644
index a63864b50..000000000
--- a/src/test/components/FactoidsResults.test.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import { vi } from 'vitest';
-import { FactoidsResults } from '@/components/features/factoids/FactoidsResults';
-import type { FactoidsOutput } from '@/lib/ai/schemas/factoids';
-import type { DiscoveredSource } from '@/lib/ai/client';
-
-vi.mock('@/components/features/ExportToolbar', () => ({
- ExportToolbar: (): null => null,
-}));
-vi.mock('@/components/features/export/GoogleDriveAuthModal', () => ({
- GoogleDriveAuthModal: (): null => null,
-}));
-
-const result: FactoidsOutput = {
- factoids: [{
- id: 'fact-1',
- shortClaim: 'A pending claim',
- keyFigure: '42%',
- context: 'Context',
- limitations: 'Limitations',
- citation: {
- title: 'Source',
- publisher: 'Publisher',
- verified: 'pending' as const,
- },
- howToUse: 'Use it carefully',
- relevanceScore: 80,
- }],
- searchSuggestions: [],
- warnings: [],
- verificationStatus: 'pending' as const,
-};
-
-test('does not expose source verification while no verification behavior exists', () => {
- render();
-
- expect(screen.queryByRole('button', { name: /verify all sources/i })).not.toBeInTheDocument();
-});
-
-test('renders consulted sources with a fallback label for weak sources', () => {
- const discoveredSources: DiscoveredSource[] = [{
- title: 'Broad fallback article',
- url: 'https://example.com/fallback',
- qualityLabel: 'weak',
- qualityScore: 20,
- }];
-
- render();
-
- expect(screen.getByRole('heading', { name: /researched sources/i })).toBeInTheDocument();
- expect(screen.getByRole('link', { name: /broad fallback article/i })).toHaveAttribute('href', discoveredSources[0].url);
- expect(screen.getByText('fallback context only')).toBeInTheDocument();
-});
-
-test('names the save action with the factoid claim', () => {
- render();
-
- expect(screen.getByRole('button', {
- name: 'Save Solar costs fell by 42 percent to library',
- })).toBeInTheDocument();
-});
diff --git a/src/test/components/GenerateFormHonesty.test.tsx b/src/test/components/GenerateFormHonesty.test.tsx
deleted file mode 100644
index b35e4b0f6..000000000
--- a/src/test/components/GenerateFormHonesty.test.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { GenerateForm } from '@/components/features/generate/GenerateForm';
-
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: () => ({
- generateForm: null as never,
- setGenerateForm: vi.fn(),
- sourceLibrary: [] as never[],
- }),
-}));
-
-vi.mock('@/lib/supabase/AuthProvider', () => ({
- useAuth: () => ({ user: { id: 'test' }, session: { access_token: 'test' } }),
-}));
-
-vi.mock('@/hooks/use-subscription', () => ({
- useSubscription: () => ({ tier: 'pro', canAccessProModels: true }),
-}));
-
-describe('GenerateForm control honesty', () => {
- it('labels audience and tone as style guidance that does not change research', () => {
- render();
- const notes = screen.getAllByText(/guides writing style; it does not change research/i);
- expect(notes.length).toBeGreaterThan(0);
- });
-});
diff --git a/src/test/components/GenerateResultsRegenerate.test.tsx b/src/test/components/GenerateResultsRegenerate.test.tsx
deleted file mode 100644
index 8bc5b76a0..000000000
--- a/src/test/components/GenerateResultsRegenerate.test.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen, within } from '@testing-library/react';
-import { GenerateResults } from '@/components/features/generate/GenerateResults';
-import type { GenerateOutput } from '@/lib/ai/schemas/generate';
-
-vi.mock('@/lib/supabase/AuthProvider', () => ({
- useAuth: () => ({ user: { id: 'test' }, session: { access_token: 'test' } }),
-}));
-
-const result: GenerateOutput = {
- caseStructure: { thesis: 'Distinct thesis text', framework: 'Framework', keyDefinitions: [], burdenOfProof: 'Burden' },
- contentions: [{ id: 'c1', claim: 'First claim', reasoning: 'Because', evidence: 'Data', needsSource: false, impact: 'Big' }],
- predictedOpponentCase: { likelyArguments: ['Their point'], strategicWeaknesses: [], keyClashes: [] },
- counters: [{ targetArgument: 'Their point', counterpoint: 'Our answer', rebuttal: 'Rebuttal' }],
- metadata: { side: 'pro', format: 'custom', generatedAt: '2026-10-02T10:00:00.000Z' },
-};
-
-describe('GenerateResults section regeneration', () => {
- it('covers only the regenerating section and keeps the other sections visible', () => {
- render();
-
- const status = screen.getByRole('status');
- expect(status).toHaveTextContent('Regenerating');
- const contentionsCard = status.closest('.card-hover-glow') as HTMLElement;
- expect(within(contentionsCard).getByText('First claim')).toBeInTheDocument();
- expect(screen.getByText('Distinct thesis text')).toBeInTheDocument();
- expect(screen.getByText('Our answer')).toBeInTheDocument();
-
- // Every regenerate button waits for the running section to finish.
- const buttons = screen.getAllByRole('button', { name: /^Regenerate / });
- expect(buttons.length).toBeGreaterThan(1);
- for (const button of buttons) expect(button).toBeDisabled();
- // Undo now would be reverted when the section comes back.
- expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
- });
-
- it('leaves every section enabled when nothing is regenerating', () => {
- render();
-
- expect(screen.queryByRole('status')).not.toBeInTheDocument();
- for (const button of screen.getAllByRole('button', { name: /^Regenerate / })) expect(button).toBeEnabled();
- expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled();
- });
-});
diff --git a/src/test/components/LanguageSwitcher.test.tsx b/src/test/components/LanguageSwitcher.test.tsx
deleted file mode 100644
index 255913e31..000000000
--- a/src/test/components/LanguageSwitcher.test.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
-
-vi.mock('@/lib/stores/settings-store', () => ({
- useSettingsStore: vi.fn(() => ({
- language: 'en',
- outputLanguage: 'en',
- setLanguage: vi.fn(),
- setOutputLanguage: vi.fn(),
- })),
-}));
-
-vi.mock('react-i18next', () => ({
- useTranslation: () => ({ t: (key: string, fallback?: string) => fallback || key }),
-}));
-
-import { LanguageSwitcher } from '@/components/LanguageSwitcher';
-
-describe('LanguageSwitcher', () => {
- beforeEach(() => { vi.clearAllMocks(); });
-
- it('renders a globe button', () => {
- render();
- expect(screen.getByRole('button')).toBeDefined();
- });
-
- it('renders Globe icon SVG', () => {
- const { container } = render();
- expect(container.querySelector('svg')).not.toBeNull();
- });
-
- it('does not show dialog content before clicking', () => {
- render();
- expect(screen.queryByText('Interface language')).toBeNull();
- });
-});
diff --git a/src/test/components/LimitReachedModal.test.tsx b/src/test/components/LimitReachedModal.test.tsx
deleted file mode 100644
index 9757b7246..000000000
--- a/src/test/components/LimitReachedModal.test.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import i18n from '@/i18n';
-import { LimitReachedModal } from '@/components/features/subscription/LimitReachedModal';
-
-beforeEach(async () => {
- await i18n.changeLanguage('en');
-});
-
-afterEach(() => cleanup());
-
-describe('LimitReachedModal', () => {
- it('shows server-provided usage details and keeps the upgrade action', () => {
- const onUpgrade = vi.fn();
-
- render(
- ,
- );
-
- expect(screen.getByText("You've used 7 of 10 included AI uses today. Access refreshes at midnight UTC.")).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Upgrade to Pro - $5/mo' }));
- expect(onUpgrade).toHaveBeenCalledOnce();
- });
-});
diff --git a/src/test/components/Markdown.test.tsx b/src/test/components/Markdown.test.tsx
deleted file mode 100644
index f25ee7ecf..000000000
--- a/src/test/components/Markdown.test.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { Markdown } from '@/components/ui/markdown';
-
-describe('Markdown', () => {
- it('renders plain text', () => {
- render(Hello world);
- expect(screen.getByText('Hello world')).toBeDefined();
- });
-
- it('renders bold text', () => {
- const { container } = render(**bold text**);
- const strong = container.querySelector('strong');
- expect(strong).not.toBeNull();
- expect(strong!.textContent).toBe('bold text');
- });
-
- it('renders italic text', () => {
- const { container } = render(*italic text*);
- const em = container.querySelector('em');
- expect(em).not.toBeNull();
- expect(em!.textContent).toBe('italic text');
- });
-
- it('renders unordered lists', () => {
- const { container } = render({'- item 1\n- item 2'});
- const ul = container.querySelector('ul');
- expect(ul).not.toBeNull();
- const items = container.querySelectorAll('li');
- expect(items.length).toBe(2);
- });
-
- it('renders ordered lists', () => {
- const { container } = render({'1. first\n2. second'});
- const ol = container.querySelector('ol');
- expect(ol).not.toBeNull();
- });
-
- it('renders h1 headings', () => {
- const { container } = render(# Heading 1);
- const h1 = container.querySelector('h1');
- expect(h1).not.toBeNull();
- expect(h1!.textContent).toBe('Heading 1');
- });
-
- it('renders h2 headings', () => {
- const { container } = render(## Heading 2);
- const h2 = container.querySelector('h2');
- expect(h2).not.toBeNull();
- expect(h2!.textContent).toBe('Heading 2');
- });
-
- it('renders h3 headings', () => {
- const { container } = render(### Heading 3);
- const h3 = container.querySelector('h3');
- expect(h3).not.toBeNull();
- expect(h3!.textContent).toBe('Heading 3');
- });
-
- it('renders inline code', () => {
- const { container } = render(Use `code` here);
- const code = container.querySelector('code');
- expect(code).not.toBeNull();
- expect(code!.textContent).toBe('code');
- });
-
- it('renders blockquotes', () => {
- const { container } = render({'> This is a quote'});
- const blockquote = container.querySelector('blockquote');
- expect(blockquote).not.toBeNull();
- });
-
- it('renders links', () => {
- const { container } = render([click here](https://example.com));
- const link = container.querySelector('a');
- expect(link).not.toBeNull();
- expect(link!.getAttribute('href')).toBe('https://example.com');
- });
-
- it('handles empty string', () => {
- const { container } = render({''});
- expect(container.querySelector('.prose')).not.toBeNull();
- });
-
- it('applies custom className', () => {
- const { container } = render(text);
- expect(container.querySelector('.custom-class')).not.toBeNull();
- });
-
- it('applies prose class by default', () => {
- const { container } = render(text);
- expect(container.querySelector('.prose')).not.toBeNull();
- });
-
- it('renders multiple paragraphs', () => {
- const { container } = render({'Paragraph 1\n\nParagraph 2'});
- const paragraphs = container.querySelectorAll('p');
- expect(paragraphs.length).toBe(2);
- });
-});
diff --git a/src/test/components/ModelSelector.test.tsx b/src/test/components/ModelSelector.test.tsx
deleted file mode 100644
index 80a22a178..000000000
--- a/src/test/components/ModelSelector.test.tsx
+++ /dev/null
@@ -1,256 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
-
-const subscriptionState = vi.hoisted(() => ({ isPro: false, tier: 'trial', loading: false }));
-
-vi.mock('@/hooks/use-subscription', () => ({
- useSubscription: () => subscriptionState,
-}));
-
-vi.mock('@/lib/stores/settings-store', () => ({
- useSettingsStore: vi.fn(() => ({
- featureConfigs: {
- generate: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' },
- attack: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' },
- brainstorm: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.9, reasoningEffort: 'low' },
- factoids: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.3, reasoningEffort: 'low' },
- ask: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' },
- },
- setFeatureConfig: vi.fn(),
- })),
-}));
-
-vi.mock('@/lib/ai/providers/types', () => ({
- DEFAULT_MODEL: 'openai/gpt-5.6-luna',
- PROVIDER_ID: 'openrouter',
- AUTO_MODEL_SELECTIONS: [
- { id: 'auto-fast', name: 'Auto Fast', isProOnly: false },
- { id: 'auto-pro', name: 'Auto Pro', isProOnly: true },
- ],
- isAutoModelSelection: (value: unknown) => value === 'auto-fast' || value === 'auto-pro',
- isReasoningEffort: (value: unknown) => value === 'low' || value === 'medium' || value === 'high',
- AVAILABLE_MODELS: [
- { id: 'openai/gpt-5.6-luna', name: 'GPT-5.6 Luna', maxTokens: 128000, isProOnly: false, supportsTemperature: false },
- { id: 'google/gemini-3.8-flash', name: 'Gemini 3.8 Flash', maxTokens: 65536, isProOnly: false, supportsTemperature: true },
- { id: 'deepseek/deepseek-v4-flash-0731', name: 'DeepSeek V4 Flash', maxTokens: 131072, isProOnly: false, supportsTemperature: true },
- { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', maxTokens: 128000, isProOnly: true, supportsTemperature: false },
- { id: 'anthropic/claude-opus-5', name: 'Claude Opus 5', maxTokens: 128000, isProOnly: true, supportsTemperature: true },
- { id: 'openai/gpt-5.6-sol', name: 'GPT-5.6 Sol', maxTokens: 128000, isProOnly: true, supportsTemperature: false },
- { id: 'openai/gpt-5.6-terra', name: 'GPT-5.6 Terra', maxTokens: 128000, isProOnly: true, supportsTemperature: false },
- { id: 'deepseek/deepseek-v4-pro-0813', name: 'DeepSeek V4 Pro', maxTokens: 384000, isProOnly: true, supportsTemperature: true },
- { id: 'z-ai/glm-5.3-flash', name: 'GLM-5.3 Flash', maxTokens: 131072, isProOnly: true, supportsTemperature: true },
- { id: 'meta/muse-spark-1.3', name: 'Muse Spark 1.3', maxTokens: 943718, isProOnly: true, supportsTemperature: true },
- ],
-}));
-
-import { ModelSelector } from '@/components/ModelSelector';
-import { useSettingsStore } from '@/lib/stores/settings-store';
-
-describe('ModelSelector', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- subscriptionState.isPro = false;
- subscriptionState.tier = 'trial';
- subscriptionState.loading = false;
- Element.prototype.scrollIntoView = vi.fn();
- });
-
- it('renders without crashing', () => {
- render();
- expect(screen.getAllByRole('combobox')[0]).toBeDefined();
- });
-
- it('renders with default model value', () => {
- render();
- const trigger = screen.getAllByRole('combobox')[0];
- expect(trigger).toBeDefined();
- });
-
- it('renders for attack feature', () => {
- render();
- expect(screen.getAllByRole('combobox')[0]).toBeDefined();
- });
-
- it('renders for brainstorm feature', () => {
- render();
- expect(screen.getAllByRole('combobox')[0]).toBeDefined();
- });
-
- it('renders for factoids feature', () => {
- render();
- expect(screen.getAllByRole('combobox')[0]).toBeDefined();
- });
-
- it('renders for ask feature', () => {
- render();
- expect(screen.getAllByRole('combobox')[0]).toBeDefined();
- });
-
- it('uses default model when featureConfigs is undefined', () => {
- vi.mocked(useSettingsStore).mockReturnValue({
- featureConfigs: undefined as any,
- setFeatureConfig: vi.fn(),
- });
- render();
- expect(screen.getAllByRole('combobox')[0]).toBeDefined();
- });
-
- it('uses default model when feature config is missing', () => {
- vi.mocked(useSettingsStore).mockReturnValue({
- featureConfigs: {} as any,
- setFeatureConfig: vi.fn(),
- });
- render();
- expect(screen.getAllByRole('combobox')[0]).toBeDefined();
- });
-
- it('uses default model when config.model is empty', () => {
- vi.mocked(useSettingsStore).mockReturnValue({
- featureConfigs: { generate: { provider: 'google', model: '', temperature: 0.7 } } as any,
- setFeatureConfig: vi.fn(),
- });
- render();
- expect(screen.getAllByRole('combobox')[0]).toBeDefined();
- });
-
- it('has a select trigger element', () => {
- render();
- const trigger = screen.getAllByRole('combobox')[0];
- expect(trigger.tagName).toBeDefined();
- });
-
- it('trigger uses form-sized text', () => {
- render();
- const trigger = screen.getAllByRole('combobox')[0];
- expect(trigger.className).toContain('text-sm');
- });
-
- it('renders full width inside forms', () => {
- render();
- const trigger = screen.getAllByRole('combobox')[0];
- expect(trigger.className).toContain('w-full');
- });
-
- it('calls setFeatureConfig from store', () => {
- const mockSetFeatureConfig = vi.fn();
- vi.mocked(useSettingsStore).mockReturnValue({
- featureConfigs: {
- generate: { provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.7 },
- } as any,
- setFeatureConfig: mockSetFeatureConfig,
- });
- render();
- expect(mockSetFeatureConfig).not.toHaveBeenCalled();
- });
-
- it('renders the Cpu icon (hidden on mobile)', () => {
- const { container } = render();
- const svg = container.querySelector('svg');
- expect(svg).toBeDefined();
- });
-
- it('handles all feature types without error', () => {
- const features = ['generate', 'attack', 'brainstorm', 'factoids', 'ask'] as const;
- features.forEach(feature => {
- const { unmount } = render();
- expect(screen.getAllByRole('combobox')[0]).toBeDefined();
- unmount();
- });
- });
-
- it('shows low reasoning and disables the choice for trial users with saved high reasoning', () => {
- const mockSetFeatureConfig = vi.fn();
- vi.mocked(useSettingsStore).mockReturnValue({
- featureConfigs: {
- generate: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'high' },
- } as any,
- setFeatureConfig: mockSetFeatureConfig,
- });
-
- render();
-
- const reasoning = screen.getAllByRole('combobox')[1];
- expect(reasoning).toBeDisabled();
- expect(reasoning).toHaveTextContent('Low');
- expect(mockSetFeatureConfig).not.toHaveBeenCalled();
- });
-
- it('keeps a saved Pro model visible after trial entitlement resolves', () => {
- vi.mocked(useSettingsStore).mockReturnValue({
- featureConfigs: {
- generate: { provider: 'openrouter', model: 'anthropic/claude-sonnet-5', temperature: 0.7, reasoningEffort: 'low' },
- } as any,
- setFeatureConfig: vi.fn(),
- });
-
- render();
-
- expect(screen.getAllByRole('combobox')[0]).toHaveTextContent('Claude Sonnet 5');
- });
-
- it('keeps saved Pro controls visible while entitlement is loading', () => {
- subscriptionState.loading = true;
- vi.mocked(useSettingsStore).mockReturnValue({
- featureConfigs: {
- generate: { provider: 'openrouter', model: 'anthropic/claude-sonnet-5', temperature: 0.7, reasoningEffort: 'high' },
- } as any,
- setFeatureConfig: vi.fn(),
- });
-
- render();
-
- expect(screen.getAllByRole('combobox')[0]).toHaveTextContent('Claude Sonnet 5');
- expect(screen.getAllByRole('combobox')[1]).toHaveTextContent('High');
- });
-
- it('enables model and reasoning controls for Pro users', () => {
- subscriptionState.isPro = true;
- subscriptionState.tier = 'pro';
- vi.mocked(useSettingsStore).mockReturnValue({
- featureConfigs: {
- generate: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' },
- } as any,
- setFeatureConfig: vi.fn(),
- });
-
- render();
- const [model, reasoning] = screen.getAllByRole('combobox');
- expect(model).not.toBeDisabled();
- expect(reasoning).not.toBeDisabled();
- });
-
- it('shows Auto Fast to Basic users and lets routing own reasoning', () => {
- vi.mocked(useSettingsStore).mockReturnValue({
- featureConfigs: {
- generate: { provider: 'openrouter', model: 'auto-fast', temperature: 0.7, reasoningEffort: 'high' },
- } as any,
- setFeatureConfig: vi.fn(),
- });
-
- render();
-
- const [model, reasoning] = screen.getAllByRole('combobox');
- expect(model).toHaveTextContent('Auto Fast');
- expect(model).not.toBeDisabled();
- expect(reasoning).toBeDisabled();
- });
-
- it('shows Auto Pro for Pro users and lets routing own reasoning', () => {
- subscriptionState.isPro = true;
- subscriptionState.tier = 'pro';
- vi.mocked(useSettingsStore).mockReturnValue({
- featureConfigs: {
- generate: { provider: 'openrouter', model: 'auto-pro', temperature: 0.7, reasoningEffort: 'high' },
- } as any,
- setFeatureConfig: vi.fn(),
- });
-
- render();
-
- const [model, reasoning] = screen.getAllByRole('combobox');
- expect(model).toHaveTextContent('Auto Pro');
- expect(reasoning).toBeDisabled();
- });
-
-});
diff --git a/src/test/components/SourceQualityList.test.tsx b/src/test/components/SourceQualityList.test.tsx
deleted file mode 100644
index 584cdb36e..000000000
--- a/src/test/components/SourceQualityList.test.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import { beforeEach, describe, it, expect } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { SourceQualityList } from '@/components/features/shared/SourceQualityList';
-import type { DiscoveredSource } from '@/lib/ai/client';
-import i18n from '@/i18n';
-
-describe('SourceQualityList', () => {
- beforeEach(async () => {
- await i18n.changeLanguage('en');
- });
-
- it('renders nothing when there are no sources', () => {
- const { container } = render();
- expect(container.firstChild).toBeNull();
- });
-
- it('shows source type, quality label, and a link for each source', () => {
- const sources: DiscoveredSource[] = [
- {
- title: 'Ministry report',
- url: 'https://kormany.hu/report',
- sourceType: 'government',
- qualityLabel: 'strong',
- qualityScore: 82,
- },
- ];
- render();
- expect(screen.getByText('Ministry report')).toBeInTheDocument();
- expect(screen.getByText('Government')).toBeInTheDocument();
- expect(screen.getByText('Strong')).toBeInTheDocument();
- expect(screen.getByRole('link', { name: /ministry report/i })).toHaveAttribute('href', 'https://kormany.hu/report');
- });
-
- it('labels weak sources as fallback context instead of evidence', () => {
- const sources: DiscoveredSource[] = [
- {
- title: 'Forum thread',
- url: 'https://reddit.com/r/debate/thread',
- sourceType: 'social',
- qualityLabel: 'weak',
- qualityScore: 10,
- },
- ];
- render();
- expect(screen.getByText('Weak')).toBeInTheDocument();
- expect(screen.getByText(/fallback context/i)).toBeInTheDocument();
- });
-
- it('marks moderate sources as usable citations', () => {
- const sources: DiscoveredSource[] = [
- {
- title: 'News analysis',
- url: 'https://telex.hu/article',
- sourceType: 'news',
- qualityLabel: 'moderate',
- qualityScore: 55,
- },
- ];
- render();
- expect(screen.getByText('Moderate')).toBeInTheDocument();
- expect(screen.queryByText(/fallback context/i)).not.toBeInTheDocument();
- });
-
- it('localizes source metadata in Hungarian', async () => {
- await i18n.changeLanguage('hu');
- render();
-
- expect(screen.getByText('Kormányzati')).toBeInTheDocument();
- expect(screen.getByText('Erős')).toBeInTheDocument();
- });
-
- it('shows case use before semantic importance and strength', () => {
- render();
-
- expect(screen.getByText('Used in this case')).toBeInTheDocument();
- expect(screen.getByText('High importance')).toBeInTheDocument();
- expect(screen.getByText('Medium strength')).toBeInTheDocument();
- });
-});
diff --git a/src/test/components/SparringSetup.test.tsx b/src/test/components/SparringSetup.test.tsx
deleted file mode 100644
index a826dec55..000000000
--- a/src/test/components/SparringSetup.test.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { fireEvent, render, screen } from '@testing-library/react';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { SparringSetup } from '@/components/features/sparring/SparringSetup';
-
-vi.mock('@/components/ModelSelector', () => ({
- ModelSelector: () => Local test model
,
-}));
-
-describe('SparringSetup', () => {
- beforeEach(() => {
- Object.defineProperty(navigator, 'onLine', { configurable: true, value: true });
- });
-
- it('keeps format selection compact while exposing the selected timing summary', () => {
- render();
-
- expect(screen.getByRole('heading', { name: 'Sparring' })).toBeInTheDocument();
- const format = screen.getByRole('combobox', { name: 'Round format' });
- expect(format).toHaveTextContent('School 4-6-6-3 · 8 speeches');
- expect(screen.getByText('About 38 minutes')).toBeInTheDocument();
- expect(screen.queryByText('8 speeches', { exact: true })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /School 4-6-6-3/ })).not.toBeInTheDocument();
- });
-
- it('keeps the request breakdown at the standard small text size', () => {
- render();
-
- expect(screen.getByText('4 replies + 1 judge')).toHaveClass('text-xs');
- });
-
- it('keeps the full speaking order available behind a native disclosure', () => {
- render();
-
- const summary = screen.getByText('Speaking order', { exact: true });
- const details = summary.closest('details');
- expect(details).not.toHaveAttribute('open');
- expect(screen.getByText('Pro Introductor')).toBeInTheDocument();
-
- fireEvent.click(summary);
- expect(details).toHaveAttribute('open');
- expect(screen.getByText('Pro Conclusor')).toBeVisible();
- });
-
- it('submits the selected side, pressure, and trimmed motion without changing the default format', () => {
- const onStart = vi.fn();
- render();
-
- fireEvent.change(screen.getByLabelText('Motion'), { target: { value: ' This House would make public transport free. ' } });
- fireEvent.click(screen.getByRole('button', { name: 'Con' }));
- fireEvent.click(screen.getByRole('button', { name: 'Ruthless' }));
- fireEvent.click(screen.getByRole('button', { name: 'Start round' }));
-
- expect(onStart).toHaveBeenCalledWith({
- motion: 'This House would make public transport free.',
- userSide: 'con',
- format: 'standard',
- difficulty: 'hard',
- voice: false,
- });
- });
-
- it('keeps the start action and model control reachable in the compact sidebar', () => {
- render();
-
- expect(screen.getByLabelText('Selected model')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Start round' })).toBeDisabled();
- expect(screen.getByLabelText('Motion').closest('form')).toHaveClass('items-start', 'lg:grid-cols-[minmax(0,1fr)_300px]');
- });
-});
diff --git a/src/test/components/StreamingIndicator.test.tsx b/src/test/components/StreamingIndicator.test.tsx
deleted file mode 100644
index 6fcc931f5..000000000
--- a/src/test/components/StreamingIndicator.test.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen, act } from '@testing-library/react';
-import { StreamingIndicator } from '@/components/ui/streaming-indicator';
-import i18n from '@/i18n';
-import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
-import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
-
-describe('StreamingIndicator', () => {
- beforeEach(() => {
- vi.useFakeTimers();
- });
-
- afterEach(async () => {
- vi.useRealTimers();
- await i18n.changeLanguage('en');
- });
-
- const messages = ['Analyzing...', 'Generating...', 'Almost done...'];
-
- it('renders first message', () => {
- render();
- expect(screen.getByText('Analyzing...')).toBeDefined();
- });
-
- it('cycles to second message after interval', () => {
- render();
- act(() => { vi.advanceTimersByTime(3200); });
- expect(screen.getByText('Generating...')).toBeDefined();
- });
-
- it('cycles to third message', () => {
- render();
- act(() => { vi.advanceTimersByTime(6400); });
- expect(screen.getByText('Almost done...')).toBeDefined();
- });
-
- it('wraps around to first message', () => {
- render();
- act(() => { vi.advanceTimersByTime(9600); });
- expect(screen.getByText('Analyzing...')).toBeDefined();
- });
-
- it('shows progress bar', () => {
- const { container } = render();
- const progressBar = container.querySelector('.bg-primary\\/60');
- expect(progressBar).not.toBeNull();
- });
-
- it('progress bar starts at 0%', () => {
- const { container } = render();
- const progressBar = container.querySelector('.bg-primary\\/60') as HTMLElement;
- expect(progressBar.style.width).toBe('0%');
- });
-
- it('progress bar increases over time', () => {
- const { container } = render();
- act(() => { vi.advanceTimersByTime(1500); });
- const progressBar = container.querySelector('.bg-primary\\/60') as HTMLElement;
- const width = parseFloat(progressBar.style.width);
- expect(width).toBeGreaterThan(0);
- });
-
- it('accepts custom interval', () => {
- render();
- act(() => { vi.advanceTimersByTime(1200); });
- expect(screen.getByText('Generating...')).toBeDefined();
- });
-
- it('applies custom className', () => {
- const { container } = render();
- expect(container.querySelector('.custom-class')).not.toBeNull();
- });
-
- it('renders with single message', () => {
- render();
- expect(screen.getByText('Loading...')).toBeDefined();
- });
-
- it('localizes loading and overlay close controls after a runtime language switch', async () => {
- await act(async () => { await i18n.changeLanguage('hu'); });
- const indicator = render();
- expect(screen.getByRole('status', { name: 'Betöltés' })).toBeInTheDocument();
- indicator.unmount();
-
- const dialog = render();
- expect(screen.getByRole('button', { name: 'Bezárás' })).toBeInTheDocument();
- dialog.unmount();
-
- render(Tesztpanel);
- expect(screen.getByRole('button', { name: 'Bezárás' })).toBeInTheDocument();
- });
-});
diff --git a/src/test/components/ui.test.tsx b/src/test/components/ui.test.tsx
deleted file mode 100644
index e902b3084..000000000
--- a/src/test/components/ui.test.tsx
+++ /dev/null
@@ -1,273 +0,0 @@
-// src/test/components/ui.test.tsx
-import { describe, it, expect, beforeAll } from 'vitest';
-import { render, screen, fireEvent } from '@testing-library/react';
-
-// Mock ResizeObserver for Radix UI components
-beforeAll(() => {
- global.ResizeObserver = class ResizeObserver {
- observe() { }
- unobserve() { }
- disconnect() { }
- };
-});
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Card, CardHeader, CardTitle, CardContent, CardDescription, CardFooter } from '@/components/ui/card';
-import { Badge } from '@/components/ui/badge';
-import { Textarea } from '@/components/ui/textarea';
-import { Label } from '@/components/ui/label';
-import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
-import { Switch } from '@/components/ui/switch';
-import { Slider } from '@/components/ui/slider';
-import { Progress } from '@/components/ui/progress';
-import { Separator } from '@/components/ui/separator';
-import { ScrollArea } from '@/components/ui/scroll-area';
-
-describe('UI Components', () => {
- describe('Button', () => {
- it('renders with text', () => {
- render();
- expect(screen.getByText('Click me')).toBeDefined();
- });
-
- it('handles click events', () => {
- let clicked = false;
- render();
- fireEvent.click(screen.getByText('Click'));
- expect(clicked).toBe(true);
- });
-
- it('renders different variants', () => {
- const { rerender } = render();
- expect(screen.getByText('Default')).toBeDefined();
-
- rerender();
- expect(screen.getByText('Destructive')).toBeDefined();
-
- rerender();
- expect(screen.getByText('Outline')).toBeDefined();
-
- rerender();
- expect(screen.getByText('Ghost')).toBeDefined();
- });
-
- it('renders different sizes', () => {
- const { rerender } = render();
- expect(screen.getByText('Small')).toBeDefined();
-
- rerender();
- expect(screen.getByText('Large')).toBeDefined();
- });
-
- it('can be disabled', () => {
- render();
- expect(screen.getByText('Disabled')).toBeDisabled();
- });
- });
-
- describe('Input', () => {
- it('renders with placeholder', () => {
- render();
- expect(screen.getByPlaceholderText('Enter text...')).toBeDefined();
- });
-
- it('handles value changes', () => {
- render();
- const input = screen.getByTestId('input') as HTMLInputElement;
- fireEvent.change(input, { target: { value: 'test value' } });
- expect(input.value).toBe('test value');
- });
-
- it('can be disabled', () => {
- render();
- expect(screen.getByTestId('input')).toBeDisabled();
- });
-
- it('renders different types', () => {
- const { rerender } = render();
- expect(screen.getByTestId('input')).toHaveAttribute('type', 'text');
-
- rerender();
- expect(screen.getByTestId('input')).toHaveAttribute('type', 'password');
-
- rerender();
- expect(screen.getByTestId('input')).toHaveAttribute('type', 'email');
- });
- });
-
- describe('Card', () => {
- it('renders card with all parts', () => {
- render(
-
-
- Title
- Description
-
- Content
- Footer
-
- );
- expect(screen.getByText('Title')).toBeDefined();
- expect(screen.getByText('Description')).toBeDefined();
- expect(screen.getByText('Content')).toBeDefined();
- expect(screen.getByText('Footer')).toBeDefined();
- });
-
- it('renders card without optional parts', () => {
- render(
-
- Only content
-
- );
- expect(screen.getByText('Only content')).toBeDefined();
- });
- });
-
- describe('Badge', () => {
- it('renders with text', () => {
- render(New);
- expect(screen.getByText('New')).toBeDefined();
- });
-
- it('renders different variants', () => {
- const { rerender } = render(Default);
- expect(screen.getByText('Default')).toBeDefined();
-
- rerender(Secondary);
- expect(screen.getByText('Secondary')).toBeDefined();
-
- rerender(Destructive);
- expect(screen.getByText('Destructive')).toBeDefined();
-
- rerender(Outline);
- expect(screen.getByText('Outline')).toBeDefined();
- });
- });
-
- describe('Textarea', () => {
- it('renders with placeholder', () => {
- render();
- expect(screen.getByPlaceholderText('Enter message...')).toBeDefined();
- });
-
- it('handles value changes', () => {
- render();
- const textarea = screen.getByTestId('textarea') as HTMLTextAreaElement;
- fireEvent.change(textarea, { target: { value: 'test message' } });
- expect(textarea.value).toBe('test message');
- });
- });
-
- describe('Label', () => {
- it('renders with text', () => {
- render();
- expect(screen.getByText('Field Label')).toBeDefined();
- });
-
- it('associates with input via htmlFor', () => {
- render(
- <>
-
-
- >
- );
- expect(screen.getByLabelText('Label')).toBeDefined();
- });
- });
-
- describe('Tabs', () => {
- it('renders with tabs and content', () => {
- render(
-
-
- Tab 1
- Tab 2
-
- Content 1
- Content 2
-
- );
- expect(screen.getByText('Tab 1')).toBeDefined();
- expect(screen.getByText('Tab 2')).toBeDefined();
- expect(screen.getByText('Content 1')).toBeDefined();
- });
-
- it('sets initial tab as active', () => {
- render(
-
-
- Tab 1
- Tab 2
-
- Content 1
- Content 2
-
- );
- // Tab 1 should be active by default
- expect(screen.getByText('Tab 1').getAttribute('data-state')).toBe('active');
- // Tab 2 should be inactive by default
- expect(screen.getByText('Tab 2').getAttribute('data-state')).toBe('inactive');
- });
- });
-
- describe('Switch', () => {
- it('renders in off state', () => {
- render();
- expect(screen.getByTestId('switch')).toBeDefined();
- });
-
- it('toggles on click', () => {
- let checked = false;
- render( { checked = v; }} data-testid="switch" />);
- fireEvent.click(screen.getByTestId('switch'));
- expect(checked).toBe(true);
- });
- });
-
- describe('Slider', () => {
- it('renders with default value', () => {
- render();
- expect(screen.getByTestId('slider')).toBeDefined();
- });
- });
-
- describe('Progress', () => {
- it('renders with value', () => {
- render();
- expect(screen.getByTestId('progress')).toBeDefined();
- });
-
- it('renders at 0%', () => {
- render();
- expect(screen.getByTestId('progress')).toBeDefined();
- });
-
- it('renders at 100%', () => {
- render();
- expect(screen.getByTestId('progress')).toBeDefined();
- });
- });
-
- describe('Separator', () => {
- it('renders horizontal separator', () => {
- render();
- expect(screen.getByTestId('separator')).toBeDefined();
- });
-
- it('renders vertical separator', () => {
- render();
- expect(screen.getByTestId('separator')).toBeDefined();
- });
- });
-
- describe('ScrollArea', () => {
- it('renders with content', () => {
- render(
-
- Scrollable content
-
- );
- expect(screen.getByText('Scrollable content')).toBeDefined();
- });
- });
-});
diff --git a/src/test/crossfire/prefill.test.ts b/src/test/crossfire/prefill.test.ts
deleted file mode 100644
index 910e0ffa4..000000000
--- a/src/test/crossfire/prefill.test.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { prefillFromAttack, prefillFromGenerate } from '@/lib/crossfire/prefill';
-import type { AttackOutput } from '@/lib/ai/schemas/attack';
-import type { GenerateOutput } from '@/lib/ai/schemas/generate';
-
-describe('Crossfire result prefill', () => {
- it('turns a generated case into a bounded argument with its resolution', () => {
- const result: GenerateOutput = {
- caseStructure: { thesis: 'Ban cars', framework: 'Safety', keyDefinitions: [], burdenOfProof: 'Show safer streets' },
- contentions: [{ id: '1', claim: 'Cars injure people', reasoning: 'Exposure creates risk', evidence: 'Collision records', needsSource: false, impact: 'Fewer injuries' }],
- predictedOpponentCase: { likelyArguments: [], strategicWeaknesses: [], keyClashes: [] },
- counters: [],
- metadata: { side: 'pro', format: 'custom', generatedAt: '2026-01-01T00:00:00Z' },
- };
- const prefill = prefillFromGenerate('Resolved: ban cars', result);
- expect(prefill.resolution).toBe('Resolved: ban cars');
- expect(prefill.argument).toContain('Thesis: Ban cars');
- expect(prefill.argument).toContain('Contention 1: Cars injure people');
- });
-
- it('carries the attacked case and generated cross-ex questions', () => {
- const result: AttackOutput = {
- overallAssessment: { score: 60, summary: 'Mixed', strengthAreas: [] },
- weaknesses: [],
- fallaciesDetected: [],
- crossExQuestions: [
- { question: 'What is the mechanism?', purpose: 'Test causation', expectedTrap: 'No warrant' },
- { question: 'Who pays?', purpose: 'Test costs', expectedTrap: 'No funding' },
- ],
- weighingAnalysis: { currentWeighing: '', missingComparisons: [], improvementSuggestions: [] },
- };
- const prefill = prefillFromAttack('The original case.', result);
- expect(prefill.argument).toBe('The original case.');
- expect(prefill.seedQuestions).toEqual(['What is the mechanism?', 'Who pays?']);
- });
-
- it('bounds oversized Attack question sets instead of throwing from the result action', () => {
- const result: AttackOutput = {
- overallAssessment: { score: 60, summary: 'Mixed', strengthAreas: [] },
- weaknesses: [],
- fallaciesDetected: [],
- crossExQuestions: Array.from({ length: 11 }, (_, index) => ({
- question: `${index}: ${'q'.repeat(2_100)}`,
- purpose: 'Test the argument',
- expectedTrap: 'Missing warrant',
- })),
- weighingAnalysis: { currentWeighing: '', missingComparisons: [], improvementSuggestions: [] },
- };
- const prefill = prefillFromAttack('The original case.', result);
- expect(prefill.seedQuestions).toHaveLength(10);
- expect(prefill.seedQuestions?.every((question) => question.length <= 2_000)).toBe(true);
- });
-});
diff --git a/src/test/crossfire/schemas.test.ts b/src/test/crossfire/schemas.test.ts
deleted file mode 100644
index 9ea24a2f7..000000000
--- a/src/test/crossfire/schemas.test.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import {
- CrossfireAIInputSchema,
- CrossfireDebriefSchema,
- CrossfireReplySchema,
- CrossfireStateSchema,
- CrossfireTurnSchema,
- MAX_CROSSFIRE_ARGUMENT_CHARS,
- MAX_CROSSFIRE_REPLY_CHARS,
- MAX_CROSSFIRE_TRANSCRIPT_CHARS,
- type CrossfireConfig,
- type CrossfireDebrief,
- type CrossfireTurn,
-} from '@/lib/crossfire/schemas';
-
-const config: CrossfireConfig = {
- resolution: 'Resolved: cities should ban private cars downtown.',
- argument: 'Private cars create congestion and unsafe streets.',
- mode: 'question-ai',
- style: 'cooperative',
- durationSeconds: 180,
- useSourceLibrary: false,
- seedQuestions: [],
-};
-
-const turn: CrossfireTurn = {
- id: 'T1',
- sequence: 1,
- speaker: 'user',
- kind: 'question',
- text: 'What evidence supports the congestion claim?',
- createdAt: 1,
-};
-
-describe('Crossfire schemas', () => {
- it('accepts bounded next-turn input and rejects oversized arguments', () => {
- expect(CrossfireAIInputSchema.safeParse({
- operation: 'next-turn',
- config,
- transcript: [turn],
- expectedReply: 'answer',
- }).success).toBe(true);
-
- expect(CrossfireAIInputSchema.safeParse({
- operation: 'next-turn',
- config: { ...config, argument: 'x'.repeat(MAX_CROSSFIRE_ARGUMENT_CHARS + 1) },
- transcript: [turn],
- expectedReply: 'answer',
- }).success).toBe(false);
- });
-
- it('rejects transcripts that exceed the total character budget', () => {
- const oversizedTurns = Array.from({ length: 13 }, (_, index) => ({
- ...turn,
- id: `T${index + 1}`,
- sequence: index + 1,
- text: 'x'.repeat(Math.ceil(MAX_CROSSFIRE_TRANSCRIPT_CHARS / 12)),
- }));
- expect(CrossfireAIInputSchema.safeParse({
- operation: 'next-turn',
- config,
- transcript: oversizedTurns,
- expectedReply: 'answer',
- }).success).toBe(false);
- });
-
- it('rejects duplicate turn IDs and non-consecutive sequences', () => {
- const secondTurn = {
- ...turn,
- id: 'T2',
- sequence: 2,
- speaker: 'ai' as const,
- kind: 'answer' as const,
- };
- expect(CrossfireAIInputSchema.safeParse({
- operation: 'next-turn',
- config,
- transcript: [turn, { ...secondTurn, id: turn.id }],
- expectedReply: 'question',
- }).success).toBe(false);
- expect(CrossfireAIInputSchema.safeParse({
- operation: 'next-turn',
- config,
- transcript: [turn, { ...secondTurn, sequence: 3 }],
- expectedReply: 'question',
- }).success).toBe(false);
- });
-
- it('validates strict, bounded AI replies', () => {
- expect(CrossfireReplySchema.parse({ type: 'answer', text: 'The policy reduces vehicle trips.' })).toEqual({
- type: 'answer',
- text: 'The policy reduces vehicle trips.',
- });
- expect(CrossfireReplySchema.safeParse({ type: 'answer', text: '' }).success).toBe(false);
- expect(CrossfireReplySchema.safeParse({ type: 'answer', text: 'x'.repeat(MAX_CROSSFIRE_REPLY_CHARS + 1) }).success).toBe(false);
- expect(CrossfireReplySchema.safeParse({ type: 'answer', text: 'Valid', extra: true }).success).toBe(false);
- });
-
- it('accepts signals only on their matching answer turn', () => {
- const signal = {
- answerTurnId: 'T2',
- answerQuality: 'direct',
- answerQualityConfidence: 0.9,
- concession: 'implicit',
- concessionConfidence: 0.7,
- followUpOpportunity: 0.8,
- } as const;
- expect(CrossfireTurnSchema.safeParse({ ...turn, id: 'T2', sequence: 2, speaker: 'ai', kind: 'answer', signal }).success).toBe(true);
- expect(CrossfireTurnSchema.safeParse({ ...turn, signal: { ...signal, answerTurnId: 'T2' } }).success).toBe(false);
- expect(CrossfireTurnSchema.safeParse({ ...turn, id: 'T2', sequence: 2, kind: 'answer', signal: { ...signal, answerTurnId: 'T9' } }).success).toBe(false);
- });
-
- it('requires every debrief claim and suggestion to cite transcript turns', () => {
- const valid: CrossfireDebrief = {
- concessionsSecured: [{ turnIds: ['T2'], claim: 'The answer concedes implementation has a cost.' }],
- controlledQuestions: [{ turnIds: ['T1', 'T2'], claim: 'The opening question fixes the scope.' }],
- weakOrRepetitiveQuestions: [],
- evasiveOrIncompleteAnswers: [],
- missedFollowUps: [{ turnIds: ['T2'], claim: 'The cost concession was not quantified.' }],
- betterFollowUpQuestions: [{ turnIds: ['T2'], question: 'How large is that cost?', reason: 'It tests the concession.' }],
- strongestExchange: { turnIds: ['T1', 'T2'], claim: 'The first exchange establishes the central tradeoff.' },
- };
- expect(CrossfireDebriefSchema.safeParse(valid).success).toBe(true);
- expect(CrossfireDebriefSchema.safeParse({
- ...valid,
- concessionsSecured: [{ turnIds: [], claim: 'Uncited claim' }],
- }).success).toBe(false);
- });
-
- it('allows an unfinished empty setup state but not an active invalid config', () => {
- expect(CrossfireStateSchema.safeParse({
- phase: 'setup',
- config: { ...config, resolution: '', argument: '' },
- }).success).toBe(true);
- expect(CrossfireStateSchema.safeParse({
- phase: 'active',
- sessionId: 'session-1',
- config: { ...config, resolution: '' },
- turns: [],
- startedAt: 1,
- endsAt: 2,
- request: { status: 'idle' },
- }).success).toBe(false);
- });
-});
diff --git a/src/test/crossfire/storage.test.ts b/src/test/crossfire/storage.test.ts
deleted file mode 100644
index 598626b9e..000000000
--- a/src/test/crossfire/storage.test.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { createCrossfireSetupState, crossfireReducer } from '@/lib/crossfire/reducer';
-import {
- MAX_CROSSFIRE_STORAGE_CHARS,
- clearCrossfireDraftOnAuthChange,
- clearCrossfireDraft,
- loadCrossfireDraft,
- saveCrossfireDraft,
- crossfireStorageKey,
-} from '@/lib/crossfire/storage';
-import type { CrossfireState } from '@/lib/crossfire/schemas';
-
-class MemoryStorage {
- private values = new Map();
- getItem(key: string) { return this.values.get(key) ?? null; }
- setItem(key: string, value: string) { this.values.set(key, value); }
- removeItem(key: string) { this.values.delete(key); }
-}
-
-describe('Crossfire draft storage', () => {
- it('round-trips a schema-validated unfinished setup for the same account', () => {
- const storage = new MemoryStorage();
- const state = createCrossfireSetupState({ resolution: 'Resolved: test', argument: 'Case text' });
- expect(saveCrossfireDraft('user-a', state, storage, 10)).toBe(true);
- expect(loadCrossfireDraft('user-a', storage)).toEqual(state);
- });
-
- it('round-trips version-one drafts containing optional answer signals', () => {
- const storage = new MemoryStorage();
- let state = crossfireReducer(createCrossfireSetupState({ resolution: 'Resolved: test', argument: 'Case text' }), { type: 'session-started', sessionId: 's', now: 1 });
- state = crossfireReducer(state, { type: 'user-turn-submitted', turn: { id: 'T1', sequence: 1, speaker: 'user', kind: 'question', text: 'Why?', createdAt: 1 }, expectedReply: 'answer' });
- state = crossfireReducer(state, {
- type: 'ai-turn-received',
- turn: { id: 'T2', sequence: 2, speaker: 'ai', kind: 'answer', text: 'Because.', createdAt: 2 },
- signal: { answerTurnId: 'T2', answerQuality: 'direct', answerQualityConfidence: 0.9, concession: 'none', concessionConfidence: 0.8, followUpOpportunity: 0.75 },
- });
- expect(saveCrossfireDraft('user-a', state, storage, 10)).toBe(true);
- expect(loadCrossfireDraft('user-a', storage)).toEqual(state);
- });
-
- it('round-trips a voice round with measured facts, and still loads drafts saved before voice rounds', () => {
- const storage = new MemoryStorage();
- const delivery = { speakingSeconds: 4, wordCount: 8, wordsPerMinute: 120, fillerCount: 0, fillers: [] as Array<{ word: string; count: number }>, longPauses: 1, longestPauseSeconds: 3 };
- let state = crossfireReducer(createCrossfireSetupState({ resolution: 'Resolved: test', argument: 'Case text' }), { type: 'voice-toggled', voice: true });
- state = crossfireReducer(state, { type: 'session-started', sessionId: 's', now: 1 });
- state = crossfireReducer(state, { type: 'user-turn-submitted', turn: { id: 'T1', sequence: 1, speaker: 'user', kind: 'question', text: 'Why?', createdAt: 1, delivery }, expectedReply: 'answer' });
- expect(saveCrossfireDraft('user-a', state, storage, 10)).toBe(true);
- expect(loadCrossfireDraft('user-a', storage)).toEqual(state);
-
- // A draft written by the app before voice rounds existed.
- const legacy = {
- version: 1,
- ownerId: 'user-b',
- savedAt: 5,
- state: {
- phase: 'active',
- sessionId: 'legacy',
- config: { resolution: 'Resolved: test', argument: 'Case text', mode: 'question-ai', style: 'cooperative', durationSeconds: 180, useSourceLibrary: false, seedQuestions: [] as string[] },
- turns: [{ id: 'T1', sequence: 1, speaker: 'user', kind: 'question', text: 'Why?', createdAt: 1 }],
- startedAt: 1,
- endsAt: 181_000,
- request: { status: 'pending', expectedReply: 'answer' },
- },
- };
- storage.setItem(crossfireStorageKey('user-b'), JSON.stringify(legacy));
- expect(loadCrossfireDraft('user-b', storage)).toEqual(legacy.state);
- });
-
- it('clears malformed, oversized, or differently owned data', () => {
- const malformed = new MemoryStorage();
- malformed.setItem(crossfireStorageKey('user-a'), '{broken');
- expect(loadCrossfireDraft('user-a', malformed)).toBeNull();
- expect(malformed.getItem(crossfireStorageKey('user-a'))).toBeNull();
-
- const oversized = new MemoryStorage();
- oversized.setItem(crossfireStorageKey('user-a'), 'x'.repeat(MAX_CROSSFIRE_STORAGE_CHARS + 1));
- expect(loadCrossfireDraft('user-a', oversized)).toBeNull();
- expect(oversized.getItem(crossfireStorageKey('user-a'))).toBeNull();
-
- const differentOwner = new MemoryStorage();
- expect(saveCrossfireDraft('user-a', createCrossfireSetupState(), differentOwner)).toBe(true);
- expect(loadCrossfireDraft('user-b', differentOwner)).toBeNull();
- expect(differentOwner.getItem(crossfireStorageKey('user-a'))).not.toBeNull();
- });
-
- it('clears account-bound data on sign-out or account switch, not token refresh', () => {
- const storage = new MemoryStorage();
- storage.setItem(crossfireStorageKey('user-a'), 'draft');
- storage.setItem(crossfireStorageKey('user-b'), 'incoming draft');
- expect(clearCrossfireDraftOnAuthChange(undefined, 'user-b', storage)).toBe(true);
- expect(storage.getItem(crossfireStorageKey('user-b'))).toBe('incoming draft');
- expect(clearCrossfireDraftOnAuthChange('user-a', 'user-a', storage)).toBe(false);
- expect(storage.getItem(crossfireStorageKey('user-a'))).toBe('draft');
- expect(clearCrossfireDraftOnAuthChange('user-a', 'user-b', storage)).toBe(true);
- expect(storage.getItem(crossfireStorageKey('user-a'))).toBeNull();
- expect(storage.getItem(crossfireStorageKey('user-b'))).toBe('incoming draft');
- storage.setItem(crossfireStorageKey('user-a'), 'new draft');
- expect(clearCrossfireDraftOnAuthChange('user-a', undefined, storage)).toBe(true);
- expect(storage.getItem(crossfireStorageKey('user-a'))).toBeNull();
- });
-
- it('removes a finished session instead of persisting it', () => {
- const storage = new MemoryStorage();
- storage.setItem(crossfireStorageKey('user-a'), 'old');
- const complete: CrossfireState = {
- phase: 'complete',
- sessionId: 'session-1',
- config: {
- resolution: 'Resolved: test',
- argument: 'Case text',
- mode: 'question-ai',
- style: 'cooperative',
- durationSeconds: 180,
- useSourceLibrary: false,
- seedQuestions: [],
- },
- turns: [{ id: 'T1', sequence: 1, speaker: 'user', kind: 'question', text: 'Why?', createdAt: 1 }],
- startedAt: 1,
- endedAt: 2,
- endedReason: 'manual',
- debrief: {
- concessionsSecured: [],
- controlledQuestions: [],
- weakOrRepetitiveQuestions: [],
- evasiveOrIncompleteAnswers: [],
- missedFollowUps: [],
- betterFollowUpQuestions: [],
- strongestExchange: null,
- },
- };
- expect(saveCrossfireDraft('user-a', complete, storage)).toBe(true);
- expect(storage.getItem(crossfireStorageKey('user-a'))).toBeNull();
- });
-
- it('fails closed when browser storage is unavailable', () => {
- const unavailable = {
- getItem: () => { throw new Error('blocked'); },
- setItem: () => { throw new Error('blocked'); },
- removeItem: () => { throw new Error('blocked'); },
- };
- expect(loadCrossfireDraft('user-a', unavailable)).toBeNull();
- expect(saveCrossfireDraft('user-a', createCrossfireSetupState(), unavailable)).toBe(false);
- expect(() => clearCrossfireDraftOnAuthChange('user-a', 'user-b', unavailable)).not.toThrow();
- });
-
- it('removes schema-invalid envelopes and owner mismatches from the user namespace', () => {
- const storage = new MemoryStorage();
- storage.setItem(crossfireStorageKey('user-a'), JSON.stringify({ version: 1 }));
- expect(loadCrossfireDraft('user-a', storage)).toBeNull();
- expect(storage.getItem(crossfireStorageKey('user-a'))).toBeNull();
-
- storage.setItem(crossfireStorageKey('user-a'), JSON.stringify({
- version: 1,
- ownerId: 'user-b',
- savedAt: 1,
- state: createCrossfireSetupState(),
- }));
- expect(loadCrossfireDraft('user-a', storage)).toBeNull();
- expect(storage.getItem(crossfireStorageKey('user-a'))).toBeNull();
- });
-
- it('handles explicit clearing, invalid state, and failed completed-state removal', () => {
- const storage = new MemoryStorage();
- storage.setItem(crossfireStorageKey('user-a'), 'draft');
- clearCrossfireDraft('user-a', storage);
- expect(storage.getItem(crossfireStorageKey('user-a'))).toBeNull();
- expect(() => clearCrossfireDraft('', storage)).not.toThrow();
-
- expect(saveCrossfireDraft('user-a', { phase: 'setup', config: {} } as CrossfireState, storage)).toBe(false);
-
- const unavailableRemove = {
- getItem: (): string | null => null,
- setItem: (): void => undefined,
- removeItem: (): void => { throw new Error('blocked'); },
- };
- const state = createCrossfireSetupState({ resolution: 'Resolved: test', argument: 'Case' });
- const active = crossfireReducer(state, { type: 'session-started', sessionId: 's', now: 1 });
- const withQuestion = crossfireReducer(active, {
- type: 'user-turn-submitted',
- turn: { id: 'T1', sequence: 1, speaker: 'user', kind: 'question', text: 'Why?', createdAt: 1 },
- expectedReply: 'answer',
- });
- const withAnswer = crossfireReducer(withQuestion, {
- type: 'ai-turn-received',
- turn: { id: 'T2', sequence: 2, speaker: 'ai', kind: 'answer', text: 'Because.', createdAt: 2 },
- });
- const debriefing = crossfireReducer(withAnswer, { type: 'session-ended', now: 3, reason: 'manual' });
- const complete = crossfireReducer(debriefing, {
- type: 'debrief-received',
- debrief: {
- concessionsSecured: [], controlledQuestions: [], weakOrRepetitiveQuestions: [], evasiveOrIncompleteAnswers: [],
- missedFollowUps: [], betterFollowUpQuestions: [], strongestExchange: null,
- },
- });
- expect(saveCrossfireDraft('user-a', complete, unavailableRemove)).toBe(false);
- });
-});
diff --git a/src/test/date-format.test.ts b/src/test/date-format.test.ts
deleted file mode 100644
index dda47baf8..000000000
--- a/src/test/date-format.test.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { formatEuropeanDate, formatEuropeanDateTime, formatEuropeanTime, formatNumber, formatRelativeDate } from '@/lib/date-format';
-
-describe('date-format', () => {
- afterEach(() => vi.useRealTimers());
-
- it('formats dates as dd/mm/yyyy', () => {
- const localDate = new Date(2026, 4, 19, 13, 45);
- expect(formatEuropeanDate(localDate)).toBe('19/05/2026');
- });
-
- it('formats date-times with dd/mm/yyyy and 24-hour time', () => {
- const localDate = new Date(2026, 4, 19, 13, 45);
- expect(formatEuropeanDateTime(localDate)).toBe('19/05/2026, 13:45');
- });
-
- it('formats time with 24-hour clock', () => {
- const localDate = new Date(2026, 4, 19, 13, 45);
- expect(formatEuropeanTime(localDate)).toBe('13:45');
- });
-
- it('returns fallback for invalid values', () => {
- expect(formatEuropeanDate('bad-date', 'Unknown')).toBe('Unknown');
- });
-
- it('formats Hungarian dates, date-times, times, and numbers', () => {
- const localDate = new Date(2026, 4, 19, 13, 45);
- expect(formatEuropeanDate(localDate, 'N/A', 'hu')).toBe('2026. 05. 19.');
- expect(formatEuropeanDateTime(localDate, 'N/A', 'hu')).toBe('2026. 05. 19. 13:45');
- expect(formatEuropeanTime(localDate, '', 'hu')).toBe('13:45');
- expect(formatNumber(1234, 'hu')).toBe('1234');
- });
-
- it('formats relative dates in Hungarian', () => {
- vi.useFakeTimers();
- vi.setSystemTime(new Date(2026, 4, 19, 13, 45));
- expect(formatRelativeDate(new Date(2026, 4, 19, 12, 45), 'hu')).toBe('körülbelül 1 órával ezelőtt');
- });
-});
diff --git a/src/test/debate/motion.test.ts b/src/test/debate/motion.test.ts
deleted file mode 100644
index 613f5c692..000000000
--- a/src/test/debate/motion.test.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { decideMotionTarget, shouldPrefillMotion } from '@/lib/debate/motion';
-import type { Debate } from '@/lib/stores/debate-store';
-
-const debate = (id: string, motion: string): Debate => ({ id, motion, settings: {}, instructions: '', lastActivityAt: 0, createdAt: 0 });
-const homework = debate('d1', 'This House would ban homework');
-const zoos = debate('d2', 'This House would abolish zoos');
-
-describe('decideMotionTarget', () => {
- it('attaches silently when the motion matches the Current Debate, ignoring case and spacing', () => {
- expect(decideMotionTarget(' this house would BAN homework ', { debates: [homework, zoos], currentDebateId: 'd1', oneOff: false }))
- .toEqual({ kind: 'attach', debateId: 'd1' });
- });
-
- it('asks before using a different motion and offers only exactly matching debates to switch to', () => {
- const decision = decideMotionTarget('This House would abolish zoos', { debates: [homework, zoos], currentDebateId: 'd1', oneOff: false });
- expect(decision).toEqual({ kind: 'ask', matches: [zoos] });
- expect(decideMotionTarget('A brand new motion', { debates: [homework, zoos], currentDebateId: 'd1', oneOff: false }))
- .toEqual({ kind: 'ask', matches: [] });
- });
-
- it('asks when there is no Current Debate yet', () => {
- expect(decideMotionTarget('Anything', { debates: [], currentDebateId: null, oneOff: false })).toEqual({ kind: 'ask', matches: [] });
- });
-
- it('never attaches in one-off mode', () => {
- expect(decideMotionTarget('This House would ban homework', { debates: [homework], currentDebateId: 'd1', oneOff: true }))
- .toEqual({ kind: 'one-off' });
- });
-});
-
-describe('shouldPrefillMotion', () => {
- const known = [homework.motion, zoos.motion];
- it('fills an empty field', () => expect(shouldPrefillMotion('', homework.motion, known)).toBe(true));
- it('replaces another debate’s motion after a switch', () => expect(shouldPrefillMotion(zoos.motion, homework.motion, known)).toBe(true));
- it('never overwrites text the user typed', () => expect(shouldPrefillMotion('My own draft motion', homework.motion, known)).toBe(false));
- it('leaves the current motion alone', () => expect(shouldPrefillMotion(homework.motion, homework.motion, known)).toBe(false));
-});
diff --git a/src/test/export-google-docs.test.ts b/src/test/export-google-docs.test.ts
deleted file mode 100644
index 36dd08782..000000000
--- a/src/test/export-google-docs.test.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import type { GenerateOutput } from '@/lib/ai/schemas/generate';
-
-const { getSessionMock } = vi.hoisted(() => ({
- getSessionMock: vi.fn(),
-}));
-
-vi.mock('@/lib/supabase/client', () => ({
- SUPABASE_URL: 'https://example.supabase.co',
- supabase: {
- auth: { getSession: getSessionMock },
- },
-}));
-
-import { exportCaseToGoogleDocs } from '@/lib/export-utils';
-
-const validCase: GenerateOutput = {
- caseStructure: { thesis: 'Thesis', framework: 'Framework', keyDefinitions: [], burdenOfProof: 'Burden' },
- contentions: [],
- predictedOpponentCase: { likelyArguments: [], strategicWeaknesses: [], keyClashes: [] },
- counters: [],
- metadata: { side: 'pro', format: 'bp', generatedAt: '2026-01-01T00:00:00Z' },
-};
-
-describe('Google Docs export credential handling', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- localStorage.clear();
- sessionStorage.clear();
- getSessionMock.mockResolvedValue({
- data: { session: { access_token: 'app-token', provider_token: undefined } },
- error: null,
- });
- });
-
- afterEach(() => {
- vi.useRealTimers();
- vi.restoreAllMocks();
- });
-
- it('uses the session-scoped provider token instead of stale local storage', async () => {
- localStorage.setItem('google_provider_token', 'stale-token');
- sessionStorage.setItem('google_provider_token', 'session-token');
- const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({ documentUrl: 'https://docs.google.com/document/d/1' }), { status: 200 }));
- globalThis.fetch = fetchMock;
-
- await expect(exportCaseToGoogleDocs(validCase)).resolves.toEqual({
- success: true,
- documentUrl: 'https://docs.google.com/document/d/1',
- });
-
- const request = fetchMock.mock.calls[0]?.[1];
- expect(JSON.parse(String(request?.body))).toMatchObject({ accessToken: 'session-token' });
- });
-
- it('stores refreshed provider access tokens only for the browser session', async () => {
- sessionStorage.setItem('google_provider_token', 'session-token');
- globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
- documentUrl: 'https://docs.google.com/document/d/1',
- newAccessToken: 'refreshed-token',
- }), { status: 200 }));
-
- await exportCaseToGoogleDocs(validCase);
-
- expect(sessionStorage.getItem('google_provider_token')).toBe('refreshed-token');
- expect(localStorage.getItem('google_provider_token')).toBeNull();
- });
-
- it('reports a specific error when the export request times out', async () => {
- vi.useFakeTimers();
- sessionStorage.setItem('google_provider_token', 'session-token');
- globalThis.fetch = vi.fn((_url, init) => new Promise((_resolve, reject) => {
- const signal = (init as RequestInit).signal;
- if (!signal) {
- reject(new Error('request was not abortable'));
- return;
- }
- signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
- })) as typeof fetch;
-
- const exportPromise = exportCaseToGoogleDocs(validCase);
- await vi.advanceTimersByTimeAsync(30_000);
-
- await expect(exportPromise).resolves.toEqual({
- success: false,
- error: 'Google Docs export timed out. Please try again.',
- });
- });
-
- it('reports a specific error when the response body stalls past the deadline', async () => {
- vi.useFakeTimers();
- sessionStorage.setItem('google_provider_token', 'session-token');
- globalThis.fetch = vi.fn((_url, init) => {
- const signal = (init as RequestInit).signal;
- return Promise.resolve({
- ok: true,
- json: () => new Promise((_resolve, reject) => {
- signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
- }),
- } as Response);
- }) as typeof fetch;
-
- const exportPromise = exportCaseToGoogleDocs(validCase);
- await vi.advanceTimersByTimeAsync(0);
- await vi.advanceTimersByTimeAsync(30_000);
-
- await expect(Promise.race([
- exportPromise,
- Promise.resolve({ pending: true }),
- ])).resolves.toEqual({
- success: false,
- error: 'Google Docs export timed out. Please try again.',
- });
- });
-});
diff --git a/src/test/export-telemetry.test.ts b/src/test/export-telemetry.test.ts
deleted file mode 100644
index 0e36248ad..000000000
--- a/src/test/export-telemetry.test.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import type { AttackOutput } from '@/lib/ai/schemas/attack';
-import type { BrainstormOutput } from '@/lib/ai/schemas/brainstorm';
-import type { FactoidsOutput } from '@/lib/ai/schemas/factoids';
-import type { GenerateOutput } from '@/lib/ai/schemas/generate';
-import { buildSparringTurns } from '@/lib/sparring/formats';
-import type { SparringSession } from '@/lib/stores/sparring-store';
-
-const mocks = vi.hoisted(() => ({
- captureProductEvent: vi.fn(),
- getSession: vi.fn(),
-}));
-
-vi.mock('@/lib/observability', () => ({
- captureProductEvent: mocks.captureProductEvent,
-}));
-
-vi.mock('@/lib/supabase/client', () => ({
- SUPABASE_URL: 'https://example.supabase.co',
- supabase: { auth: { getSession: mocks.getSession } },
-}));
-
-vi.mock('docx', () => ({
- Document: vi.fn(function DocumentMock(options: Record) {
- return { options };
- }),
- Packer: { toBlob: vi.fn().mockResolvedValue(new Blob(['docx'])) },
- Paragraph: vi.fn(function ParagraphMock(options: Record) {
- return options;
- }),
- TextRun: vi.fn(function TextRunMock(options: Record | string) {
- return typeof options === 'string' ? { text: options } : options;
- }),
- HeadingLevel: { HEADING_1: 'HEADING_1', HEADING_2: 'HEADING_2' },
- AlignmentType: { CENTER: 'CENTER' },
-}));
-
-
-vi.mock('jspdf', () => ({
- jsPDF: class MockJsPdf {
- constructor() {
- return {
- internal: { pageSize: { height: 297, width: 210 } },
- lastAutoTable: { finalY: 100 },
- setFillColor: vi.fn(),
- rect: vi.fn(),
- setTextColor: vi.fn(),
- setFontSize: vi.fn(),
- setFont: vi.fn(),
- text: vi.fn(),
- setDrawColor: vi.fn(),
- setLineWidth: vi.fn(),
- line: vi.fn(),
- getTextWidth: vi.fn(() => 10),
- splitTextToSize: vi.fn((text: string) => [text]),
- addPage: vi.fn(),
- getNumberOfPages: vi.fn(() => 1),
- setPage: vi.fn(),
- save: vi.fn(),
- };
- }
- },
-}));
-
-vi.mock('jspdf-autotable', () => ({
- default: vi.fn((doc: Record) => {
- doc.lastAutoTable = { finalY: 100 };
- }),
-}));
-
-import {
- exportAttackToDocx,
- exportAttackToGoogleDocs,
- exportBrainstormToDocx,
- exportBrainstormToGoogleDocs,
- exportCaseToDocx,
- exportCaseToGoogleDocs,
- exportFactoidsToDocx,
- exportFactoidsToGoogleDocs,
- exportSparringToDocx,
-} from '@/lib/export-utils';
-import {
- exportAttackToPdf,
- exportBrainstormToPdf,
- exportCaseToPdf,
- exportFactoidsToPdf,
- exportSparringToPdf,
-} from '@/lib/pdf-export-utils';
-
-const validCase: GenerateOutput = {
- caseStructure: {
- thesis: 'Private thesis content',
- framework: 'Framework',
- keyDefinitions: [],
- burdenOfProof: 'Burden',
- },
- contentions: [],
- predictedOpponentCase: { likelyArguments: [], strategicWeaknesses: [], keyClashes: [] },
- counters: [],
- metadata: { side: 'pro', format: 'bp', generatedAt: '2026-01-01T00:00:00Z' },
-};
-
-const validAttack: AttackOutput = {
- overallAssessment: { score: 75, summary: 'Private summary content', strengthAreas: [] },
- weaknesses: [],
- fallaciesDetected: [],
- crossExQuestions: [],
- weighingAnalysis: { currentWeighing: '', missingComparisons: [], improvementSuggestions: [] },
-};
-
-const validBrainstorm: BrainstormOutput = {
- needsMoreInfo: false,
- mindmap: [],
- buckets: [],
- topFive: [],
- nextSteps: [],
- warningsAndTradeoffs: [],
-};
-
-const validFactoids: FactoidsOutput = {
- factoids: [],
- searchSuggestions: [],
- warnings: [],
- verificationStatus: 'complete',
-};
-
-const validSparringSession: SparringSession = {
- id: '00000000-0000-4000-8000-000000000010',
- motion: 'Private motion content',
- userSide: 'pro',
- format: 'pf',
- difficulty: 'hard',
- turns: buildSparringTurns('pf', 'pro'),
- speeches: [],
- currentTurnIndex: 0,
- phase: 'complete',
- draft: '',
- timerRemainingSeconds: 0,
- timerRunning: false,
- timerDeadline: null,
- createdAt: 0,
- updatedAt: 0,
-};
-
-describe('content export telemetry', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- mocks.getSession.mockResolvedValue({
- data: { session: { access_token: 'app-token', provider_token: 'provider-token' } },
- error: null,
- });
- globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ documentUrl: 'https://docs.google.com/document/d/example' }), { status: 200 }));
- });
-
- it('records only enum-only format and tool values for successful exports', async () => {
- const exports = [
- { format: 'docx', tool: 'generate', run: () => exportCaseToDocx(validCase) },
- { format: 'docx', tool: 'attack', run: () => exportAttackToDocx(validAttack) },
- { format: 'docx', tool: 'brainstorm', run: () => exportBrainstormToDocx(validBrainstorm) },
- { format: 'docx', tool: 'factoids', run: () => exportFactoidsToDocx(validFactoids) },
- { format: 'docx', tool: 'sparring', run: () => exportSparringToDocx(validSparringSession) },
- { format: 'pdf', tool: 'generate', run: () => exportCaseToPdf(validCase) },
- { format: 'pdf', tool: 'attack', run: () => exportAttackToPdf(validAttack) },
- { format: 'pdf', tool: 'brainstorm', run: () => exportBrainstormToPdf(validBrainstorm) },
- { format: 'pdf', tool: 'factoids', run: () => exportFactoidsToPdf(validFactoids) },
- { format: 'pdf', tool: 'sparring', run: () => exportSparringToPdf(validSparringSession) },
- { format: 'google_docs', tool: 'generate', run: () => exportCaseToGoogleDocs(validCase) },
- { format: 'google_docs', tool: 'attack', run: () => exportAttackToGoogleDocs(validAttack) },
- { format: 'google_docs', tool: 'brainstorm', run: () => exportBrainstormToGoogleDocs(validBrainstorm) },
- { format: 'google_docs', tool: 'factoids', run: () => exportFactoidsToGoogleDocs(validFactoids) },
- ] as const;
-
- for (const exportOperation of exports) {
- await exportOperation.run();
- }
-
- expect(mocks.captureProductEvent).toHaveBeenCalledTimes(exports.length);
- exports.forEach(({ format, tool }, index) => {
- expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(index + 1, 'content_exported', { format, tool });
- });
- expect(JSON.stringify(mocks.captureProductEvent.mock.calls)).not.toContain('Private thesis content');
- expect(JSON.stringify(mocks.captureProductEvent.mock.calls)).not.toContain('Private motion content');
- });
-
- it('does not record validation or request failures as exports', async () => {
- await exportCaseToDocx({ ...validCase, metadata: undefined } as unknown as GenerateOutput);
- mocks.getSession.mockResolvedValue({ data: { session: null }, error: null });
- await exportCaseToGoogleDocs(validCase);
-
- expect(mocks.captureProductEvent).not.toHaveBeenCalled();
- });
-});
diff --git a/src/test/export-utils-extended.test.ts b/src/test/export-utils-extended.test.ts
deleted file mode 100644
index c00590280..000000000
--- a/src/test/export-utils-extended.test.ts
+++ /dev/null
@@ -1,225 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import type { GenerateOutput } from '@/lib/ai/schemas/generate';
-import type { AttackOutput } from '@/lib/ai/schemas/attack';
-import type { BrainstormOutput } from '@/lib/ai/schemas/brainstorm';
-import type { FactoidsOutput } from '@/lib/ai/schemas/factoids';
-
-vi.mock('docx', () => ({
- Document: vi.fn(function DocumentMock() {}),
- Packer: {
- toBlob: vi.fn().mockResolvedValue(new Blob(['test'])),
- },
- Paragraph: vi.fn(function ParagraphMock() {}),
- TextRun: vi.fn(function TextRunMock() {}),
- HeadingLevel: { HEADING_1: 'HEADING_1', HEADING_2: 'HEADING_2' },
- AlignmentType: { CENTER: 'CENTER' },
-}));
-
-
-vi.mock('@/lib/supabase/client', () => ({
- supabase: {
- auth: { getSession: vi.fn(async () => ({ data: { session: null } })) },
- from: vi.fn(() => ({ upsert: vi.fn(async () => ({ data: null, error: null })) })),
- },
- SUPABASE_URL: 'http://localhost',
-}));
-
-import {
- exportCaseToDocx,
- exportAttackToDocx,
- exportBrainstormToDocx,
- exportFactoidsToDocx,
- exportCaseToGoogleDocs,
- exportAttackToGoogleDocs,
- exportBrainstormToGoogleDocs,
- exportFactoidsToGoogleDocs,
-} from '@/lib/export-utils';
-import i18n from '@/i18n';
-
-describe('Export Utils - Extended', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- const validCase: GenerateOutput = {
- caseStructure: {
- thesis: 'Test thesis.',
- framework: 'Utilitarian',
- keyDefinitions: ['A'],
- burdenOfProof: 'Net positive',
- },
- contentions: [{ id: 'c1', claim: 'Claim', reasoning: 'R', evidence: 'E', needsSource: false, impact: 'I' }],
- predictedOpponentCase: { likelyArguments: ['Arg'], strategicWeaknesses: ['W'], keyClashes: ['K'] },
- counters: [{ targetArgument: 'T', counterpoint: 'C', rebuttal: 'R' }],
- metadata: { side: 'pro', format: 'bp', generatedAt: '2024-01-01T00:00:00Z' },
- };
-
- const validAttack: AttackOutput = {
- overallAssessment: { score: 75, summary: 'Decent case', strengthAreas: ['Clear framework'] },
- weaknesses: [{ id: 'weakness-1', type: 'logical', description: 'Weak point', severity: 'major', whyWeak: 'Because', howOpponentAttacks: 'Like this', improvedVersion: 'Better' }],
- fallaciesDetected: [],
- crossExQuestions: [],
- weighingAnalysis: {
- currentWeighing: 'None',
- missingComparisons: [],
- improvementSuggestions: [],
- },
- };
-
- const validBrainstorm: BrainstormOutput = {
- topFive: [{ rank: 1, idea: { title: 'Idea', description: 'Desc' }, whyTop: 'Good' }],
- mindmap: [{ category: 'Cat', items: [{ title: 'T', description: 'D' }] }],
- buckets: [{ name: 'B', keyInsight: 'K', ideas: [{ title: 'I' }] }],
- nextSteps: ['Step 1'],
- } as any;
-
- const validFactoids: FactoidsOutput = {
- factoids: [{ shortClaim: 'Claim', keyFigure: '50%', context: 'Ctx', howToUse: 'Use', relevanceScore: 80, citation: { title: 'T', publisher: 'P', verified: 'verified' } }],
- warnings: [],
- } as any;
-
- describe('exportAttackToDocx', () => {
- it('exports valid attack successfully', async () => {
- const result = await exportAttackToDocx(validAttack);
- expect(result).toBe(true);
- });
-
- it('returns false when overallAssessment is missing', async () => {
- const result = await exportAttackToDocx({ ...validAttack, overallAssessment: undefined } as any);
- expect(result).toBe(false);
- });
-
- it('returns false when weaknesses is not an array', async () => {
- const result = await exportAttackToDocx({ ...validAttack, weaknesses: null } as any);
- expect(result).toBe(false);
- });
-
- it('handles empty weaknesses array', async () => {
- const result = await exportAttackToDocx({ ...validAttack, weaknesses: [] });
- expect(result).toBe(true);
- });
-
- it('handles empty fallaciesDetected', async () => {
- const result = await exportAttackToDocx({ ...validAttack, fallaciesDetected: [] });
- expect(result).toBe(true);
- });
-
- it('handles empty crossExQuestions', async () => {
- const result = await exportAttackToDocx({ ...validAttack, crossExQuestions: [] });
- expect(result).toBe(true);
- });
- });
-
- describe('exportBrainstormToDocx', () => {
- it('exports valid brainstorm successfully', async () => {
- const result = await exportBrainstormToDocx(validBrainstorm);
- expect(result).toBe(true);
- });
-
- it('handles empty topFive', async () => {
- const result = await exportBrainstormToDocx({ ...validBrainstorm, topFive: [] });
- expect(result).toBe(true);
- });
-
- it('handles empty mindmap', async () => {
- const result = await exportBrainstormToDocx({ ...validBrainstorm, mindmap: [] });
- expect(result).toBe(true);
- });
-
- it('handles empty buckets', async () => {
- const result = await exportBrainstormToDocx({ ...validBrainstorm, buckets: [] });
- expect(result).toBe(true);
- });
-
- it('handles empty nextSteps', async () => {
- const result = await exportBrainstormToDocx({ ...validBrainstorm, nextSteps: [] });
- expect(result).toBe(true);
- });
- });
-
- describe('exportFactoidsToDocx', () => {
- it('exports valid factoids successfully', async () => {
- const result = await exportFactoidsToDocx(validFactoids);
- expect(result).toBe(true);
- });
-
- it('returns false when factoids is not an array', async () => {
- const result = await exportFactoidsToDocx({ factoids: null } as any);
- expect(result).toBe(false);
- });
-
- it('handles empty factoids array', async () => {
- const result = await exportFactoidsToDocx({ factoids: [], warnings: [] } as any);
- expect(result).toBe(true);
- });
-
- it('handles factoids with warnings', async () => {
- const result = await exportFactoidsToDocx({ ...validFactoids, warnings: ['Warning 1'] } as any);
- expect(result).toBe(true);
- });
- });
-
- describe('export with different languages', () => {
- it('exports case with Spanish language active', async () => {
- await i18n.changeLanguage('es');
- const result = await exportCaseToDocx(validCase);
- expect(result).toBe(true);
- await i18n.changeLanguage('en');
- });
-
- it('exports case with Hungarian language active', async () => {
- await i18n.changeLanguage('hu');
- const result = await exportCaseToDocx(validCase);
- expect(result).toBe(true);
- await i18n.changeLanguage('en');
- });
-
- it('exports attack with Spanish language', async () => {
- await i18n.changeLanguage('es');
- const result = await exportAttackToDocx(validAttack);
- expect(result).toBe(true);
- await i18n.changeLanguage('en');
- });
-
- it('exports brainstorm with Hungarian language', async () => {
- await i18n.changeLanguage('hu');
- const result = await exportBrainstormToDocx(validBrainstorm);
- expect(result).toBe(true);
- await i18n.changeLanguage('en');
- });
- });
-
- describe('Google Docs exports - validation', () => {
- it('exportCaseToGoogleDocs rejects missing metadata', async () => {
- const result = await exportCaseToGoogleDocs({ ...validCase, metadata: undefined } as any);
- expect(result.success).toBe(false);
- });
-
- it('exportCaseToGoogleDocs rejects missing caseStructure', async () => {
- const result = await exportCaseToGoogleDocs({ ...validCase, caseStructure: undefined } as any);
- expect(result.success).toBe(false);
- });
-
- it('exportCaseToGoogleDocs rejects non-array contentions', async () => {
- const result = await exportCaseToGoogleDocs({ ...validCase, contentions: null } as any);
- expect(result.success).toBe(false);
- });
-
- it('exportAttackToGoogleDocs rejects missing overallAssessment', async () => {
- const result = await exportAttackToGoogleDocs({ ...validAttack, overallAssessment: undefined } as any);
- expect(result.success).toBe(false);
- });
-
- it('exportAttackToGoogleDocs rejects non-array weaknesses', async () => {
- const result = await exportAttackToGoogleDocs({ ...validAttack, weaknesses: null } as any);
- expect(result.success).toBe(false);
- });
-
- it('exportFactoidsToGoogleDocs rejects non-array factoids', async () => {
- const result = await exportFactoidsToGoogleDocs({ factoids: null } as any);
- expect(result.success).toBe(false);
- });
- });
-
-});
diff --git a/src/test/export-utils.test.ts b/src/test/export-utils.test.ts
deleted file mode 100644
index 34d46e12d..000000000
--- a/src/test/export-utils.test.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-// src/test/export-utils.test.ts
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import type { GenerateOutput } from '@/lib/ai/schemas/generate';
-
-// Mock docx; downloads are captured via the real DOM path
-vi.mock('docx', () => ({
- Document: vi.fn(function DocumentMock() {}),
- Packer: {
- toBlob: vi.fn().mockResolvedValue(new Blob(['test'], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' })),
- },
- Paragraph: vi.fn(function ParagraphMock() {}),
- TextRun: vi.fn(function TextRunMock() {}),
- HeadingLevel: { HEADING_1: 'HEADING_1', HEADING_2: 'HEADING_2' },
- AlignmentType: { CENTER: 'CENTER' },
-}));
-
-// Import after mocking
-import { exportCaseToDocx } from '@/lib/export-utils';
-import { captureDownloads } from './helpers/downloads';
-
-describe('Export Utils', () => {
- let downloads: ReturnType;
-
- beforeEach(() => {
- vi.clearAllMocks();
- downloads = captureDownloads();
- });
-
- const createValidOutput = (overrides: Partial = {}): GenerateOutput => ({
- caseStructure: {
- thesis: 'Technology improves education significantly.',
- framework: 'Utilitarian analysis',
- keyDefinitions: ['Technology', 'Education'],
- burdenOfProof: 'Net positive impact',
- },
- contentions: [
- {
- id: 'c1',
- claim: 'Increases access',
- reasoning: 'Online reaches more people',
- evidence: 'UNESCO data shows 50% increase',
- needsSource: false,
- impact: 'More educated population',
- },
- ],
- predictedOpponentCase: {
- likelyArguments: ['Digital divide concerns'],
- strategicWeaknesses: ['Infrastructure dependency'],
- keyClashes: ['Definition of improvement'],
- },
- counters: [
- {
- targetArgument: 'Digital divide',
- counterpoint: 'Mobile adoption closing gap',
- rebuttal: 'Infrastructure improving',
- },
- ],
- metadata: {
- side: 'pro',
- format: 'bp',
- generatedAt: '2024-01-01T00:00:00Z',
- },
- ...overrides,
- });
-
- describe('exportCaseToDocx', () => {
- it('exports valid case successfully', async () => {
- const result = await exportCaseToDocx(createValidOutput());
- expect(result).toBe(true);
- expect(downloads).toHaveLength(1);
- });
-
- it('generates correct filename for pro side', async () => {
- await exportCaseToDocx(createValidOutput({ metadata: { side: 'pro', format: 'bp', generatedAt: '2024-01-01' } }));
- expect(downloads.at(-1)?.filename).toBe('DebateCase_pro.docx');
- });
-
- it('generates correct filename for con side', async () => {
- await exportCaseToDocx(createValidOutput({ metadata: { side: 'con', format: 'bp', generatedAt: '2024-01-01' } }));
- expect(downloads.at(-1)?.filename).toBe('DebateCase_con.docx');
- });
-
- it('returns false when metadata is missing', async () => {
- const result = await exportCaseToDocx({ ...createValidOutput(), metadata: undefined } as unknown as GenerateOutput);
- expect(result).toBe(false);
- });
-
- it('returns false when caseStructure is missing', async () => {
- const result = await exportCaseToDocx({ ...createValidOutput(), caseStructure: undefined } as unknown as GenerateOutput);
- expect(result).toBe(false);
- });
-
- it('returns false when contentions is not an array', async () => {
- const result = await exportCaseToDocx({ ...createValidOutput(), contentions: null } as unknown as GenerateOutput);
- expect(result).toBe(false);
- });
-
- it('returns false when predictedOpponentCase is missing', async () => {
- const result = await exportCaseToDocx({ ...createValidOutput(), predictedOpponentCase: undefined } as unknown as GenerateOutput);
- expect(result).toBe(false);
- });
-
- it('returns false when counters is not an array', async () => {
- const result = await exportCaseToDocx({ ...createValidOutput(), counters: null } as unknown as GenerateOutput);
- expect(result).toBe(false);
- });
-
- it('handles empty contentions array', async () => {
- const result = await exportCaseToDocx(createValidOutput({ contentions: [] }));
- expect(result).toBe(true);
- });
-
- it('handles empty counters array', async () => {
- const result = await exportCaseToDocx(createValidOutput({ counters: [] }));
- expect(result).toBe(true);
- });
-
- it('handles empty predicted arguments array', async () => {
- const result = await exportCaseToDocx(createValidOutput({
- predictedOpponentCase: {
- likelyArguments: [],
- strategicWeaknesses: [],
- keyClashes: [],
- },
- }));
- expect(result).toBe(true);
- });
-
- it('handles missing thesis gracefully', async () => {
- const result = await exportCaseToDocx(createValidOutput({
- caseStructure: {
- thesis: '',
- framework: 'Test',
- keyDefinitions: [],
- burdenOfProof: 'Test',
- },
- }));
- expect(result).toBe(true);
- });
-
- it('handles invalid date in generatedAt', async () => {
- const result = await exportCaseToDocx(createValidOutput({
- metadata: { side: 'pro', format: 'bp', generatedAt: 'invalid-date' },
- }));
- expect(result).toBe(true);
- });
-
- it('handles contention with missing optional fields', async () => {
- const result = await exportCaseToDocx(createValidOutput({
- contentions: [
- {
- id: 'c1',
- claim: '',
- reasoning: '',
- evidence: '',
- needsSource: false,
- impact: '',
- },
- ],
- }));
- expect(result).toBe(true);
- });
- });
-});
diff --git a/src/test/helpers/downloads.ts b/src/test/helpers/downloads.ts
deleted file mode 100644
index 4272160fc..000000000
--- a/src/test/helpers/downloads.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { vi } from "vitest";
-
-export interface CapturedDownload {
- blob: Blob;
- filename: string;
-}
-
-/**
- * Captures downloads triggered by the `URL.createObjectURL` + ``
- * path used across the export helpers, so tests can assert on filenames
- * without mocking a module.
- */
-export function captureDownloads(): CapturedDownload[] {
- const downloads: CapturedDownload[] = [];
- let lastBlob: Blob | undefined;
-
- vi.spyOn(URL, "createObjectURL").mockImplementation((source: Blob | MediaSource) => {
- if (source instanceof Blob) lastBlob = source;
- return "blob:mock";
- });
-
- vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) {
- if (!this.download) return;
- downloads.push({ blob: lastBlob ?? new Blob(), filename: this.download });
- });
-
- return downloads;
-}
diff --git a/src/test/hooks/use-subscription-error.test.ts b/src/test/hooks/use-subscription-error.test.ts
deleted file mode 100644
index 628b2504e..000000000
--- a/src/test/hooks/use-subscription-error.test.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { act, renderHook } from '@testing-library/react';
-import { useSubscriptionError } from '@/hooks/use-subscription-error';
-import { AIModelRestrictionError, AISubscriptionError } from '@/lib/ai/client';
-
-describe('useSubscriptionError', () => {
- it('handles trial-expired subscription errors', () => {
- const { result } = renderHook(() => useSubscriptionError());
- let handled = false;
-
- act(() => {
- handled = result.current.handleSubscriptionError(
- new AISubscriptionError('Trial expired', {
- code: 'trial_expired',
- limit: 5,
- current: 5,
- })
- );
- });
-
- expect(handled).toBe(true);
- expect(result.current.limitModalOpen).toBe(true);
- expect(result.current.limitModalVariant).toBe('trial_expired');
- expect(result.current.limitModalLimit).toBe(5);
- expect(result.current.limitModalCurrent).toBe(5);
- });
-
- it('defaults to limit_reached variant for generic subscription errors', () => {
- const { result } = renderHook(() => useSubscriptionError());
- let handled = false;
-
- act(() => {
- handled = result.current.handleSubscriptionError(
- new AISubscriptionError('Limit reached', {
- code: 'limit_reached',
- })
- );
- });
-
- expect(handled).toBe(true);
- expect(result.current.limitModalOpen).toBe(true);
- expect(result.current.limitModalVariant).toBe('limit_reached');
- });
-
- it('handles model-restriction errors', () => {
- const { result } = renderHook(() => useSubscriptionError());
- let handled = false;
-
- act(() => {
- handled = result.current.handleSubscriptionError(
- new AIModelRestrictionError('Upgrade required')
- );
- });
-
- expect(handled).toBe(true);
- expect(result.current.modelModalOpen).toBe(true);
- });
-
- it('returns false for non-subscription errors', () => {
- const { result } = renderHook(() => useSubscriptionError());
- let handled = false;
-
- act(() => {
- handled = result.current.handleSubscriptionError(new Error('Other failure'));
- });
-
- expect(handled).toBe(false);
- expect(result.current.limitModalOpen).toBe(false);
- expect(result.current.modelModalOpen).toBe(false);
- });
-
- it('exposes modal state setters', () => {
- const { result } = renderHook(() => useSubscriptionError());
-
- act(() => {
- result.current.setLimitModalOpen(true);
- result.current.setModelModalOpen(true);
- result.current.setPricingModalOpen(true);
- });
-
- expect(result.current.limitModalOpen).toBe(true);
- expect(result.current.modelModalOpen).toBe(true);
- expect(result.current.pricingModalOpen).toBe(true);
- });
-});
diff --git a/src/test/hooks/use-subscription.test.ts b/src/test/hooks/use-subscription.test.ts
deleted file mode 100644
index ad3ab1d5e..000000000
--- a/src/test/hooks/use-subscription.test.ts
+++ /dev/null
@@ -1,549 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { act, renderHook, waitFor } from '@testing-library/react';
-import { createElement, type ReactNode } from 'react';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { useSubscription } from '@/hooks/use-subscription';
-
-interface MockResponses {
- userSettings: {
- subscription_tier?: string;
- daily_limit_override?: number;
- trial_ends_at?: string | null;
- grace_period_ends_at?: string | null;
- } | null;
- usageCount: number | null;
- usageError: { message: string } | null;
- billingData: Array<{ stripe_customer_id?: string | null; status?: string | null; current_period_end?: string | null }> | null;
- billingError: { message: string } | null;
- throwOnUserSettings: boolean;
-}
-
-const {
- mockAuthState,
- mockResponses,
- fromMock,
-} = vi.hoisted(() => {
- const mockAuthState = { user: null as { id: string } | null };
- const mockResponses: MockResponses = {
- userSettings: null,
- usageCount: 0,
- usageError: null,
- billingData: null,
- billingError: null,
- throwOnUserSettings: false,
- };
-
- const fromMock = vi.fn((table: string) => {
- if (table === 'user_settings') {
- return {
- select: vi.fn(() => ({
- eq: vi.fn(() => ({
- maybeSingle: vi.fn(async () => {
- if (mockResponses.throwOnUserSettings) {
- throw new Error('user_settings failed');
- }
- return { data: mockResponses.userSettings, error: null };
- }),
- })),
- })),
- };
- }
-
- if (table === 'ai_requests') {
- return {
- select: vi.fn(() => ({
- eq: vi.fn(() => ({
- gte: vi.fn(async () => ({ count: mockResponses.usageCount, error: mockResponses.usageError })),
- })),
- })),
- };
- }
-
- if (table === 'subscriptions') {
- return {
- select: vi.fn(() => ({
- eq: vi.fn(() => ({
- order: vi.fn(() => ({
- limit: vi.fn(async () => ({
- data: mockResponses.billingData,
- error: mockResponses.billingError,
- })),
- })),
- })),
- })),
- };
- }
-
- throw new Error(`Unexpected table: ${table}`);
- });
-
- return { mockAuthState, mockResponses, fromMock };
-});
-
-vi.mock('@/lib/supabase/AuthProvider', () => ({
- useAuth: () => mockAuthState,
-}));
-
-vi.mock('@/lib/supabase/client', () => ({
- supabase: {
- from: fromMock,
- },
-}));
-
-let queryClient: QueryClient;
-
-function renderSubscriptionHook() {
- return renderHook(() => useSubscription(), {
- wrapper: ({ children }: { children: ReactNode }) => createElement(QueryClientProvider, { client: queryClient }, children),
- });
-}
-
-describe('useSubscription', () => {
- beforeEach(() => {
- queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- vi.clearAllMocks();
- mockAuthState.user = null;
- mockResponses.userSettings = null;
- mockResponses.usageCount = 0;
- mockResponses.usageError = null;
- mockResponses.billingData = null;
- mockResponses.billingError = null;
- mockResponses.throwOnUserSettings = false;
- });
-
- it('returns non-loading default state when user is not signed in', async () => {
- const { result } = renderSubscriptionHook();
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false);
- });
-
- expect(result.current.tier).toBe('trial');
- expect(result.current.hasStripeBilling).toBe(false);
- expect(result.current.dailyLimit).toBe(5);
- expect(result.current.dailyUsed).toBe(0);
- expect(fromMock).not.toHaveBeenCalled();
- });
-
- it('loads active trial state with override limit and billing enabled', async () => {
- mockAuthState.user = { id: 'user-1' };
- mockResponses.userSettings = {
- subscription_tier: 'trial',
- daily_limit_override: 2,
- trial_ends_at: '2099-12-31T00:00:00.000Z',
- grace_period_ends_at: null,
- };
- mockResponses.usageCount = 3;
- mockResponses.billingData = [{ stripe_customer_id: 'cus_123', status: 'active', current_period_end: '2099-12-31T00:00:00.000Z' }];
-
- const { result } = renderSubscriptionHook();
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false);
- });
-
- expect(result.current.tier).toBe('trial');
- expect(result.current.isTrialActive).toBe(true);
- expect(result.current.isExpired).toBe(false);
- expect(result.current.dailyLimit).toBe(2);
- expect(result.current.dailyUsed).toBe(3);
- expect(result.current.hasStripeBilling).toBe(true);
- expect(result.current.trialEndsAt).toBe('2099-12-31T00:00:00.000Z');
- });
-
- it('uses pro override when backed by active Stripe billing', async () => {
- mockAuthState.user = { id: 'user-pro' };
- mockResponses.userSettings = {
- subscription_tier: 'pro',
- daily_limit_override: 80,
- };
- mockResponses.billingData = [{ stripe_customer_id: 'cus_pro', status: 'active', current_period_end: '2099-12-31T00:00:00.000Z' }];
-
- const { result } = renderSubscriptionHook();
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false);
- });
-
- expect(result.current.tier).toBe('pro');
- expect(result.current.dailyLimit).toBe(80);
- expect(result.current.isPro).toBe(true);
- });
-
- it('keeps promo-backed pro active until grace period ends', async () => {
- mockAuthState.user = { id: 'promo-pro' };
- mockResponses.userSettings = {
- subscription_tier: 'pro',
- daily_limit_override: 60,
- grace_period_ends_at: '2099-12-31T00:00:00.000Z',
- };
- mockResponses.billingError = { message: 'billing read failed' };
-
- const { result } = renderSubscriptionHook();
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false);
- });
-
- expect(result.current.tier).toBe('pro');
- expect(result.current.dailyLimit).toBe(60);
- expect(result.current.isPro).toBe(true);
- expect(result.current.hasStripeBilling).toBe(false);
- });
-
- it('handles legacy_free grace-period transitions on refresh', async () => {
- mockAuthState.user = { id: 'user-legacy' };
- mockResponses.userSettings = {
- subscription_tier: 'legacy_free',
- daily_limit_override: 20,
- grace_period_ends_at: '2099-01-01T00:00:00.000Z',
- };
-
- const { result } = renderSubscriptionHook();
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false);
- expect(result.current.tier).toBe('legacy_free');
- });
-
- expect(result.current.isPro).toBe(true);
- expect(result.current.dailyLimit).toBe(20);
-
- mockResponses.userSettings = {
- subscription_tier: 'legacy_free',
- daily_limit_override: 20,
- grace_period_ends_at: '2000-01-01T00:00:00.000Z',
- };
-
- await act(async () => {
- await result.current.refresh();
- });
-
- await waitFor(() => expect(result.current.tier).toBe('expired'));
- expect(result.current.isExpired).toBe(true);
- expect(result.current.dailyLimit).toBe(20);
- });
-
- it('expires legacy_free users without a grace period end date', async () => {
- mockAuthState.user = { id: 'legacy-no-grace' };
- mockResponses.userSettings = {
- subscription_tier: 'legacy_free',
- daily_limit_override: undefined,
- grace_period_ends_at: null,
- };
-
- const { result } = renderSubscriptionHook();
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false);
- });
-
- expect(result.current.tier).toBe('expired');
- expect(result.current.dailyLimit).toBe(0);
- });
-
- it('handles expired trial and missing billing row', async () => {
- mockAuthState.user = { id: 'user-expired' };
- mockResponses.userSettings = {
- subscription_tier: 'trial',
- daily_limit_override: 2,
- trial_ends_at: '2000-01-01T00:00:00.000Z',
- };
- mockResponses.usageCount = 7;
-
- const { result } = renderSubscriptionHook();
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false);
- });
-
- expect(result.current.tier).toBe('expired');
- expect(result.current.dailyLimit).toBe(2);
- expect(result.current.dailyUsed).toBe(7);
- expect(result.current.hasStripeBilling).toBe(false);
- });
-
- it('treats trial with no trial end date as expired', async () => {
- mockAuthState.user = { id: 'user-no-trial-end' };
- mockResponses.userSettings = {
- subscription_tier: 'trial',
- daily_limit_override: 0,
- trial_ends_at: null,
- };
-
- const { result } = renderSubscriptionHook();
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false);
- });
-
- expect(result.current.tier).toBe('expired');
- expect(result.current.isExpired).toBe(true);
- expect(result.current.dailyLimit).toBe(0);
- });
-
- it('uses active trial override when above default minimum', async () => {
- mockAuthState.user = { id: 'trial-override' };
- mockResponses.userSettings = {
- subscription_tier: 'trial',
- daily_limit_override: 12,
- trial_ends_at: '2099-01-01T00:00:00.000Z',
- };
-
- const { result } = renderSubscriptionHook();
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false);
- });
-
- expect(result.current.tier).toBe('trial');
- expect(result.current.dailyLimit).toBe(12);
- });
-
- it('falls back to expired for unknown subscription tiers', async () => {
- mockAuthState.user = { id: 'user-unknown-tier' };
- mockResponses.userSettings = {
- subscription_tier: 'enterprise',
- daily_limit_override: 13,
- };
-
- const { result } = renderSubscriptionHook();
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false);
- });
-
- expect(result.current.tier).toBe('expired');
- expect(result.current.dailyLimit).toBe(13);
- });
-
- it('honors an active trial gift on an otherwise expired account', async () => {
- mockAuthState.user = { id: 'gifted-user' };
- mockResponses.userSettings = {
- subscription_tier: 'expired',
- trial_ends_at: '2099-01-01T00:00:00.000Z',
- };
-
- const { result } = renderSubscriptionHook();
-
- await waitFor(() => expect(result.current.loading).toBe(false));
- expect(result.current.tier).toBe('trial');
- expect(result.current.dailyLimit).toBe(5);
- });
-
- it('fails closed when switching to an account with no settings row', async () => {
- mockAuthState.user = { id: 'user-a' };
- mockResponses.userSettings = {
- subscription_tier: 'pro',
- daily_limit_override: 80,
- };
- mockResponses.billingData = [{
- stripe_customer_id: 'cus_a',
- status: 'active',
- current_period_end: '2099-12-31T00:00:00.000Z',
- }];
-
- const { result, rerender } = renderSubscriptionHook();
-
- await waitFor(() => {
- expect(result.current.tier).toBe('pro');
- expect(result.current.loading).toBe(false);
- });
-
- mockAuthState.user = { id: 'user-b' };
- mockResponses.userSettings = null;
- mockResponses.billingData = null;
- mockResponses.usageCount = 0;
- rerender();
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false);
- expect(result.current.tier).toBe('expired');
- });
-
- expect(result.current.dailyLimit).toBe(0);
- expect(result.current.dailyUsed).toBe(0);
- expect(result.current.hasStripeBilling).toBe(false);
- });
-
- it('expires pro when billing lookup fails and no promo access is active', async () => {
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
-
- mockAuthState.user = { id: 'user-2' };
- mockResponses.userSettings = {
- subscription_tier: 'pro',
- daily_limit_override: 10,
- };
- mockResponses.billingError = { message: 'billing read failed' };
-
- const { result } = renderSubscriptionHook();
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false);
- });
-
- expect(result.current.tier).toBe('expired');
- expect(result.current.dailyLimit).toBe(10);
- expect(result.current.hasStripeBilling).toBe(false);
- expect(warnSpy).toHaveBeenCalledWith(
- '[useSubscription] Failed to load Stripe billing state:',
- 'billing read failed'
- );
-
- warnSpy.mockRestore();
- });
-
- it('keeps settings-backed trial access when billing lookup fails', async () => {
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
- mockAuthState.user = { id: 'trial-with-billing-error' };
- mockResponses.userSettings = {
- subscription_tier: 'trial',
- trial_ends_at: '2099-12-31T00:00:00.000Z',
- };
- mockResponses.billingError = { message: 'billing read failed' };
-
- const { result } = renderSubscriptionHook();
-
- await waitFor(() => expect(result.current.loading).toBe(false));
- expect(result.current.tier).toBe('trial');
- expect(result.current.dailyLimit).toBe(5);
- expect(result.current.hasStripeBilling).toBe(false);
-
- warnSpy.mockRestore();
- });
-
- it('fails closed when a cached Pro entitlement cannot be refreshed', async () => {
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
- mockAuthState.user = { id: 'stale-pro' };
- mockResponses.userSettings = {
- subscription_tier: 'pro',
- daily_limit_override: 50,
- };
- mockResponses.billingData = [{
- stripe_customer_id: 'cus_stale',
- status: 'active',
- current_period_end: '2099-12-31T00:00:00.000Z',
- }];
-
- const { result } = renderSubscriptionHook();
- await waitFor(() => expect(result.current.tier).toBe('pro'));
-
- mockResponses.throwOnUserSettings = true;
- await act(async () => {
- await result.current.refresh();
- });
-
- await waitFor(() => expect(result.current.tier).toBe('expired'));
- expect(result.current.isPro).toBe(false);
-
- warnSpy.mockRestore();
- });
-
- it('replaces cached Pro access when the billing refresh fails', async () => {
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
- mockAuthState.user = { id: 'billing-refresh-pro' };
- mockResponses.userSettings = {
- subscription_tier: 'pro',
- daily_limit_override: 50,
- };
- mockResponses.billingData = [{
- stripe_customer_id: 'cus_refresh',
- status: 'active',
- current_period_end: '2099-12-31T00:00:00.000Z',
- }];
-
- const { result } = renderSubscriptionHook();
- await waitFor(() => expect(result.current.tier).toBe('pro'));
-
- mockResponses.billingData = null;
- mockResponses.billingError = { message: 'billing read failed' };
- await act(async () => {
- await result.current.refresh();
- });
-
- await waitFor(() => expect(result.current.tier).toBe('expired'));
- expect(result.current.isPro).toBe(false);
-
- warnSpy.mockRestore();
- });
-
- it('keeps validated Pro access when the usage-count refresh fails', async () => {
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
- mockAuthState.user = { id: 'usage-refresh-pro' };
- mockResponses.userSettings = {
- subscription_tier: 'pro',
- daily_limit_override: 50,
- };
- mockResponses.billingData = [{
- stripe_customer_id: 'cus_usage',
- status: 'active',
- current_period_end: '2099-12-31T00:00:00.000Z',
- }];
- mockResponses.usageCount = 3;
-
- const { result } = renderSubscriptionHook();
- await waitFor(() => expect(result.current.tier).toBe('pro'));
- expect(result.current.dailyUsed).toBe(3);
-
- mockResponses.usageError = { message: 'usage read failed' };
- await act(async () => {
- await result.current.refresh();
- });
-
- await waitFor(() => expect(result.current.dailyUsed).toBe(0));
- expect(result.current.tier).toBe('pro');
- expect(result.current.isPro).toBe(true);
-
- warnSpy.mockRestore();
- });
-
- it('handles unexpected load failures and clears loading state', async () => {
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
-
- mockAuthState.user = { id: 'user-3' };
- mockResponses.throwOnUserSettings = true;
-
- const { result } = renderSubscriptionHook();
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false);
- });
-
- expect(result.current.tier).toBe('expired');
- expect(result.current.dailyLimit).toBe(0);
- expect(result.current.hasStripeBilling).toBe(false);
- expect(warnSpy).toHaveBeenCalledWith(
- '[useSubscription] Failed to load:',
- expect.any(Error)
- );
-
- warnSpy.mockRestore();
- });
-
- it('retries a transient entitlement failure when the hook remounts inside the stale window', async () => {
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
- mockAuthState.user = { id: 'recovering-user' };
- mockResponses.throwOnUserSettings = true;
-
- const first = renderSubscriptionHook();
- await waitFor(() => expect(first.result.current.loading).toBe(false));
- expect(first.result.current.tier).toBe('expired');
- first.unmount();
-
- mockResponses.throwOnUserSettings = false;
- mockResponses.userSettings = {
- subscription_tier: 'pro',
- daily_limit_override: 50,
- };
- mockResponses.billingData = [{
- stripe_customer_id: 'cus_recovered',
- status: 'active',
- current_period_end: '2099-12-31T00:00:00.000Z',
- }];
-
- const second = renderSubscriptionHook();
- await waitFor(() => expect(second.result.current.tier).toBe('pro'));
-
- warnSpy.mockRestore();
- });
-});
diff --git a/src/test/hooks/use-toast.test.ts b/src/test/hooks/use-toast.test.ts
deleted file mode 100644
index a06511412..000000000
--- a/src/test/hooks/use-toast.test.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-const sonnerBase = vi.fn();
-const sonnerError = vi.fn();
-const sonnerSuccess = vi.fn();
-
-vi.mock('sonner', () => {
- const toast = Object.assign(sonnerBase, { error: sonnerError, success: sonnerSuccess });
- return { toast };
-});
-
-const { toast } = await import('@/hooks/use-toast');
-
-beforeEach(() => {
- sonnerBase.mockClear();
- sonnerError.mockClear();
- sonnerSuccess.mockClear();
-});
-
-describe('toast adapter', () => {
- it('routes destructive to sonner.error', () => {
- toast({ title: 'Broke', description: 'why', variant: 'destructive' });
- expect(sonnerError).toHaveBeenCalledWith('Broke', { description: 'why', action: undefined });
- expect(sonnerBase).not.toHaveBeenCalled();
- });
-
- it('routes success to sonner.success', () => {
- toast({ title: 'Done', variant: 'success' });
- expect(sonnerSuccess).toHaveBeenCalledWith('Done', { description: undefined, action: undefined });
- });
-
- it('routes default and missing variant to the base toast', () => {
- toast({ title: 'Saved' });
- toast({ title: 'Also saved', variant: 'default' });
- expect(sonnerBase).toHaveBeenCalledTimes(2);
- expect(sonnerError).not.toHaveBeenCalled();
- });
-
- it('forwards an action and tolerates a missing title', () => {
- const action = { label: 'Retry', onClick: () => {} };
- toast({ description: 'no title', action });
- expect(sonnerBase).toHaveBeenCalledWith('', { description: 'no title', action });
- });
-});
diff --git a/src/test/i18n.test.ts b/src/test/i18n.test.ts
deleted file mode 100644
index 966c861bc..000000000
--- a/src/test/i18n.test.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import i18n from '@/i18n';
-import en from '@/locales/en.json';
-import es from '@/locales/es.json';
-import hu from '@/locales/hu.json';
-import {
- APP_LANGUAGES,
- isAppLanguage,
- languageName,
- toIntlLocale,
-} from '@/lib/languages';
-import { applyAppLanguage } from '@/i18n';
-
-function flattenKeys(obj: Record, prefix = ''): string[] {
- return Object.entries(obj).flatMap(([key, value]) => {
- const path = prefix ? `${prefix}.${key}` : key;
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
- return flattenKeys(value as Record, path);
- }
- return [path];
- });
-}
-
-function flattenValues(obj: Record, prefix = ''): Record {
- const result: Record = {};
- Object.entries(obj).forEach(([key, value]) => {
- const path = prefix ? `${prefix}.${key}` : key;
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
- Object.assign(result, flattenValues(value as Record, path));
- } else {
- result[path] = String(value);
- }
- });
- return result;
-}
-
-const HU_IDENTICAL_VALUE_ALLOWLIST = new Set([
- 'common.pdf', 'common.docx', 'common.googleDocs',
- 'home.title', 'settings.tierPro', 'settings.admin',
- 'login.googleTab', 'login.verificationCodePlaceholder', 'login.demoCodePlaceholder',
- 'notFound.title', 'pricing.trialPrice', 'pricing.pro', 'pricing.proPrice',
- 'generateForm.lengthStandard', 'generateResults.durationMs', 'generateResults.word',
- 'generateResults.pdf', 'generateResults.googleDocs', 'generateResults.exordio',
- 'pricingModal.pro', 'pricingModal.freePrice', 'pricingModal.proPrice',
- 'timer.formatStandard', 'timer.formatLD',
- 'timer.speeches.policy.1ac', 'timer.speeches.policy.1nc',
- 'timer.speeches.policy.2ac', 'timer.speeches.policy.2nc',
- 'timer.speeches.policy.1nr', 'timer.speeches.policy.1ar',
- 'timer.speeches.policy.2nr', 'timer.speeches.policy.2ar',
- 'export.exordio', 'sparring.format.ld', 'sparring.format.pf',
- 'sparring.turns.standard-1', 'sparring.turns.standard-3',
- 'sparring.turns.standard-5', 'sparring.turns.standard-7',
- 'sparring.scorecard.exportPdf',
- 'libraryPage.urlSource',
- 'langSwitcher.en', 'langSwitcher.es', 'langSwitcher.hu',
-]);
-
-describe('i18n locale files', () => {
- const enKeys = flattenKeys(en);
- const esKeys = flattenKeys(es);
- const huKeys = flattenKeys(hu);
-
- it('all keys in en.json exist in es.json', () => {
- const esKeySet = new Set(esKeys);
- const missing = enKeys.filter(k => !esKeySet.has(k));
- expect(missing).toEqual([]);
- });
-
- it('all keys in en.json exist in hu.json', () => {
- const huKeySet = new Set(huKeys);
- const missing = enKeys.filter(k => !huKeySet.has(k));
- expect(missing).toEqual([]);
- });
-
- it('all keys in es.json exist in en.json', () => {
- const enKeySet = new Set(enKeys);
- const missing = esKeys.filter(k => !enKeySet.has(k));
- expect(missing).toEqual([]);
- });
-
- it('all keys in hu.json exist in en.json', () => {
- const enKeySet = new Set(enKeys);
- const missing = huKeys.filter(k => !enKeySet.has(k));
- expect(missing).toEqual([]);
- });
-
- it('no empty string values in en.json', () => {
- const values = flattenValues(en);
- const empty = Object.entries(values).filter(([, v]) => v === '');
- expect(empty).toEqual([]);
- });
-
- it('no empty string values in es.json', () => {
- const values = flattenValues(es);
- const empty = Object.entries(values).filter(([, v]) => v === '');
- expect(empty).toEqual([]);
- });
-
- it('no empty string values in hu.json', () => {
- const values = flattenValues(hu);
- const empty = Object.entries(values).filter(([, v]) => v === '');
- expect(empty).toEqual([]);
- });
-
- it('has Hungarian copy for every non-canonical English value', () => {
- const enValues = flattenValues(en);
- const huValues = flattenValues(hu);
- const untranslated = Object.keys(enValues).filter((key) => (
- enValues[key] === huValues[key] && !HU_IDENTICAL_VALUE_ALLOWLIST.has(key)
- ));
-
- expect(untranslated).toEqual([]);
- });
-
- it('names both observability processors in every privacy policy translation', () => {
- for (const locale of [en, es, hu]) {
- expect(locale.privacyPage.p7).toContain('PostHog');
- expect(locale.privacyPage.p7).toContain('Sentry');
- }
- });
-});
-
-describe('i18n runtime', () => {
- it('defines the complete typed language catalog', () => {
- expect(Object.keys(APP_LANGUAGES)).toEqual(['en', 'es', 'hu']);
- expect(isAppLanguage('hu')).toBe(true);
- expect(isAppLanguage('zz')).toBe(false);
- expect(isAppLanguage(undefined)).toBe(false);
- expect(toIntlLocale('hu')).toBe('hu-HU');
- expect(languageName('hu')).toBe('Hungarian');
- });
-
- it('applies Hungarian to i18n and browser metadata', async () => {
- const languageMeta = document.createElement('meta');
- languageMeta.setAttribute('name', 'language');
- document.head.append(languageMeta);
-
- await applyAppLanguage('hu');
-
- expect(i18n.language).toBe('hu');
- expect(document.documentElement.lang).toBe('hu');
- expect(languageMeta.content).toBe('Hungarian');
- expect(document.title).toBe(hu.home.title);
-
- languageMeta.remove();
- await applyAppLanguage('en');
- });
-
- it('i18n.t resolves English nav values', () => {
- expect(i18n.t('nav.home')).toBe('Home');
- expect(i18n.t('nav.generate')).toBe('Generate');
- expect(i18n.t('nav.settings')).toBe('Settings');
- });
-
- it('i18n.changeLanguage works for es', async () => {
- await i18n.changeLanguage('es');
- expect(i18n.language).toBe('es');
- await i18n.changeLanguage('en');
- });
-
- it('i18n.changeLanguage works for hu', async () => {
- await i18n.changeLanguage('hu');
- expect(i18n.language).toBe('hu');
- await i18n.changeLanguage('en');
- });
-
- it('interpolation works for limitModal.limitReachedDesc', () => {
- const result = i18n.t('limitModal.limitReachedDesc', { current: 5, limit: 10 });
- expect(result).toContain('5');
- expect(result).toContain('10');
- });
-
- it('interpolation works for modelModal.descriptionWithModel', () => {
- const result = i18n.t('modelModal.descriptionWithModel', { model: 'GPT-5' });
- expect(result).toContain('GPT-5');
- });
-
- it('pluralizes the Crossfire character counter in every locale', async () => {
- await i18n.changeLanguage('en');
- expect(i18n.t('crossfire.charactersRemaining', { count: 1 })).toBe('1 character remaining');
- expect(i18n.t('crossfire.charactersRemaining', { count: 2 })).toBe('2 characters remaining');
- await i18n.changeLanguage('es');
- expect(i18n.t('crossfire.charactersRemaining', { count: 1 })).toBe('Queda 1 carácter');
- expect(i18n.t('crossfire.charactersRemaining', { count: 2 })).toBe('Quedan 2 caracteres');
- await i18n.changeLanguage('hu');
- expect(i18n.t('crossfire.charactersRemaining', { count: 1 })).toBe('1 karakter maradt');
- expect(i18n.t('crossfire.charactersRemaining', { count: 2 })).toBe('2 karakter maradt');
- await i18n.changeLanguage('en');
- });
-
- it('fallback language is en', () => {
- expect(i18n.options.fallbackLng).toContain('en');
- });
-});
diff --git a/src/test/jam-loader.test.ts b/src/test/jam-loader.test.ts
deleted file mode 100644
index 79a6fdf02..000000000
--- a/src/test/jam-loader.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { loadJamRecorder } from '@/jam-loader';
-
-function jamScripts() {
- return Array.from(document.head.querySelectorAll('script[data-jam-recorder]'));
-}
-
-describe('Jam recorder loader', () => {
- afterEach(() => {
- vi.useRealTimers();
- });
-
- it('does not load recorder scripts when MediaRecorder is unavailable', async () => {
- jamScripts().forEach((element) => element.remove());
-
- await loadJamRecorder({ documentRef: document, mediaRecorderAvailable: false });
-
- expect(jamScripts()).toHaveLength(0);
- });
-
- it('loads recorder and capture in parallel without parser-blocking scripts', async () => {
- jamScripts().forEach((element) => element.remove());
-
- const loading = loadJamRecorder({ documentRef: document, mediaRecorderAvailable: true });
- expect(jamScripts().map((script) => script.src)).toEqual([
- 'https://js.jam.dev/recorder.js',
- 'https://js.jam.dev/capture.js',
- ]);
- expect(jamScripts().every((script) => script.type === 'module' && script.async)).toBe(true);
-
- jamScripts()[0].dispatchEvent(new Event('load'));
- jamScripts()[1].dispatchEvent(new Event('load'));
- await loading;
- await loadJamRecorder({ documentRef: document, mediaRecorderAvailable: true });
- expect(jamScripts()).toHaveLength(2);
- });
-
- it('rejects a stalled Jam load so application bootstrap can continue', async () => {
- jamScripts().forEach((element) => element.remove());
- vi.useFakeTimers();
-
- const loading = loadJamRecorder({ documentRef: document, mediaRecorderAvailable: true });
- const rejection = expect(loading).rejects.toThrow('Jam module load timed out');
- await vi.advanceTimersByTimeAsync(3_000);
-
- await rejection;
- expect(jamScripts()).toHaveLength(0);
- });
-});
diff --git a/src/test/jam-metadata.test.ts b/src/test/jam-metadata.test.ts
deleted file mode 100644
index 892f314b4..000000000
--- a/src/test/jam-metadata.test.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { setJamUserId } from '@/lib/jam-metadata';
-
-interface JamWindow extends Window {
- __jam__?: { exports?: { metadata?: () => { userId: string | undefined } } };
-}
-
-function readMetadata() {
- return (window as JamWindow).__jam__?.exports?.metadata?.();
-}
-
-describe('jam metadata', () => {
- it('registers a metadata callback on the Jam global', () => {
- expect(typeof (window as JamWindow).__jam__?.exports?.metadata).toBe('function');
- });
-
- it('reports the current user id and clears it on sign-out', () => {
- setJamUserId('user-123');
- expect(readMetadata()).toEqual({ userId: 'user-123' });
-
- setJamUserId(undefined);
- expect(readMetadata()).toEqual({ userId: undefined });
- });
-});
diff --git a/src/test/native-app-url-listener.test.ts b/src/test/native-app-url-listener.test.ts
deleted file mode 100644
index 3a75d2fe2..000000000
--- a/src/test/native-app-url-listener.test.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-const { addListenerMock, getLaunchUrlMock } = vi.hoisted(() => ({
- addListenerMock: vi.fn(),
- getLaunchUrlMock: vi.fn(),
-}));
-
-vi.mock('@capacitor/app', () => ({
- App: {
- addListener: addListenerMock,
- getLaunchUrl: getLaunchUrlMock,
- },
-}));
-
-vi.mock('@/lib/native', () => ({
- NATIVE_URL_SCHEME: 'debatelab',
- closeExternalBrowser: vi.fn(),
- isNativeApp: () => true,
-}));
-
-import { registerNativeAppUrlListener, resolveNativeAppUrl } from '@/native-app-url-listener';
-
-describe('native app URL listener', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- addListenerMock.mockResolvedValue({ remove: vi.fn() });
- getLaunchUrlMock.mockResolvedValue(null);
- });
-
- it('checks the launch URL so terminated-app callbacks are not lost', () => {
- registerNativeAppUrlListener();
-
- expect(getLaunchUrlMock).toHaveBeenCalledTimes(1);
- });
-
- it('rejects legacy fragment-token callbacks', () => {
- expect(resolveNativeAppUrl('debatelab://auth/callback#access_token=a&refresh_token=r')).toBeNull();
- });
-
- it('preserves PKCE codes from the native auth callback', () => {
- expect(resolveNativeAppUrl('debatelab://auth/callback?code=pkce-code')).toEqual({
- route: '/app?code=pkce-code',
- });
- });
-
- it('restores nested app routes and ignores unrelated schemes', () => {
- expect(resolveNativeAppUrl('debatelab://app/settings?checkout=success')).toEqual({
- route: '/app/settings?checkout=success',
- });
- expect(resolveNativeAppUrl('debatelab://auth/not-the-callback?code=pkce-code')).toBeNull();
- expect(resolveNativeAppUrl('https://example.com/app')).toBeNull();
- });
-});
diff --git a/src/test/native-runtime.test.ts b/src/test/native-runtime.test.ts
deleted file mode 100644
index 02b6f9e7b..000000000
--- a/src/test/native-runtime.test.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { shouldOpenExternalUrl } from '@/native-runtime';
-
-describe('native runtime links', () => {
- it('opens same-tab web links outside the native WebView', () => {
- expect(shouldOpenExternalUrl(new URL('https://example.com/docs'))).toBe(true);
- });
-
- it('keeps custom app links inside the native app', () => {
- expect(shouldOpenExternalUrl(new URL('debatelab://app/settings'))).toBe(false);
- });
-});
diff --git a/src/test/native.test.ts b/src/test/native.test.ts
deleted file mode 100644
index b79c4c033..000000000
--- a/src/test/native.test.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-const { isNativePlatformMock } = vi.hoisted(() => ({
- isNativePlatformMock: vi.fn(),
-}));
-
-vi.mock('@capacitor/core', () => ({
- Capacitor: {
- isNativePlatform: isNativePlatformMock,
- },
-}));
-
-vi.mock('@capacitor/browser', () => ({
- Browser: {
- open: vi.fn(),
- close: vi.fn(),
- },
-}));
-
-import { Browser } from '@capacitor/browser';
-import { getWebAppRedirectUrl, openExternalUrl } from '@/lib/native';
-
-describe('native URL helpers', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- isNativePlatformMock.mockReturnValue(false);
- });
-
- it('uses the browser origin for web Stripe return URLs', () => {
- expect(getWebAppRedirectUrl('/app/settings')).toBe(`${window.location.origin}/app/settings`);
- });
-
- it('uses the public HTTPS origin for native Stripe return URLs', () => {
- isNativePlatformMock.mockReturnValue(true);
-
- expect(getWebAppRedirectUrl('/app/settings')).toBe('https://debate.pi-stack.org/app/settings');
- });
-
- it('rejects unsafe schemes before opening a native browser', async () => {
- isNativePlatformMock.mockReturnValue(true);
-
- await expect(openExternalUrl('javascript:alert(1)')).rejects.toThrow('unsupported URL scheme');
- expect(Browser.open).not.toHaveBeenCalled();
- });
-
- it('rejects unsafe schemes before opening a browser window', async () => {
- const openSpy = vi.spyOn(window, 'open');
-
- await expect(openExternalUrl('data:text/html,unsafe', { newWindow: true })).rejects.toThrow('unsupported URL scheme');
- expect(openSpy).not.toHaveBeenCalled();
- });
-});
diff --git a/src/test/observability.test.ts b/src/test/observability.test.ts
deleted file mode 100644
index 8a84b1409..000000000
--- a/src/test/observability.test.ts
+++ /dev/null
@@ -1,361 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-const {
- browserTracingIntegrationMock,
- captureMock,
- posthogInitMock,
- replayIntegrationMock,
- sentryCaptureMock,
- sentryInitMock,
- sentryLoggerInfoMock,
- sentryMetricsCountMock,
- sentryMetricsDistributionMock,
-} = vi.hoisted(() => ({
- browserTracingIntegrationMock: vi.fn(() => ({ name: "BrowserTracing" })),
- captureMock: vi.fn(),
- posthogInitMock: vi.fn(),
- replayIntegrationMock: vi.fn(() => ({ name: "Replay" })),
- sentryCaptureMock: vi.fn(),
- sentryInitMock: vi.fn(),
- sentryLoggerInfoMock: vi.fn(),
- sentryMetricsCountMock: vi.fn(),
- sentryMetricsDistributionMock: vi.fn(),
-}));
-
-vi.mock("@sentry/react", () => ({
- browserTracingIntegration: browserTracingIntegrationMock,
- captureException: sentryCaptureMock,
- init: sentryInitMock,
- logger: { info: sentryLoggerInfoMock },
- metrics: {
- count: sentryMetricsCountMock,
- distribution: sentryMetricsDistributionMock,
- },
- replayIntegration: replayIntegrationMock,
-}));
-
-vi.mock("posthog-js", () => ({
- default: {
- capture: captureMock,
- init: posthogInitMock,
- },
-}));
-
-import {
- captureException,
- captureProductEvent,
- filterObservabilityBreadcrumb,
- initializeObservability,
- resetObservabilityForTests,
- toTelemetrySafeError,
-} from "@/lib/observability";
-
-describe("observability primitives", () => {
- beforeEach(() => {
- resetObservabilityForTests();
- vi.clearAllMocks();
- vi.stubEnv("VITE_POSTHOG_KEY", undefined);
- vi.stubEnv("VITE_POSTHOG_HOST", undefined);
- vi.stubEnv("VITE_SENTRY_DSN", undefined);
- vi.stubEnv("VITE_SENTRY_RELEASE", undefined);
- vi.stubEnv("VITE_SENTRY_ENVIRONMENT", undefined);
- });
-
- afterEach(() => {
- vi.unstubAllEnvs();
- });
-
- it("does not capture product events when neither service is configured", () => {
- captureProductEvent("tool_opened", { tool: "generate" });
-
- expect(captureMock).not.toHaveBeenCalled();
- expect(sentryLoggerInfoMock).not.toHaveBeenCalled();
- expect(sentryMetricsCountMock).not.toHaveBeenCalled();
- });
-
- it("passes structured product events through when PostHog is configured", async () => {
- vi.stubEnv("VITE_POSTHOG_KEY", "posthog-test-key");
- const properties = {
- tool: "generate",
- format: "pdf",
- durationMs: 123,
- streaming: false,
- };
-
- captureProductEvent("tool_opened", properties);
-
- await vi.waitFor(() => expect(captureMock).toHaveBeenCalledOnce());
- expect(captureMock).toHaveBeenCalledOnce();
- expect(captureMock).toHaveBeenCalledWith("tool_opened", properties);
- expect(properties).not.toHaveProperty("motion");
- expect(properties).not.toHaveProperty("content");
- expect(properties).not.toHaveProperty("message");
- });
-
- it("does not let PostHog failures interrupt the product", async () => {
- vi.stubEnv("VITE_POSTHOG_KEY", "posthog-test-key");
- captureMock.mockImplementationOnce(() => {
- throw new Error("synthetic PostHog failure");
- });
-
- expect(() => captureProductEvent("tool_opened", { tool: "generate" })).not.toThrow();
- await vi.waitFor(() => expect(captureMock).toHaveBeenCalledOnce());
- });
-
- it("sends safe product logs and metrics to Sentry while dropping unexpected properties", async () => {
- vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1");
-
- captureProductEvent("ai_request_succeeded", {
- durationMs: 234,
- feature: "ask",
- message: "private debate content",
- model: "openai/gpt-5.6-luna",
- });
-
- const attributes = {
- event_name: "ai_request_succeeded",
- durationMs: 234,
- feature: "ask",
- model: "openai/gpt-5.6-luna",
- };
- await vi.waitFor(() => expect(sentryLoggerInfoMock).toHaveBeenCalledOnce());
- expect(sentryLoggerInfoMock).toHaveBeenCalledWith("Product event", attributes);
- expect(sentryMetricsCountMock).toHaveBeenCalledWith("debatelab.product_event", 1, { attributes });
- expect(sentryMetricsDistributionMock).toHaveBeenCalledWith(
- "debatelab.product_event.duration",
- 234,
- { unit: "millisecond", attributes },
- );
- expect(JSON.stringify([
- ...sentryLoggerInfoMock.mock.calls,
- ...sentryMetricsCountMock.mock.calls,
- ...sentryMetricsDistributionMock.mock.calls,
- ])).not.toContain("private debate content");
- });
-
- it("rejects private strings in allowlisted fields and rejects unknown event names", async () => {
- vi.stubEnv("VITE_POSTHOG_KEY", "posthog-test-key");
- vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1");
-
- captureProductEvent("ai_request_succeeded", {
- feature: "private debate content",
- model: "student@example.com",
- provider: "openrouter",
- });
- captureProductEvent("student@example.com", { feature: "ask" });
-
- await vi.waitFor(() => expect(captureMock).toHaveBeenCalledOnce());
- expect(captureMock).toHaveBeenCalledOnce();
- expect(captureMock).toHaveBeenCalledWith("ai_request_succeeded", { provider: "openrouter" });
- expect(sentryLoggerInfoMock).toHaveBeenCalledOnce();
- expect(sentryLoggerInfoMock).toHaveBeenCalledWith("Product event", {
- event_name: "ai_request_succeeded",
- provider: "openrouter",
- });
- expect(JSON.stringify([
- ...captureMock.mock.calls,
- ...sentryLoggerInfoMock.mock.calls,
- ...sentryMetricsCountMock.mock.calls,
- ])).not.toMatch(/private debate content|student@example\.com/);
- });
-
- it("does not send exceptions when Sentry is not configured", () => {
- captureException(new Error("synthetic test error"), { category: "test" });
-
- expect(sentryCaptureMock).not.toHaveBeenCalled();
- });
-
- it("passes exceptions and structured context through when Sentry is configured", async () => {
- vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1");
- const error = new Error("synthetic test error");
- const context = { provider: "openrouter", category: "provider_error" };
-
- captureException(error, context);
-
- await vi.waitFor(() => expect(sentryCaptureMock).toHaveBeenCalledOnce());
- expect(sentryCaptureMock).toHaveBeenCalledOnce();
- expect(sentryCaptureMock).toHaveBeenCalledWith(error, { extra: context });
- });
-
- it("does not let Sentry failures interrupt the product", async () => {
- vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1");
- sentryCaptureMock.mockImplementationOnce(() => {
- throw new Error("synthetic Sentry failure");
- });
-
- expect(() => captureException(new Error("synthetic test error"))).not.toThrow();
- await vi.waitFor(() => expect(sentryCaptureMock).toHaveBeenCalledOnce());
- });
-
- it("drops React error-boundary console breadcrumbs but preserves other console errors", () => {
- const reactBreadcrumb = {
- category: "console",
- level: "error",
- data: {
- arguments: [
- new Error("private descendant message"),
- "The above error occurred in the component:\n at PrivateRoute",
- "React will try to recreate this component tree from scratch using the error boundary you provided.",
- ],
- },
- };
- const ordinaryBreadcrumb = {
- category: "console",
- level: "error",
- data: { arguments: ["ordinary application error"] },
- };
-
- expect(filterObservabilityBreadcrumb(reactBreadcrumb)).toBeNull();
- expect(filterObservabilityBreadcrumb(ordinaryBreadcrumb)).toBe(ordinaryBreadcrumb);
- });
-
- it("initializes selected observability capabilities and privacy controls once", async () => {
- vi.stubEnv("VITE_POSTHOG_KEY", "posthog-test-key");
- vi.stubEnv("VITE_POSTHOG_HOST", "https://posthog.example");
- vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1");
-
- await initializeObservability();
- await initializeObservability();
-
- expect(sentryInitMock).toHaveBeenCalledOnce();
- expect(sentryInitMock).toHaveBeenCalledWith(expect.objectContaining({
- dsn: "https://public-test@sentry.example/1",
- ignoreErrors: [/^Synthetic observability verification$/],
- beforeBreadcrumb: filterObservabilityBreadcrumb,
- dataCollection: {
- userInfo: false,
- cookies: false,
- httpHeaders: { request: false, response: false },
- httpBodies: [],
- queryParams: false,
- genAI: { inputs: false, outputs: false },
- stackFrameVariables: false,
- frameContextLines: 5,
- },
- enableLogs: true,
- enableMetrics: true,
- tracesSampleRate: 0.1,
- replaysSessionSampleRate: 0,
- replaysOnErrorSampleRate: 1,
- }));
- // Deprecated and ignored whenever dataCollection is set: passing it would
- // read as a PII control that does nothing.
- expect(sentryInitMock.mock.calls[0][0]).not.toHaveProperty("sendDefaultPii");
- expect(browserTracingIntegrationMock).toHaveBeenCalledOnce();
- expect(replayIntegrationMock).toHaveBeenCalledWith({
- maskAllText: true,
- maskAllInputs: true,
- blockAllMedia: true,
- });
- expect(posthogInitMock).toHaveBeenCalledOnce();
- expect(posthogInitMock).toHaveBeenCalledWith("posthog-test-key", expect.objectContaining({
- api_host: "https://posthog.example",
- autocapture: false,
- capture_pageview: false,
- disable_session_recording: true,
- disable_surveys: true,
- disable_external_dependency_loading: true,
- advanced_disable_flags: true,
- persistence: "memory",
- }));
- });
-
- it("tags Sentry events with the build release and deployment environment", async () => {
- vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1");
- vi.stubEnv("VITE_SENTRY_RELEASE", "abc123");
- vi.stubEnv("VITE_SENTRY_ENVIRONMENT", "preview");
-
- await initializeObservability();
-
- expect(sentryInitMock).toHaveBeenCalledWith(expect.objectContaining({
- release: "abc123",
- environment: "preview",
- }));
- });
-
- it("falls back to the build mode when no deployment environment is known", async () => {
- vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1");
-
- await initializeObservability();
-
- expect(sentryInitMock.mock.calls[0][0].release).toBeUndefined();
- expect(sentryInitMock.mock.calls[0][0].environment).toBe(import.meta.env.MODE);
- });
-
- it("keeps V8 stack frames but drops a multi-line private message", () => {
- const original = new TypeError("secret debate text\n still secret");
- original.stack = [
- "TypeError: secret debate text",
- " still secret",
- " at ExplodingRoute (https://debate.example/assets/index.js:10:5)",
- " at renderWithHooks (https://debate.example/assets/vendor.js:20:7)",
- ].join("\n");
-
- const safe = toTelemetrySafeError(original, "Route rendering failed");
-
- expect(safe.name).toBe("TypeError");
- expect(safe.message).toBe("Route rendering failed");
- expect(safe.stack).toBe([
- "TypeError: Route rendering failed",
- " at ExplodingRoute (https://debate.example/assets/index.js:10:5)",
- " at renderWithHooks (https://debate.example/assets/vendor.js:20:7)",
- ].join("\n"));
- expect(safe).not.toHaveProperty("cause");
- });
-
- it("drops message lines shaped like stack frames", () => {
- const original = new TypeError("my speech\n at the rally 10:30:45\n at secret (native)\nme@home.org:12:30\n at leak (https://evil.example/a.js:1:2)");
- original.stack = [
- "TypeError: my speech",
- " at the rally 10:30:45",
- " at secret (native)",
- "me@home.org:12:30",
- " at leak (https://evil.example/a.js:1:2)",
- " at ExplodingRoute (https://debate.example/assets/index.js:10:5)",
- ].join("\n");
-
- expect(toTelemetrySafeError(original, "Route rendering failed").stack).toBe([
- "TypeError: Route rendering failed",
- " at ExplodingRoute (https://debate.example/assets/index.js:10:5)",
- ].join("\n"));
- });
-
- it("drops frame-shaped text that is not a code location when the header is unknown", () => {
- const original = new Error("changed later");
- original.stack = [
- " at the rally 10:30:45",
- " at secret (native)",
- "me@home.org:12:30",
- " at ExplodingRoute (https://debate.example/assets/index.js:10:5)",
- ].join("\n");
-
- expect(toTelemetrySafeError(original, "Route rendering failed").stack).toBe([
- "Error: Route rendering failed",
- " at ExplodingRoute (https://debate.example/assets/index.js:10:5)",
- ].join("\n"));
- });
-
- it("keeps Firefox and Safari stack frames", () => {
- const original = new Error("secret");
- original.stack = [
- "ExplodingRoute@https://debate.example/assets/index.js:10:5",
- "@https://debate.example/assets/vendor.js:20:7",
- ].join("\n");
-
- expect(toTelemetrySafeError(original, "Route rendering failed").stack).toBe([
- "Error: Route rendering failed",
- "ExplodingRoute@https://debate.example/assets/index.js:10:5",
- "@https://debate.example/assets/vendor.js:20:7",
- ].join("\n"));
- });
-
- it("replaces custom or non-error names and ignores non-error values", () => {
- const named = new Error("secret");
- named.name = "secret user text";
- expect(toTelemetrySafeError(named, "Route rendering failed").name).toBe("Error");
-
- const safe = toTelemetrySafeError("secret thrown string", "Route rendering failed");
- expect(safe.name).toBe("Error");
- expect(safe.stack).toBe("Error: Route rendering failed");
- });
-});
diff --git a/src/test/pages/AdminPage.test.tsx b/src/test/pages/AdminPage.test.tsx
deleted file mode 100644
index 8d63379f2..000000000
--- a/src/test/pages/AdminPage.test.tsx
+++ /dev/null
@@ -1,691 +0,0 @@
-
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen, waitFor, fireEvent } from '@testing-library/react';
-import AdminPage from '@/pages/AdminPage';
-import { BrowserRouter } from 'react-router-dom';
-import type { ReactElement, ReactNode } from 'react';
-import i18n from '@/i18n';
-
-// Hoist mocks
-const { mockSupabase, mockToast, captureProductEventMock } = vi.hoisted(() => ({
- mockSupabase: {
- auth: {
- getSession: vi.fn(),
- },
- },
- mockToast: vi.fn(),
- captureProductEventMock: vi.fn(),
-}));
-
-vi.mock('@/lib/supabase/client', () => ({
- supabase: mockSupabase,
- SUPABASE_URL: 'http://localhost:54321',
-}));
-
-// Mock Recharts
-vi.mock('recharts', () => {
- const ResponsiveContainer = ({ children }: { children?: ReactNode }): ReactElement => {children}
;
- return {
- ResponsiveContainer,
- BarChart: () => Bar Chart
,
- Bar: (): null => null,
- XAxis: (): null => null,
- YAxis: (): null => null,
- CartesianGrid: (): null => null,
- Tooltip: (): null => null,
- PieChart: () => Pie Chart
,
- Pie: (): null => null,
- Cell: (): null => null,
- };
-});
-
-vi.mock('@/hooks/use-toast', () => ({
- useToast: (): { toast: typeof mockToast } => ({ toast: mockToast }),
-}));
-
-vi.mock('@/lib/observability', () => ({
- captureProductEvent: captureProductEventMock,
-}));
-
-// Mock global fetch
-const globalFetch = vi.fn();
-global.fetch = globalFetch;
-window.fetch = globalFetch;
-
-describe('AdminPage Integration', () => {
- beforeEach(async () => {
- vi.clearAllMocks();
- await i18n.changeLanguage('en');
- // Default authenticated session
- mockSupabase.auth.getSession.mockResolvedValue({
- data: { session: { access_token: 'fake-token', user: { id: 'admin-id' } } },
- error: null,
- });
- });
-
- afterEach(() => {
- vi.resetAllMocks();
- });
-
- it('renders loading state initially', async () => {
- globalFetch.mockImplementation(() => new Promise(() => { }));
- render(
-
-
-
- );
- expect(screen.queryByText('Admin Dashboard')).not.toBeInTheDocument();
- });
-
- it('redirects/shows error if not admin', async () => {
- globalFetch.mockResolvedValue({
- ok: false,
- status: 403,
- text: async () => JSON.stringify({ error: 'Admin access required' }),
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('Access Denied')).toBeInTheDocument();
- });
- expect(captureProductEventMock).not.toHaveBeenCalled();
- });
-
- it('loads and displays analytics when admin', async () => {
- const mockStats: Record = {
- totals: { requests: 1234, cost: 5.67, successRate: 98.5 },
- daily: [{ date: 'Feb 01', requests: 100, cost: 0.5, errors: 2 }],
- byFeature: { generate: 50, ask: 50 },
- byUser: [
- { user_id: 'user-1', email: 'top@example.com', requests: 10, cost: 0.1 },
- { user_id: '6c355d47-1111-4111-8111-111111111111', email: null, requests: 8, cost: 0.05 },
- ],
- };
-
- const mockUsers = {
- users: [{
- id: '6c355d47-1111-4111-8111-111111111111',
- email: 'joined@example.com',
- createdAt: '2026-01-01T00:00:00.000Z',
- lastSignIn: '2026-01-02T00:00:00.000Z',
- requestCount: 8,
- role: 'user',
- }],
- };
-
- globalFetch.mockImplementation(async (url: string) => {
- console.log('FETCH URL:', url); // Debug log
- if (url.includes('action=stats')) {
- return { ok: true, status: 200, text: async () => JSON.stringify(mockStats) };
- }
- if (url.includes('action=users')) {
- return { ok: true, status: 200, text: async () => JSON.stringify(mockUsers) };
- }
- return { ok: false, status: 404, text: async () => '{}' };
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
- });
- expect(captureProductEventMock).toHaveBeenCalledWith('tool_opened', { tool: 'admin' });
-
- // Debug output if fails
- // screen.debug();
-
- expect(screen.getByText('1,234')).toBeInTheDocument();
- expect(screen.getByText('$5.6700')).toBeInTheDocument();
- expect(screen.getByRole('tab', { name: 'Usage Analytics' })).toBeInTheDocument();
- expect(screen.queryByRole('tab', { name: 'Rate Limits' })).not.toBeInTheDocument();
- expect(screen.getByText('top@example.com')).toBeInTheDocument();
- expect(screen.getByText('joined@example.com')).toBeInTheDocument();
- expect(screen.queryByText('Unknown user')).not.toBeInTheDocument();
- expect(screen.queryByText('6c355d47…')).not.toBeInTheDocument();
- expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
- expect(globalFetch.mock.calls.some(([url]) => String(url).includes('action=limits'))).toBe(false);
- });
-
- it('falls back to shortened user ID when no email is available', async () => {
- const unknownUserId = 'abcd1234-5678-4abc-9def-000000000000';
- const mockStats: Record = {
- totals: { requests: 5, cost: 0.01, successRate: 100 },
- daily: [] as unknown[],
- byFeature: { generate: 5 },
- byUser: [
- { user_id: unknownUserId, email: null, requests: 5, cost: 0.01 },
- ],
- };
-
- globalFetch.mockImplementation(async (url: string) => {
- if (url.includes('action=stats')) {
- return { ok: true, status: 200, text: async () => JSON.stringify(mockStats) };
- }
- if (url.includes('action=users')) {
- return { ok: true, status: 200, text: async () => JSON.stringify({ users: [] }) };
- }
- return { ok: false, status: 404, text: async () => '{}' };
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
- });
-
- const fallback = await screen.findByText('abcd1234…');
- expect(fallback).toHaveAttribute('title', unknownUserId);
- expect(screen.queryByText('Unknown user')).not.toBeInTheDocument();
- });
-
- it('gifts trial access through the admin grant-trial action', async () => {
- const targetUserId = '11111111-2222-4333-8444-555555555555';
- const mockStats = {
- totals: { requests: 0, cost: 0, successRate: 100 },
- daily: [] as unknown[],
- byFeature: {},
- byUser: [] as unknown[],
- };
- const mockUsers: Record = {
- users: [{
- id: targetUserId,
- email: 'gift-me@example.com',
- createdAt: '2026-01-01T00:00:00.000Z',
- lastSignIn: '2026-01-02T00:00:00.000Z',
- requestCount: 3,
- role: 'user',
- subscriptionTier: 'expired',
- trialEndsAt: '2026-03-01T00:00:00.000Z',
- gracePeriodEndsAt: null,
- dailyLimitOverride: null,
- }],
- };
-
- const grantBody: Record = {};
- globalFetch.mockImplementation(async (url: string, init?: RequestInit) => {
- if (url.includes('action=stats')) {
- return { ok: true, status: 200, text: async () => JSON.stringify(mockStats) };
- }
- if (url.includes('action=users')) {
- return { ok: true, status: 200, text: async () => JSON.stringify(mockUsers) };
- }
- if (url.includes('action=grant-trial')) {
- Object.assign(grantBody, JSON.parse(String(init?.body || '{}')));
- return {
- ok: true,
- status: 200,
- text: async () => JSON.stringify({
- success: true,
- user_id: targetUserId,
- subscription_tier: 'trial',
- trial_ends_at: '2026-06-18T00:00:00.000Z',
- daily_limit_override: null,
- }),
- };
- }
- return { ok: false, status: 404, text: async () => '{}' };
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => expect(screen.getByText('Admin Dashboard')).toBeInTheDocument());
-
- // Radix Tabs activates via mouseDown, not click.
- const usersTab = await screen.findByRole('tab', { name: 'User Management' });
- fireEvent.mouseDown(usersTab, { button: 0 });
-
- const giftButton = await screen.findByRole('button', { name: /gift trial/i });
- fireEvent.click(giftButton);
-
- const dialogConfirm = await screen.findByRole('button', { name: /gift 30 days/i });
- fireEvent.click(dialogConfirm);
-
- await waitFor(() => {
- expect(globalFetch.mock.calls.some(([url]) => String(url).includes('action=grant-trial'))).toBe(true);
- });
- expect(grantBody.userId).toBe(targetUserId);
- expect(grantBody.days).toBe(30);
- });
-
- it('renders cost by model with unknown-cost rows never shown as $0.0000', async () => {
- const mockStats: Record = {
- totals: { requests: 12, cost: 0.0123, successRate: 100, unknownCostRequests: 3 },
- daily: [] as unknown[],
- byFeature: { ask: 12 },
- byUser: [
- { user_id: 'user-1', email: 'top@example.com', requests: 12, cost: 0.0123, unknownCostRequests: 3 },
- ],
- byModel: [
- { provider: 'openai', model: 'gpt-5.4-mini', requests: 9, cost: 0.0123, unknownCostRequests: 1 },
- { provider: 'google', model: 'gemini-3.1-flash-lite-preview', requests: 3, cost: 0, unknownCostRequests: 2 },
- ],
- };
-
- globalFetch.mockImplementation(async (url: string) => {
- if (url.includes('action=stats')) {
- return { ok: true, status: 200, text: async () => JSON.stringify(mockStats) };
- }
- if (url.includes('action=users')) {
- return { ok: true, status: 200, text: async () => JSON.stringify({ users: [] }) };
- }
- return { ok: false, status: 404, text: async () => '{}' };
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
- });
-
- expect(screen.getByText('gpt-5.4-mini')).toBeInTheDocument();
- expect(screen.getByText('$0.0123 + 1 unknown')).toBeInTheDocument();
- expect(screen.getByText('gemini-3.1-flash-lite-preview')).toBeInTheDocument();
- // Unknown-priced model row must not render a fake zero.
- expect(screen.queryByText('$0.0000')).not.toBeInTheDocument();
- });
-
- it('renders known, mixed, and unknown cost by feature rows honestly', async () => {
- const mockStats: Record = {
- totals: { requests: 6, cost: 0.0073, successRate: 100, unknownCostRequests: 5 },
- daily: [] as unknown[],
- byFeature: { ask: 1, generate: 2, attack: 3 },
- byFeatureCost: [
- { feature: 'ask', requests: 1, cost: 0.0042, unknownCostRequests: 0 },
- { feature: 'generate', requests: 2, cost: 0.0031, unknownCostRequests: 1 },
- { feature: 'attack', requests: 3, cost: 0, unknownCostRequests: 3 },
- ],
- byUser: [],
- };
-
- globalFetch.mockImplementation(async (url: string) => {
- if (url.includes('action=stats')) {
- return { ok: true, status: 200, text: async () => JSON.stringify(mockStats) };
- }
- if (url.includes('action=users')) {
- return { ok: true, status: 200, text: async () => JSON.stringify({ users: [] }) };
- }
- return { ok: false, status: 404, text: async () => '{}' };
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
- });
-
- expect(screen.getByText('Cost by Feature')).toBeInTheDocument();
- expect(screen.getByText('$0.0042')).toBeInTheDocument();
- expect(screen.getByText('$0.0031 + 1 unknown')).toBeInTheDocument();
- expect(screen.getByText('Unknown')).toBeInTheDocument();
- expect(screen.queryByText('$0.0000')).not.toBeInTheDocument();
- });
-
- it('labels totals as unknown when cost is unknown instead of rendering $0.0000', async () => {
- const mockStats: Record = {
- totals: { requests: 7, cost: 0, successRate: 100, unknownCostRequests: 7 },
- daily: [] as unknown[],
- byFeature: { ask: 7 },
- byUser: [
- { user_id: 'user-1', email: 'a@example.com', requests: 7, cost: 0, unknownCostRequests: 7 },
- ],
- };
-
- globalFetch.mockImplementation(async (url: string) => {
- if (url.includes('action=stats')) {
- return { ok: true, status: 200, text: async () => JSON.stringify(mockStats) };
- }
- if (url.includes('action=users')) {
- return { ok: true, status: 200, text: async () => JSON.stringify({ users: [] }) };
- }
- return { ok: false, status: 404, text: async () => '{}' };
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
- });
-
- expect(screen.getAllByText('Unknown').length).toBeGreaterThan(0);
- expect(screen.queryByText('$0.0000')).not.toBeInTheDocument();
- expect(screen.getByText('7 requests with unknown cost')).toBeInTheDocument();
- });
-
- it('creates a demo code and copies the raw code once', async () => {
- const createdBody: Record = {};
- const copyMock = vi.fn().mockResolvedValue(undefined);
- Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: copyMock } });
- const emptyStats = {
- totals: { requests: 0, cost: 0, successRate: 100 },
- daily: [] as unknown[],
- byFeature: {},
- byUser: [] as unknown[],
- };
- const createdRow = {
- id: 'demo-1',
- label: 'September school demo',
- email: 'demo-1@example.com',
- role: 'user',
- expires_at: '2026-09-30T12:00:00.000Z',
- max_uses: 4,
- use_count: 0,
- last_used_at: null as string | null,
- disabled_at: null as string | null,
- created_at: '2026-09-02T12:00:00.000Z',
- };
-
- globalFetch.mockImplementation(async (url: string, init?: RequestInit) => {
- if (url.includes('action=stats')) return { ok: true, status: 200, text: async () => JSON.stringify(emptyStats) };
- if (url.includes('action=users')) return { ok: true, status: 200, text: async () => JSON.stringify({ users: [] }) };
- if (url.includes('action=promo-codes')) return { ok: true, status: 200, text: async () => JSON.stringify({ codes: [] }) };
- if (url.includes('action=subscribers')) return { ok: true, status: 200, text: async () => JSON.stringify({ subscribers: [] }) };
- if (url.includes('action=demo-codes')) return { ok: true, status: 200, text: async () => JSON.stringify({ codes: [] }) };
- if (url.includes('action=create-demo-code')) {
- Object.assign(createdBody, JSON.parse(String(init?.body || '{}')));
- return { ok: true, status: 201, text: async () => JSON.stringify({ code: 'DEMO-7KQ4-MV9P', demoCode: createdRow }) };
- }
- return { ok: false, status: 404, text: async () => '{}' };
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => expect(screen.getByText('Admin Dashboard')).toBeInTheDocument());
- fireEvent.mouseDown(screen.getByRole('tab', { name: 'Demo access' }), { button: 0 });
- fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'September school demo' } });
- fireEvent.change(screen.getByLabelText('Assigned email (optional)'), { target: { value: 'school@example.com' } });
- fireEvent.change(screen.getByLabelText('Expiry (optional)'), { target: { value: '2026-09-30T12:00' } });
- fireEvent.change(screen.getByLabelText('Max uses (optional)'), { target: { value: '4' } });
- fireEvent.click(screen.getByRole('button', { name: 'Create demo code' }));
-
- await waitFor(() => expect(createdBody.label).toBe('September school demo'));
- expect(createdBody.email).toBe('school@example.com');
- expect(createdBody.expiresAt).toBe('2026-09-30T12:00:00.000Z');
- expect(createdBody.maxUses).toBe(4);
- expect(createdBody.role).toBeUndefined();
- expect(screen.getByText('DEMO-7KQ4-MV9P')).toBeInTheDocument();
-
- fireEvent.click(screen.getByRole('button', { name: 'Copy code' }));
- await waitFor(() => expect(copyMock).toHaveBeenCalledWith('DEMO-7KQ4-MV9P'));
- });
-
- it('confirms disabling and resetting a demo code', async () => {
- const demoId = 'demo-2';
- const disableBody: Record = {};
- const resetBody: Record = {};
- const emptyStats = {
- totals: { requests: 0, cost: 0, successRate: 100 },
- daily: [] as unknown[],
- byFeature: {},
- byUser: [] as unknown[],
- };
- globalFetch.mockImplementation(async (url: string, init?: RequestInit) => {
- if (url.includes('action=stats')) return { ok: true, status: 200, text: async () => JSON.stringify(emptyStats) };
- if (url.includes('action=users')) return { ok: true, status: 200, text: async () => JSON.stringify({ users: [] }) };
- if (url.includes('action=promo-codes')) return { ok: true, status: 200, text: async () => JSON.stringify({ codes: [] }) };
- if (url.includes('action=subscribers')) return { ok: true, status: 200, text: async () => JSON.stringify({ subscribers: [] }) };
- if (url.includes('action=demo-codes')) {
- return {
- ok: true,
- status: 200,
- text: async () => JSON.stringify({ codes: [{
- id: demoId,
- label: 'Tournament room',
- email: 'room@example.com',
- role: 'user',
- expires_at: null,
- max_uses: 2,
- use_count: 1,
- last_used_at: '2026-09-02T12:00:00.000Z',
- disabled_at: null,
- created_at: '2026-09-01T12:00:00.000Z',
- }] }),
- };
- }
- if (url.includes('action=disable-demo-code')) {
- Object.assign(disableBody, JSON.parse(String(init?.body || '{}')));
- return { ok: true, status: 200, text: async () => JSON.stringify({ success: true }) };
- }
- if (url.includes('action=reset-demo-code')) {
- Object.assign(resetBody, JSON.parse(String(init?.body || '{}')));
- return { ok: true, status: 200, text: async () => JSON.stringify({ success: true }) };
- }
- return { ok: false, status: 404, text: async () => '{}' };
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => expect(screen.getByText('Admin Dashboard')).toBeInTheDocument());
- fireEvent.mouseDown(screen.getByRole('tab', { name: 'Demo access' }), { button: 0 });
- expect(screen.getByText('room@example.com')).toBeInTheDocument();
- expect(screen.getByText('1 / 2')).toBeInTheDocument();
- expect(screen.getByText('active')).toBeInTheDocument();
-
- fireEvent.click(screen.getByRole('button', { name: 'Disable demo code Tournament room' }));
- fireEvent.click(await screen.findByRole('button', { name: 'Disable code' }));
- await waitFor(() => expect(disableBody.id).toBe(demoId));
- expect(screen.getByText('disabled')).toBeInTheDocument();
-
- fireEvent.click(screen.getByRole('button', { name: 'Reset use count for Tournament room' }));
- fireEvent.click(await screen.findByRole('button', { name: 'Reset use count' }));
- await waitFor(() => expect(resetBody.id).toBe(demoId));
- expect(screen.getByText('0 / 2')).toBeInTheDocument();
- });
-
- it('creates an announcement from the admin tab and adds it to history', async () => {
- const createdBody: Record = {};
- const emptyStats = {
- totals: { requests: 0, cost: 0, successRate: 100 },
- daily: [] as unknown[],
- byFeature: {},
- byUser: [] as unknown[],
- };
-
- globalFetch.mockImplementation(async (url: string, init?: RequestInit) => {
- if (url.includes('action=stats')) {
- return { ok: true, status: 200, text: async () => JSON.stringify(emptyStats) };
- }
- if (url.includes('action=users')) {
- return { ok: true, status: 200, text: async () => JSON.stringify({ users: [] }) };
- }
- if (url.includes('action=promo-codes')) {
- return { ok: true, status: 200, text: async () => JSON.stringify({ codes: [] }) };
- }
- if (url.includes('action=subscribers')) {
- return { ok: true, status: 200, text: async () => JSON.stringify({ subscribers: [] }) };
- }
- if (url.includes('action=announcements')) {
- return { ok: true, status: 200, text: async () => JSON.stringify({ announcements: [] }) };
- }
- if (url.includes('action=create-announcement')) {
- Object.assign(createdBody, JSON.parse(String(init?.body ?? '{}')));
- return {
- ok: true,
- status: 201,
- text: async () => JSON.stringify({
- announcement: {
- id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
- title: createdBody.title,
- body: createdBody.body,
- target_type: createdBody.targetType,
- target_user_ids: null,
- starts_at: '2026-08-31T12:00:00+00:00',
- expires_at: null,
- deactivated_at: null,
- created_by: '99999999-9999-4999-8999-999999999999',
- created_at: '2026-08-31T12:00:00+00:00',
- },
- }),
- };
- }
- return { ok: false, status: 404, text: async () => '{}' };
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => expect(screen.getByText('Admin Dashboard')).toBeInTheDocument());
- fireEvent.mouseDown(screen.getByRole('tab', { name: 'Announcements' }), { button: 0 });
-
- await screen.findByText('No announcements yet');
- fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Service update' } });
- fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'DebateLab is back online.' } });
- fireEvent.click(screen.getByRole('button', { name: 'Create announcement' }));
-
- await waitFor(() => expect(createdBody.title).toBe('Service update'));
- expect(createdBody.body).toBe('DebateLab is back online.');
- expect(createdBody.targetType).toBe('all');
- expect(await screen.findByText('Service update')).toBeInTheDocument();
- expect(screen.getByText('DebateLab is back online.')).toBeInTheDocument();
- });
-
- it('lists and deletes an existing announcement after confirmation', async () => {
- const announcementId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
- const emptyStats = {
- totals: { requests: 0, cost: 0, successRate: 100 },
- daily: [] as unknown[],
- byFeature: {},
- byUser: [] as unknown[],
- };
- const deleteBody: Record = {};
-
- globalFetch.mockImplementation(async (url: string, init?: RequestInit) => {
- if (url.includes('action=stats')) {
- return { ok: true, status: 200, text: async () => JSON.stringify(emptyStats) };
- }
- if (url.includes('action=users')) {
- return { ok: true, status: 200, text: async () => JSON.stringify({ users: [] }) };
- }
- if (url.includes('action=promo-codes')) {
- return { ok: true, status: 200, text: async () => JSON.stringify({ codes: [] }) };
- }
- if (url.includes('action=subscribers')) {
- return { ok: true, status: 200, text: async () => JSON.stringify({ subscribers: [] }) };
- }
- if (url.includes('action=announcements')) {
- return {
- ok: true,
- status: 200,
- text: async () => JSON.stringify({
- announcements: [{
- id: announcementId,
- title: 'Remove me',
- body: 'This announcement is no longer needed.',
- target_type: 'all',
- target_user_ids: null,
- starts_at: '2026-08-31T12:00:00+00:00',
- expires_at: null,
- deactivated_at: null,
- created_by: '99999999-9999-4999-8999-999999999999',
- created_at: '2026-08-31T12:00:00+00:00',
- }],
- }),
- };
- }
- if (url.includes('action=delete-announcement')) {
- Object.assign(deleteBody, JSON.parse(String(init?.body ?? '{}')));
- return { ok: true, status: 200, text: async () => JSON.stringify({ success: true }) };
- }
- return { ok: false, status: 404, text: async () => '{}' };
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => expect(screen.getByText('Admin Dashboard')).toBeInTheDocument());
- fireEvent.mouseDown(screen.getByRole('tab', { name: 'Announcements' }), { button: 0 });
- await screen.findByText('Remove me');
- fireEvent.click(screen.getByRole('button', { name: 'Delete announcement: Remove me' }));
- fireEvent.click(await screen.findByRole('button', { name: 'Delete announcement' }));
-
- await waitFor(() => expect(deleteBody.announcementId).toBe(announcementId));
- expect(screen.queryByText('Remove me')).not.toBeInTheDocument();
- });
-
- it('renders every admin tab group in Hungarian', async () => {
- await i18n.changeLanguage('hu');
- const stats = {
- totals: { requests: 2, cost: 0.01, successRate: 100 },
- daily: [{ date: 'Sep 21', requests: 2, cost: 0.01, errors: 0 }],
- byFeature: { generate: 2 },
- byUser: [{ user_id: 'user-1', email: 'user@example.com', requests: 2, cost: 0.01 }],
- };
- const users = { users: [{
- id: 'user-1',
- email: 'user@example.com',
- createdAt: '2026-09-01T00:00:00.000Z',
- lastSignIn: '2026-09-20T00:00:00.000Z',
- requestCount: 2,
- role: 'user',
- }] };
-
- globalFetch.mockImplementation(async (url: string) => {
- if (url.includes('action=stats')) return { ok: true, status: 200, text: async () => JSON.stringify(stats) };
- if (url.includes('action=users')) return { ok: true, status: 200, text: async () => JSON.stringify(users) };
- if (url.includes('action=promo-codes')) return { ok: true, status: 200, text: async () => JSON.stringify({ codes: [] }) };
- if (url.includes('action=subscribers')) return { ok: true, status: 200, text: async () => JSON.stringify({ subscribers: [] }) };
- if (url.includes('action=demo-codes')) return { ok: true, status: 200, text: async () => JSON.stringify({ codes: [] }) };
- if (url.includes('action=announcements')) return { ok: true, status: 200, text: async () => JSON.stringify({ announcements: [] }) };
- return { ok: false, status: 404, text: async () => '{}' };
- });
-
- render();
-
- expect(await screen.findByRole('heading', { name: 'Adminisztrációs vezérlőpult' })).toBeInTheDocument();
- expect(screen.getByRole('tab', { name: 'Használati statisztikák' })).toBeInTheDocument();
- expect(screen.getByRole('tab', { name: 'Felhasználók kezelése' })).toBeInTheDocument();
- expect(screen.getByRole('tab', { name: 'Közlemények' })).toBeInTheDocument();
- expect(screen.getByRole('tab', { name: 'Demóhozzáférés' })).toBeInTheDocument();
- expect(screen.getByRole('tab', { name: 'Promóciós kódok' })).toBeInTheDocument();
- expect(screen.getByRole('tab', { name: 'Előfizetők' })).toBeInTheDocument();
-
- fireEvent.mouseDown(screen.getByRole('tab', { name: 'Felhasználók kezelése' }), { button: 0 });
- expect(await screen.findByRole('button', { name: 'Próbaidőszak ajándékozása' })).toBeInTheDocument();
- fireEvent.mouseDown(screen.getByRole('tab', { name: 'Promóciós kódok' }), { button: 0 });
- expect(await screen.findByRole('heading', { name: 'Promóciós kód létrehozása' })).toBeInTheDocument();
- fireEvent.mouseDown(screen.getByRole('tab', { name: 'Előfizetők' }), { button: 0 });
- expect(await screen.findByRole('heading', { name: 'Előfizetők' })).toBeInTheDocument();
- });
-});
diff --git a/src/test/pages/AttackPage.test.tsx b/src/test/pages/AttackPage.test.tsx
deleted file mode 100644
index af3937bae..000000000
--- a/src/test/pages/AttackPage.test.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
-import AttackPage from '@/pages/AttackPage';
-import { BrowserRouter } from 'react-router-dom';
-import type { AttackOutput } from '@/lib/ai/schemas/attack';
-
-// Mock dependencies
-vi.mock('@/hooks/use-subscription', () => ({
- useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }),
-}));
-vi.mock('@/lib/ai/client', () => ({
- attackCase: vi.fn(),
- askChat: vi.fn(),
-}));
-
-// Mock hooks
-const mockNavigate = vi.fn();
-vi.mock('@/hooks/use-subscription', () => ({
- useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }),
-}));
-vi.mock('react-router-dom', async () => {
- const actual = await vi.importActual('react-router-dom');
- return {
- ...actual,
- useNavigate: () => mockNavigate,
- };
-});
-
-const mockToast = vi.fn();
-vi.mock('@/hooks/use-subscription', () => ({
- useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }),
-}));
-vi.mock('@/hooks/use-toast', () => ({
- useToast: () => ({ toast: mockToast }),
-}));
-
-// Mock stores
-const mockSetAttackLoading = vi.fn();
-const mockSetAttackResult = vi.fn();
-const mockAddAskMessage = vi.fn();
-const mockSetAskLoading = vi.fn();
-let mockAttackResult: any = null;
-
-vi.mock('@/hooks/use-subscription', () => ({
- useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }),
-}));
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: () => ({
- attackResult: mockAttackResult,
- attackLoading: false,
- setAttackResult: mockSetAttackResult,
- setAttackLoading: mockSetAttackLoading,
- addAskMessage: mockAddAskMessage,
- setAskLoading: mockSetAskLoading,
- askMessages: [] as any[],
- attackForm: { caseText: 'Original attacked case' },
- }),
-}));
-
-vi.mock('@/hooks/use-subscription', () => ({
- useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }),
-}));
-vi.mock('@/lib/stores/settings-store', () => ({
- useSettingsStore: () => ({
- getFeatureConfig: vi.fn(() => ({ model: 'google' })),
- }),
-}));
-
-// Mock child components
-vi.mock('@/hooks/use-subscription', () => ({
- useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }),
-}));
-vi.mock('@/components/features/attack/AttackForm', () => ({
- AttackForm: ({ onSubmit, isLoading }: { onSubmit: (data: unknown) => void, isLoading: boolean }) => (
-
-
-
- ),
-}));
-
-vi.mock('@/hooks/use-subscription', () => ({
- useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }),
-}));
-vi.mock('@/components/features/attack/AttackResults', () => ({
- AttackResults: ({ result, onDiscuss, onPracticeCrossfire }: { result: { overallAssessment: { score: number } }, onDiscuss: () => void, onPracticeCrossfire?: () => void }) => (
-
-
Score {result.overallAssessment.score}
-
-
-
- ),
-}));
-
-import { attackCase } from '@/lib/ai/client';
-
-describe('AttackPage Integration', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- mockAttackResult = null;
- });
-
- it('renders the empty state initially', () => {
- render(
-
-
-
- );
-
- expect(screen.getByRole('heading', { name: 'Attack' })).toBeInTheDocument();
- expect(screen.getByTestId('attack-form')).toBeInTheDocument();
- expect(screen.queryByTestId('attack-results')).not.toBeInTheDocument();
- });
-
- it('handles form submission and successful attack', async () => {
- // Setup API mock
- const mockResult = {
- overallAssessment: { score: 85, summary: 'Good case' },
- weaknesses: [] as any[],
- fallacies: [] as any[],
- fallacyScore: 0,
- recommendations: [] as any[]
- };
- (attackCase as Mock).mockResolvedValue({ result: mockResult, discoveredSources: [] });
-
- render(
-
-
-
- );
-
- // Trigger submit
- fireEvent.click(screen.getByTestId('submit-button'));
-
- // Verify loading state calls
- expect(mockSetAttackLoading).toHaveBeenCalledWith(true);
-
- // Wait for API call
- await waitFor(() => {
- expect(attackCase).toHaveBeenCalled();
- });
-
- // Verify result setting
- expect(mockSetAttackResult).toHaveBeenCalledWith(mockResult);
- expect(mockSetAttackLoading).toHaveBeenCalledWith(false);
- });
-
- it('opens Crossfire with the attacked case and cross-ex questions', () => {
- const result: AttackOutput = {
- overallAssessment: { score: 60, summary: 'Mixed', strengthAreas: [] },
- weaknesses: [],
- fallaciesDetected: [],
- crossExQuestions: [{ question: 'What is the mechanism?', purpose: 'Test causation', expectedTrap: 'No warrant' }],
- weighingAnalysis: { currentWeighing: '', missingComparisons: [], improvementSuggestions: [] },
- };
- mockAttackResult = result;
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('open-crossfire'));
- expect(mockNavigate).toHaveBeenCalledWith('/app/crossfire', {
- state: expect.objectContaining({
- source: 'attack',
- argument: 'Original attacked case',
- seedQuestions: ['What is the mechanism?'],
- }),
- });
- });
-});
diff --git a/src/test/pages/ConversationPages.test.tsx b/src/test/pages/ConversationPages.test.tsx
deleted file mode 100644
index 9888769f1..000000000
--- a/src/test/pages/ConversationPages.test.tsx
+++ /dev/null
@@ -1,731 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { BrowserRouter, MemoryRouter } from 'react-router-dom';
-import type { FactoidsOutput } from '@/lib/ai/schemas/factoids';
-import type { WorkspaceToolContext } from '@/lib/workspace/tool-context';
-
-vi.mock('@/hooks/use-subscription', () => ({
- useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }),
-}));
-
-const mockNavigate = vi.fn();
-const mockToast = vi.fn();
-const mockAskChat = vi.fn();
-const mockIsAskStreamingSupported = vi.fn(
- (_config: unknown, _options?: { mode?: string }) => false,
-);
-const mockStreamAskChat = vi.fn();
-const mockBrainstorm = vi.fn();
-const mockFactoids = vi.fn();
-const mockIsTimeoutError = vi.fn(() => false);
-
-const drainAsyncWork = async () => {
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 0));
- });
-};
-
-const sessionState = {
- askMessages: [] as any[],
- askLoading: false,
- askStreamingAssistant: null as any,
- addAskMessage: vi.fn(),
- setAskLoading: vi.fn(),
- setAskStreamingAssistant: vi.fn(),
- appendAskStreamingDelta: vi.fn(),
- clearAskMessages: vi.fn(),
- saveCurrentChat: vi.fn(),
- loadSavedChat: vi.fn(),
- savedChats: [] as any[],
- addSource: vi.fn(),
- brainstormResult: null as any,
- brainstormLoading: false,
- setBrainstormResult: vi.fn(),
- setBrainstormLoading: vi.fn(),
- factoidsResult: null as any,
- factoidsLoading: false,
- setFactoidsResult: vi.fn(),
- setFactoidsLoading: vi.fn(),
-};
-
-const subErrorState = {
- limitModalOpen: false,
- limitModalVariant: 'limit_reached' as const,
- limitModalLimit: undefined as number | undefined,
- limitModalCurrent: undefined as number | undefined,
- modelModalOpen: false,
- pricingModalOpen: false,
- setLimitModalOpen: vi.fn(),
- setModelModalOpen: vi.fn(),
- setPricingModalOpen: vi.fn(),
- handleSubscriptionError: vi.fn(() => false),
-};
-
-vi.mock('react-router-dom', async () => {
- const actual = await vi.importActual('react-router-dom');
- return {
- ...actual,
- useNavigate: () => mockNavigate,
- };
-});
-
-vi.mock('@/hooks/use-toast', () => ({
- useToast: () => ({ toast: mockToast }),
-}));
-
-vi.mock('@/hooks/use-subscription-error', () => ({
- useSubscriptionError: () => subErrorState,
-}));
-
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: () => sessionState,
-}));
-
-vi.mock('@/lib/stores/settings-store', () => ({
- useSettingsStore: () => ({
- getFeatureConfig: vi.fn(() => ({ provider: 'openai', model: 'gpt-4o', temperature: 0.7 })),
- }),
-}));
-
-vi.mock('@/lib/ai/client', () => ({
- askChat: (...args: unknown[]) => (mockAskChat as (...callArgs: unknown[]) => unknown)(...args),
- brainstorm: (...args: unknown[]) => (mockBrainstorm as (...callArgs: unknown[]) => unknown)(...args),
- factoids: (...args: unknown[]) => (mockFactoids as (...callArgs: unknown[]) => unknown)(...args),
- isAskMessageWithinBudget: (message: string) => new TextEncoder().encode(message).byteLength <= 80_000,
- isTimeoutError: (...args: unknown[]) => (mockIsTimeoutError as (...callArgs: unknown[]) => unknown)(...args),
-}));
-
-vi.mock('@/lib/ai/streaming', () => ({
- isAskStreamingSupported: (...args: unknown[]) => (mockIsAskStreamingSupported as (...callArgs: unknown[]) => unknown)(...args),
- streamAskChat: (...args: unknown[]) => (mockStreamAskChat as (...callArgs: unknown[]) => unknown)(...args),
-}));
-
-vi.mock('@/components/features/subscription', () => ({
- LimitReachedModal: (): null => null,
- ModelRestrictedModal: (): null => null,
- PricingModal: (): null => null,
-}));
-
-vi.mock('@/components/features/ask/AskChat', () => ({
- AskChat: ({ onSend, onClear, onLoadChat, onSaveChat, onSaveSource }: { onSend: (...args: any[]) => Promise, onClear: () => void, onLoadChat?: (id: string) => void, onSaveChat: () => void, onSaveSource: (t: string, c: string) => void }) => (
-
-
-
-
-
-
-
-
-
- ),
-}));
-
-vi.mock('@/components/features/brainstorm/BrainstormForm', () => ({
- BrainstormForm: ({ onSubmit }: { onSubmit: (input: any) => void }) => (
-
- ),
-}));
-
-vi.mock('@/components/features/brainstorm/BrainstormResults', () => ({
- BrainstormResults: ({ onDiscuss }: { onDiscuss: () => void }) => (
-
- ),
-}));
-
-vi.mock('@/components/features/factoids/FactoidsForm', () => ({
- FactoidsForm: ({ onSubmit }: { onSubmit: (input: any) => void }) => (
-
- ),
-}));
-
-vi.mock('@/components/features/factoids/FactoidsResults', () => ({
- FactoidsResults: ({ onDiscuss, onSaveSource, discoveredSources }: { onDiscuss: () => void, onSaveSource: (t: string, c: string, u?: string) => void, discoveredSources?: unknown[] }) => (
-
-
-
-
-
- ),
-}));
-
-import AskPage from '@/pages/AskPage';
-import BrainstormPage from '@/pages/BrainstormPage';
-import FactoidsPage from '@/pages/FactoidsPage';
-import { enableOneOffMode } from '@/test/helpers/debate';
-
-describe('Conversation Pages', () => {
- beforeEach(() => {
- enableOneOffMode();
- vi.clearAllMocks();
-
- sessionState.askMessages = [];
- sessionState.askLoading = false;
- sessionState.askStreamingAssistant = null;
- sessionState.brainstormResult = null;
- sessionState.brainstormLoading = false;
- sessionState.factoidsResult = null;
- sessionState.factoidsLoading = false;
- sessionState.setFactoidsResult.mockImplementation((result: any) => {
- sessionState.factoidsResult = result;
- });
-
- subErrorState.handleSubscriptionError.mockReturnValue(false);
- mockIsTimeoutError.mockReturnValue(false);
- mockIsAskStreamingSupported.mockReturnValue(false);
- sessionState.setAskStreamingAssistant.mockClear();
- sessionState.appendAskStreamingDelta.mockClear();
- });
-
- it('AskPage sends user message and appends AI response', async () => {
- mockAskChat.mockResolvedValueOnce({ response: 'Try value framing.', followUpQuestions: [], uncertainties: [] });
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('ask-send'));
-
- await waitFor(() => {
- expect(mockAskChat).toHaveBeenCalled();
- });
-
- expect(mockAskChat).toHaveBeenCalledWith(
- 'How do I frame this?',
- [],
- 'general',
- { provider: 'openai', model: 'gpt-4o', temperature: 0.7 },
- { citationMode: true, webSearch: true, prosConsMode: true, argumentMapMode: true, debateId: null }
- );
- expect(sessionState.addAskMessage).toHaveBeenCalledWith({ role: 'user', content: 'How do I frame this?' });
- expect(sessionState.addAskMessage).toHaveBeenCalledWith({
- role: 'assistant',
- content: 'Try value framing.',
- output: expect.objectContaining({ response: 'Try value framing.', followUpQuestions: [], uncertainties: [] }),
- });
- expect(sessionState.setAskLoading).toHaveBeenCalledWith(true);
- expect(sessionState.setAskLoading).toHaveBeenCalledWith(false);
- });
-
- it('AskPage excludes private chat history from workspace requests', async () => {
- sessionState.askMessages = [{ role: 'user', content: 'Private question' }];
- mockAskChat.mockResolvedValueOnce({ response: 'Workspace answer', followUpQuestions: [], uncertainties: [] });
- const workspaceContext: WorkspaceToolContext = {
- documentEpoch: 2,
- documentId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
- documentIds: ['bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'],
- documentText: 'Accepted text',
- documentTitle: 'R1',
- motion: 'Motion',
- requestId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
- returnUrl: '/app/workspaces/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
- side: 'pro',
- sourceIds: [],
- threadRequest: null,
- threadId: null,
- workspaceId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
- };
-
- render(
-
-
- ,
- );
- fireEvent.click(screen.getByTestId('ask-send'));
-
- await waitFor(() => expect(mockAskChat).toHaveBeenCalled());
- expect(mockAskChat.mock.calls[0][1]).toEqual([]);
- expect(mockAskChat.mock.calls[0][4]).toEqual(expect.objectContaining({ workspaceContext }));
- });
-
- it('AskPage shows timeout toast for timeout errors', async () => {
- mockAskChat.mockRejectedValueOnce(new Error('timeout'));
- mockIsTimeoutError.mockReturnValueOnce(true);
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('ask-send'));
-
- await waitFor(() => {
- expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Request Timed Out' }));
- });
- });
-
- it('AskPage rejects an oversized multibyte message before calling AI', async () => {
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('ask-oversized'));
-
- await waitFor(() => {
- expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Message is too large' }));
- });
- expect(mockAskChat).not.toHaveBeenCalled();
- });
-
- it('AskPage streams basic ask replies when supported', async () => {
- mockIsAskStreamingSupported.mockReturnValueOnce(true);
- mockStreamAskChat.mockImplementationOnce(async (...args: any[]) => {
- const handlers = args[5];
- handlers.onDelta('Try ');
- handlers.onDelta('value framing.');
- return { response: 'Try value framing.', followUpQuestions: [], uncertainties: [] };
- });
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('ask-send'));
-
- await waitFor(() => {
- expect(mockStreamAskChat).toHaveBeenCalled();
- });
-
- expect(mockAskChat).not.toHaveBeenCalled();
- expect(sessionState.addAskMessage).toHaveBeenCalledWith({
- role: 'assistant',
- content: 'Try value framing.',
- output: expect.objectContaining({ response: 'Try value framing.', followUpQuestions: [], uncertainties: [] }),
- });
- });
-
- it('AskPage keeps clarify mode on the deterministic non-streaming path', async () => {
- mockIsAskStreamingSupported.mockImplementation(
- (_config: unknown, options?: { mode?: string }) => options?.mode !== 'clarify',
- );
- mockAskChat.mockResolvedValueOnce({
- response: 'Please specify the governing ruleset.',
- followUpQuestions: ['Which ruleset applies?'],
- uncertainties: [],
- });
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('ask-clarify'));
-
- await waitFor(() => {
- expect(mockAskChat).toHaveBeenCalledWith(
- 'What should I clarify?',
- [],
- 'clarify',
- { provider: 'openai', model: 'gpt-4o', temperature: 0.7 },
- { citationMode: false, webSearch: false, prosConsMode: false, argumentMapMode: false, debateId: null },
- );
- });
- expect(mockStreamAskChat).not.toHaveBeenCalled();
- });
-
- it('drops buffered deltas from an aborted stream when a newer request starts', async () => {
- mockIsAskStreamingSupported.mockReturnValue(true);
- let firstHandlers: any;
- mockStreamAskChat
- .mockImplementationOnce((...args: any[]) => {
- firstHandlers = args[5];
- return new Promise((_resolve, reject) => {
- firstHandlers.signal.addEventListener(
- 'abort',
- () => reject(new DOMException('Aborted', 'AbortError')),
- { once: true },
- );
- });
- })
- .mockImplementationOnce(async (...args: any[]) => {
- const handlers = args[5];
- handlers.onDelta('new response');
- return { response: 'new response', followUpQuestions: [], uncertainties: [] };
- });
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('ask-send'));
- await waitFor(() => expect(firstHandlers).toBeDefined());
- firstHandlers.onDelta('stale response');
-
- fireEvent.click(screen.getByTestId('ask-send'));
-
- await waitFor(() => {
- expect(mockStreamAskChat).toHaveBeenCalledTimes(2);
- expect(sessionState.addAskMessage).toHaveBeenCalledWith(expect.objectContaining({
- role: 'assistant',
- content: 'new response',
- }));
- });
-
- expect(sessionState.appendAskStreamingDelta).not.toHaveBeenCalledWith('stale response');
- });
-
- it('ignores a stream that resolves after a clarify request replaces it', async () => {
- mockIsAskStreamingSupported.mockImplementation(
- (_config: unknown, options?: { mode?: string }) => options?.mode !== 'clarify',
- );
- let resolveOldStream!: (value: unknown) => void;
- let resolveClarify!: (value: unknown) => void;
- let firstHandlers: any;
- mockStreamAskChat.mockImplementationOnce((...args: any[]) => {
- firstHandlers = args[5];
- return new Promise((resolve) => {
- resolveOldStream = resolve;
- });
- });
- mockAskChat.mockImplementationOnce(() => new Promise((resolve) => {
- resolveClarify = resolve;
- }));
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('ask-send'));
- await waitFor(() => expect(firstHandlers).toBeDefined());
-
- sessionState.setAskLoading.mockClear();
- fireEvent.click(screen.getByTestId('ask-clarify'));
- await waitFor(() => expect(mockAskChat).toHaveBeenCalledWith(
- 'What should I clarify?',
- [],
- 'clarify',
- { provider: 'openai', model: 'gpt-4o', temperature: 0.7 },
- { citationMode: false, webSearch: false, prosConsMode: false, argumentMapMode: false, debateId: null },
- ));
-
- sessionState.setAskLoading.mockClear();
- sessionState.setAskStreamingAssistant.mockClear();
- resolveOldStream({ response: 'stale response', followUpQuestions: [], uncertainties: [] });
- await drainAsyncWork();
-
- expect(sessionState.addAskMessage).not.toHaveBeenCalledWith(expect.objectContaining({
- role: 'assistant',
- content: 'stale response',
- }));
- expect(sessionState.appendAskStreamingDelta).not.toHaveBeenCalled();
- expect(sessionState.setAskStreamingAssistant).not.toHaveBeenCalledWith(null);
- expect(sessionState.setAskLoading).not.toHaveBeenCalledWith(false);
-
- resolveClarify({ response: 'Please specify the governing ruleset.', followUpQuestions: [], uncertainties: [] });
- await waitFor(() => expect(sessionState.addAskMessage).toHaveBeenCalledWith(expect.objectContaining({
- role: 'assistant',
- content: 'Please specify the governing ruleset.',
- })));
- });
-
- it('ignores a stream that resolves after the conversation is cleared', async () => {
- mockIsAskStreamingSupported.mockReturnValue(true);
- let resolveOldStream!: (value: unknown) => void;
- let firstHandlers: any;
- mockStreamAskChat.mockImplementationOnce((...args: any[]) => {
- firstHandlers = args[5];
- return new Promise((resolve) => {
- resolveOldStream = resolve;
- });
- });
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('ask-send'));
- await waitFor(() => expect(firstHandlers).toBeDefined());
-
- sessionState.setAskStreamingAssistant.mockClear();
- sessionState.setAskLoading.mockClear();
- fireEvent.click(screen.getByTestId('ask-clear'));
- expect(sessionState.clearAskMessages).toHaveBeenCalled();
- expect(sessionState.setAskStreamingAssistant).toHaveBeenCalledWith(null);
- expect(sessionState.setAskLoading).toHaveBeenCalledWith(false);
-
- sessionState.setAskStreamingAssistant.mockClear();
- sessionState.setAskLoading.mockClear();
- resolveOldStream({ response: 'stale response', followUpQuestions: [], uncertainties: [] });
- await drainAsyncWork();
-
- expect(sessionState.addAskMessage).not.toHaveBeenCalledWith(expect.objectContaining({
- role: 'assistant',
- content: 'stale response',
- }));
- expect(sessionState.appendAskStreamingDelta).not.toHaveBeenCalled();
- expect(sessionState.setAskStreamingAssistant).not.toHaveBeenCalledWith(null);
- expect(sessionState.setAskLoading).not.toHaveBeenCalledWith(false);
- });
-
- it('ignores a stream that resolves after a saved chat is loaded', async () => {
- mockIsAskStreamingSupported.mockReturnValue(true);
- let resolveOldStream!: (value: unknown) => void;
- let firstHandlers: any;
- mockStreamAskChat.mockImplementationOnce((...args: any[]) => {
- firstHandlers = args[5];
- return new Promise((resolve) => {
- resolveOldStream = resolve;
- });
- });
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('ask-send'));
- await waitFor(() => expect(firstHandlers).toBeDefined());
-
- sessionState.setAskStreamingAssistant.mockClear();
- sessionState.setAskLoading.mockClear();
- fireEvent.click(screen.getByTestId('ask-load-chat'));
- expect(sessionState.loadSavedChat).toHaveBeenCalledWith('saved-chat');
- expect(sessionState.setAskStreamingAssistant).toHaveBeenCalledWith(null);
- expect(sessionState.setAskLoading).toHaveBeenCalledWith(false);
-
- sessionState.setAskStreamingAssistant.mockClear();
- sessionState.setAskLoading.mockClear();
- resolveOldStream({ response: 'stale response', followUpQuestions: [], uncertainties: [] });
- await drainAsyncWork();
-
- expect(sessionState.addAskMessage).not.toHaveBeenCalledWith(expect.objectContaining({
- role: 'assistant',
- content: 'stale response',
- }));
- expect(sessionState.appendAskStreamingDelta).not.toHaveBeenCalled();
- expect(sessionState.setAskStreamingAssistant).not.toHaveBeenCalledWith(null);
- expect(sessionState.setAskLoading).not.toHaveBeenCalledWith(false);
- });
-
- it('does not write stale stream results after AskPage unmounts', async () => {
- mockIsAskStreamingSupported.mockReturnValue(true);
- let resolveOldStream!: (value: unknown) => void;
- let firstHandlers: any;
- mockStreamAskChat.mockImplementationOnce((...args: any[]) => {
- firstHandlers = args[5];
- return new Promise((resolve) => {
- resolveOldStream = resolve;
- });
- });
-
- const { unmount } = render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('ask-send'));
- await waitFor(() => expect(firstHandlers).toBeDefined());
-
- sessionState.addAskMessage.mockClear();
- sessionState.appendAskStreamingDelta.mockClear();
- sessionState.setAskStreamingAssistant.mockClear();
- sessionState.setAskLoading.mockClear();
- unmount();
-
- expect(sessionState.setAskStreamingAssistant).not.toHaveBeenCalled();
- expect(sessionState.setAskLoading).not.toHaveBeenCalled();
-
- resolveOldStream({ response: 'stale response', followUpQuestions: [], uncertainties: [] });
- await drainAsyncWork();
-
- expect(sessionState.addAskMessage).not.toHaveBeenCalled();
- expect(sessionState.appendAskStreamingDelta).not.toHaveBeenCalled();
- expect(sessionState.setAskStreamingAssistant).not.toHaveBeenCalled();
- expect(sessionState.setAskLoading).not.toHaveBeenCalled();
- });
-
- it('AskPage reports a rejected chat save instead of confirming success', async () => {
- sessionState.askMessages = [{ content: 'Chat to save' }];
- sessionState.saveCurrentChat.mockRejectedValueOnce(new Error('save failed'));
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('ask-save-chat'));
-
- await waitFor(() => {
- expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: 'destructive' }));
- });
- expect(mockToast).not.toHaveBeenCalledWith(expect.objectContaining({ title: 'Chat Saved' }));
- });
-
- it('AskPage saves source to library', () => {
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('ask-save'));
-
- expect(sessionState.addSource).toHaveBeenCalledWith({ title: 'Saved Title', content: 'Saved Content', type: 'text', folder: 'Ask' });
- expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Saved' }));
- });
-
- it('BrainstormPage submits and stores brainstorm results', async () => {
- mockBrainstorm.mockResolvedValueOnce({ result: { needsMoreInfo: false, nextSteps: ['A'], warningsAndTradeoffs: [] }, discoveredSources: [] });
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('brainstorm-submit'));
-
- await waitFor(() => {
- expect(mockBrainstorm).toHaveBeenCalled();
- });
-
- expect(sessionState.setBrainstormLoading).toHaveBeenCalledWith(true);
- expect(sessionState.setBrainstormResult).toHaveBeenCalledWith({ needsMoreInfo: false, nextSteps: ['A'], warningsAndTradeoffs: [] });
- expect(sessionState.setBrainstormLoading).toHaveBeenCalledWith(false);
- });
-
- it('BrainstormPage discuss flow navigates to ask and creates follow-up response', async () => {
- sessionState.brainstormResult = {
- needsMoreInfo: false,
- nextSteps: [],
- warningsAndTradeoffs: [],
- topFive: [{ rank: 1, idea: { title: 'Best idea', side: 'pro' } }],
- };
- mockAskChat.mockResolvedValueOnce({ response: 'Expand this angle.', followUpQuestions: [], uncertainties: [] });
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('brainstorm-discuss'));
-
- await waitFor(() => {
- expect(mockAskChat).toHaveBeenCalled();
- });
-
- expect(mockNavigate).toHaveBeenCalledWith('/app/ask');
- expect(sessionState.addAskMessage).toHaveBeenCalledWith(expect.objectContaining({ role: 'user' }));
- expect(sessionState.addAskMessage).toHaveBeenCalledWith(expect.objectContaining({ role: 'assistant', content: 'Expand this angle.' }));
- expect(sessionState.setAskLoading).toHaveBeenCalledWith(true);
- expect(sessionState.setAskLoading).toHaveBeenCalledWith(false);
- });
-
- it('FactoidsPage submits and stores factoid results', async () => {
- const factoidsResult: FactoidsOutput = { factoids: [], searchSuggestions: [], warnings: [], verificationStatus: 'complete' };
- mockFactoids.mockResolvedValueOnce({ result: factoidsResult, discoveredSources: [{ title: 'Consulted source', url: 'https://example.com' }] });
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('factoids-submit'));
-
- await waitFor(() => {
- expect(mockFactoids).toHaveBeenCalled();
- });
-
- expect(sessionState.setFactoidsLoading).toHaveBeenCalledWith(true);
- expect(sessionState.setFactoidsResult).toHaveBeenCalledWith(factoidsResult);
- expect(screen.getByTestId('factoids-source-count')).toHaveTextContent('1');
- expect(sessionState.setFactoidsLoading).toHaveBeenCalledWith(false);
- });
-
- it('FactoidsPage clears and replaces consulted sources for each request', async () => {
- const factoidsResult: FactoidsOutput = { factoids: [], searchSuggestions: [], warnings: [], verificationStatus: 'complete' };
- mockFactoids
- .mockResolvedValueOnce({ result: factoidsResult, discoveredSources: [{ title: 'First source', url: 'https://example.com/first' }] })
- .mockResolvedValueOnce({ result: factoidsResult, discoveredSources: [] });
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('factoids-submit'));
- await waitFor(() => expect(screen.getByTestId('factoids-source-count')).toHaveTextContent('1'));
-
- fireEvent.click(screen.getByTestId('factoids-submit'));
- await waitFor(() => expect(screen.getByTestId('factoids-source-count')).toHaveTextContent('0'));
- });
-
- it('FactoidsPage discuss flow navigates and appends assistant response', async () => {
- sessionState.factoidsResult = {
- factoids: [{ shortClaim: 'Claim A', citation: { publisher: 'Source A' } }],
- searchSuggestions: [],
- warnings: [],
- verificationStatus: 'complete',
- };
- mockAskChat.mockResolvedValueOnce({ response: 'Use this evidence in rebuttal.', followUpQuestions: [], uncertainties: [] });
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('factoids-discuss'));
-
- await waitFor(() => {
- expect(mockAskChat).toHaveBeenCalled();
- });
-
- expect(mockNavigate).toHaveBeenCalledWith('/app/ask');
- expect(sessionState.addAskMessage).toHaveBeenCalledWith(expect.objectContaining({ role: 'assistant', content: 'Use this evidence in rebuttal.' }));
- });
-
- it('FactoidsPage saves selected evidence to source library', async () => {
- sessionState.factoidsResult = {
- factoids: [{ shortClaim: 'Claim A', citation: { publisher: 'Source A' } }],
- searchSuggestions: [],
- warnings: [],
- verificationStatus: 'complete',
- };
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByTestId('factoids-save'));
-
- await waitFor(() => {
- expect(sessionState.addSource).toHaveBeenCalledWith({
- title: 'Fact source',
- content: 'Fact content',
- type: 'url',
- origin: 'https://example.com',
- folder: 'Factoids',
- });
- expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Saved to Library' }));
- });
- });
-});
diff --git a/src/test/pages/CrossfirePage.test.tsx b/src/test/pages/CrossfirePage.test.tsx
deleted file mode 100644
index 04d8e8daa..000000000
--- a/src/test/pages/CrossfirePage.test.tsx
+++ /dev/null
@@ -1,275 +0,0 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-const mocks = vi.hoisted(() => ({
- userId: 'user-a',
- crossfire: vi.fn<() => Promise>(() => new Promise(() => undefined)),
- crossfireSignal: vi.fn<(input: unknown, config: unknown, options: { signal?: AbortSignal }) => Promise>(() => Promise.resolve(undefined)),
- saveArtifact: vi.fn<(input: { progress: { config: { resolution: string } } }) => Promise>(),
-}));
-
-vi.mock('@/lib/workspace/practice-client', () => ({
- saveWorkspaceArtifact: mocks.saveArtifact,
-}));
-
-vi.mock('@/lib/supabase/AuthProvider', () => ({
- useAuth: () => ({ user: { id: mocks.userId } }),
-}));
-
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: (selector: (state: { sourceLibrary: unknown[] }) => unknown) => selector({ sourceLibrary: [] }),
-}));
-
-vi.mock('@/lib/stores/settings-store', () => ({
- useSettingsStore: () => ({
- getFeatureConfig: () => ({ provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.7 }),
- }),
-}));
-
-vi.mock('@/lib/ai/client', () => ({
- AIResponseError: class AIResponseError extends Error {},
- crossfire: mocks.crossfire,
- crossfireSignal: mocks.crossfireSignal,
- isTimeoutError: () => false,
-}));
-
-import CrossfirePage from '@/pages/CrossfirePage';
-import { createCrossfireSetupState } from '@/lib/crossfire/reducer';
-import { loadCrossfireDraft, saveCrossfireDraft } from '@/lib/crossfire/storage';
-import { enableOneOffMode, seedCurrentDebate, TEST_DEBATE_ID } from '@/test/helpers/debate';
-import type { WorkspaceToolContext } from '@/lib/workspace/tool-context';
-
-function renderPage() {
- return render(
-
-
- ,
- );
-}
-
-describe('CrossfirePage', () => {
- beforeEach(() => {
- enableOneOffMode();
- mocks.userId = 'user-a';
- mocks.crossfire.mockReset();
- mocks.crossfire.mockImplementation(() => new Promise(() => undefined));
- mocks.crossfireSignal.mockReset();
- mocks.crossfireSignal.mockResolvedValue(undefined);
- localStorage.clear();
- });
-
- it('autosaves workspace progress one save at a time so older text never lands last', async () => {
- const workspaceContext: WorkspaceToolContext = {
- documentEpoch: 2,
- documentId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
- documentIds: ['bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'],
- documentText: 'Accepted text',
- documentTitle: 'R1',
- motion: 'Motion',
- requestId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
- returnUrl: '/app/workspaces/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
- side: 'pro',
- sourceIds: [],
- threadRequest: null,
- threadId: null,
- workspaceId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
- };
- const finishSave: (() => void)[] = [];
- mocks.saveArtifact.mockImplementation(() => new Promise((resolve) => { finishSave.push(() => resolve('artifact')); }));
-
- render(
-
-
- ,
- );
- await waitFor(() => expect(mocks.saveArtifact).toHaveBeenCalledTimes(1));
-
- fireEvent.change(screen.getByTestId('crossfire-resolution'), { target: { value: 'Resolved: a' } });
- fireEvent.change(screen.getByTestId('crossfire-resolution'), { target: { value: 'Resolved: ab' } });
- fireEvent.change(screen.getByTestId('crossfire-resolution'), { target: { value: 'Resolved: abc' } });
- // The first save is still running, so nothing else is sent yet.
- expect(mocks.saveArtifact).toHaveBeenCalledTimes(1);
-
- finishSave[0]();
- await waitFor(() => expect(mocks.saveArtifact).toHaveBeenCalledTimes(2));
- expect(mocks.saveArtifact.mock.calls[1][0].progress.config.resolution).toBe('Resolved: abc');
- finishSave[1]();
- });
-
- it('supports arrow-key radio navigation', () => {
- renderPage();
- const questionAi = screen.getByTestId('mode-question-ai');
- const aiQuestionsUser = screen.getByTestId('mode-ai-questions-user');
- questionAi.focus();
- fireEvent.keyDown(questionAi, { key: 'ArrowRight' });
- expect(aiQuestionsUser).toHaveAttribute('aria-checked', 'true');
- expect(aiQuestionsUser).toHaveFocus();
-
- const cooperative = screen.getByTestId('style-cooperative');
- cooperative.focus();
- fireEvent.keyDown(cooperative, { key: 'End' });
- expect(screen.getByTestId('style-aggressive')).toHaveAttribute('aria-checked', 'true');
- expect(screen.getByTestId('style-aggressive')).toHaveFocus();
- });
-
- it('resets a mounted transcript when the authenticated account changes', async () => {
- const setItemSpy = vi.spyOn(window.localStorage, 'setItem');
- const view = renderPage();
- fireEvent.change(screen.getByTestId('crossfire-resolution'), { target: { value: 'Resolved: test' } });
- fireEvent.change(screen.getByTestId('crossfire-argument'), { target: { value: 'Case text' } });
- fireEvent.click(screen.getByTestId('start-crossfire'));
- fireEvent.change(screen.getByTestId('crossfire-input'), { target: { value: 'What is the mechanism?' } });
- fireEvent.click(screen.getByTestId('submit-turn'));
- expect(screen.getByText('What is the mechanism?')).toBeInTheDocument();
-
- const userBDraft = createCrossfireSetupState({
- resolution: 'Resolved: preserve B',
- argument: 'Account B saved case',
- });
- expect(saveCrossfireDraft('user-b', userBDraft, window.localStorage, 10)).toBe(true);
- setItemSpy.mockClear();
- mocks.userId = 'user-b';
- view.rerender(
-
-
- ,
- );
-
- await waitFor(() => expect(screen.getByTestId('crossfire-resolution')).toHaveValue('Resolved: preserve B'));
- expect(screen.queryByText('What is the mechanism?')).not.toBeInTheDocument();
- expect(screen.getByTestId('crossfire-argument')).toHaveValue('Account B saved case');
- const writesForUserB = setItemSpy.mock.calls.filter(([key]) => key === 'debatelab-crossfire-draft:user-b');
- expect(writesForUserB.every(([, value]) => !value.includes('What is the mechanism?'))).toBe(true);
- expect(loadCrossfireDraft('user-b', window.localStorage)).toEqual(userBDraft);
- setItemSpy.mockRestore();
- });
-
- it('keeps a restored round out of a Current Debate with another motion', async () => {
- const start = () => {
- const view = renderPage();
- fireEvent.change(screen.getByTestId('crossfire-resolution'), { target: { value: 'Resolved: test' } });
- fireEvent.change(screen.getByTestId('crossfire-argument'), { target: { value: 'Case text' } });
- fireEvent.click(screen.getByTestId('start-crossfire'));
- view.unmount();
- };
- const sendTurn = () => {
- renderPage();
- fireEvent.change(screen.getByTestId('crossfire-input'), { target: { value: 'What is the mechanism?' } });
- fireEvent.click(screen.getByTestId('submit-turn'));
- };
- const lastDebateId = () => (mocks.crossfire.mock.calls.at(-1) as unknown[] | undefined)?.[2] as { debateId?: string | null };
-
- // Started one-off, then the student opens a debate on another motion and comes back.
- start();
- seedCurrentDebate({ motion: 'Resolved: something else' });
- sendTurn();
- await waitFor(() => expect(mocks.crossfire).toHaveBeenCalled());
- expect(lastDebateId().debateId).toBeNull();
- });
-
- it('keeps a restored round in the Current Debate when the motion matches', async () => {
- seedCurrentDebate({ motion: 'Resolved: test' });
- const view = renderPage();
- fireEvent.change(screen.getByTestId('crossfire-resolution'), { target: { value: 'Resolved: test' } });
- fireEvent.change(screen.getByTestId('crossfire-argument'), { target: { value: 'Case text' } });
- fireEvent.click(screen.getByTestId('start-crossfire'));
- view.unmount();
-
- renderPage();
- fireEvent.change(screen.getByTestId('crossfire-input'), { target: { value: 'What is the mechanism?' } });
- fireEvent.click(screen.getByTestId('submit-turn'));
- await waitFor(() => expect(mocks.crossfire).toHaveBeenCalled());
- expect(((mocks.crossfire.mock.calls.at(-1) as unknown[])[2] as { debateId?: string | null }).debateId).toBe(TEST_DEBATE_ID);
- });
-
- it('moves an unexpected next-turn variant into the retry state', async () => {
- mocks.crossfire.mockResolvedValueOnce({
- concessionsSecured: [],
- controlledQuestions: [],
- weakOrRepetitiveQuestions: [],
- evasiveOrIncompleteAnswers: [],
- missedFollowUps: [],
- betterFollowUpQuestions: [],
- strongestExchange: null,
- });
- renderPage();
- fireEvent.change(screen.getByTestId('crossfire-resolution'), { target: { value: 'Resolved: test' } });
- fireEvent.change(screen.getByTestId('crossfire-argument'), { target: { value: 'Case text' } });
- fireEvent.click(screen.getByTestId('start-crossfire'));
- fireEvent.change(screen.getByTestId('crossfire-input'), { target: { value: 'What is the mechanism?' } });
- fireEvent.click(screen.getByTestId('submit-turn'));
-
- expect(await screen.findByTestId('retry-ai')).toBeInTheDocument();
- expect(screen.getByText('The coach returned an incomplete response. Try again to continue.')).toBeInTheDocument();
- });
-
- it('moves an unexpected debrief variant into the retry state', async () => {
- mocks.crossfire
- .mockResolvedValueOnce({ type: 'answer', text: 'A bounded answer.' })
- .mockResolvedValueOnce({ type: 'question', text: 'Not a debrief.' });
- renderPage();
- fireEvent.change(screen.getByTestId('crossfire-resolution'), { target: { value: 'Resolved: test' } });
- fireEvent.change(screen.getByTestId('crossfire-argument'), { target: { value: 'Case text' } });
- fireEvent.click(screen.getByTestId('start-crossfire'));
- fireEvent.change(screen.getByTestId('crossfire-input'), { target: { value: 'What is the mechanism?' } });
- fireEvent.click(screen.getByTestId('submit-turn'));
- expect(await screen.findByText('A bounded answer.')).toBeInTheDocument();
- fireEvent.click(screen.getByTestId('end-crossfire'));
-
- expect(await screen.findByTestId('retry-debrief')).toBeInTheDocument();
- expect(screen.getByText('The coach returned an incomplete debrief. Try again to continue.')).toBeInTheDocument();
- });
-
- it('shows live coaching on the answer only', async () => {
- mocks.crossfire.mockResolvedValueOnce({
- type: 'answer',
- text: 'A bounded answer.',
- signalToken: 'token',
- });
- mocks.crossfireSignal.mockResolvedValueOnce({ answerTurnId: 'T2', answerQuality: 'direct', answerQualityConfidence: 0.91, concession: 'implicit', concessionConfidence: 0.74, followUpOpportunity: 0.82 });
- renderPage();
- fireEvent.change(screen.getByTestId('crossfire-resolution'), { target: { value: 'Resolved: test' } });
- fireEvent.change(screen.getByTestId('crossfire-argument'), { target: { value: 'Case text' } });
- fireEvent.click(screen.getByTestId('start-crossfire'));
- fireEvent.change(screen.getByTestId('crossfire-input'), { target: { value: 'What is the mechanism?' } });
- fireEvent.click(screen.getByTestId('submit-turn'));
-
- const answer = await screen.findByText('A bounded answer.');
- expect(answer.closest('article')).toHaveTextContent('Direct answer');
- expect(answer.closest('article')).toHaveTextContent('Implicit concession');
- expect(answer.closest('article')).toHaveTextContent('Follow-up opportunity');
- expect(screen.getByText('What is the mechanism?').closest('article')).not.toHaveTextContent('Direct answer');
- expect(answer.closest('article')?.querySelector('[aria-label*="82%"]')).not.toBeNull();
- });
-
- it('keeps a delayed coaching signal alive while the next turn runs', async () => {
- let resolveSignal: (value: unknown) => void = () => undefined;
- let sidecarSignal: AbortSignal | undefined;
- mocks.crossfire
- .mockResolvedValueOnce({ type: 'answer', text: 'First bounded answer.', signalToken: 'token-1' })
- .mockResolvedValueOnce({ type: 'answer', text: 'Second bounded answer.' });
- mocks.crossfireSignal.mockImplementationOnce((_input, _config, options) => {
- sidecarSignal = options.signal;
- return new Promise((resolve) => {
- resolveSignal = resolve;
- });
- });
- renderPage();
- fireEvent.change(screen.getByTestId('crossfire-resolution'), { target: { value: 'Resolved: test' } });
- fireEvent.change(screen.getByTestId('crossfire-argument'), { target: { value: 'Case text' } });
- fireEvent.click(screen.getByTestId('start-crossfire'));
-
- fireEvent.change(screen.getByTestId('crossfire-input'), { target: { value: 'First question?' } });
- fireEvent.click(screen.getByTestId('submit-turn'));
- const firstAnswer = await screen.findByText('First bounded answer.');
-
- fireEvent.change(screen.getByTestId('crossfire-input'), { target: { value: 'Second question?' } });
- fireEvent.click(screen.getByTestId('submit-turn'));
- expect(await screen.findByText('Second bounded answer.')).toBeInTheDocument();
- expect(sidecarSignal?.aborted).toBe(false);
-
- resolveSignal({ answerTurnId: 'T2', answerQuality: 'direct', answerQualityConfidence: 0.91, concession: 'none', concessionConfidence: 0.88, followUpOpportunity: 0.76 });
- await waitFor(() => expect(firstAnswer.closest('article')).toHaveTextContent('Direct answer'));
- });
-});
diff --git a/src/test/pages/FlowchartPage.test.tsx b/src/test/pages/FlowchartPage.test.tsx
deleted file mode 100644
index 4905f6c2c..000000000
--- a/src/test/pages/FlowchartPage.test.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import { fireEvent, render, screen } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
-import { beforeEach, expect, test, vi } from 'vitest';
-import FlowchartPage from '@/pages/FlowchartPage';
-import i18n from '@/i18n';
-
-const { mockSavedFlowcharts, mockToastSuccess } = vi.hoisted(() => ({
- mockSavedFlowcharts: [] as Array<{
- id: string;
- title: string;
- createdAt: number;
- data: { nodes: unknown[]; edges: unknown[] };
- }>,
- mockToastSuccess: vi.fn(),
-}));
-
-vi.mock('sonner', () => ({
- toast: { success: mockToastSuccess, error: vi.fn() },
-}));
-
-vi.mock('html-to-image', () => ({ toPng: vi.fn() }));
-
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: () => ({
- pendingFlowchartData: null as null,
- setPendingFlowchartData: vi.fn(),
- saveFlowchart: vi.fn(),
- savedFlowcharts: mockSavedFlowcharts,
- deleteSavedFlowchart: vi.fn(),
- }),
-}));
-
-vi.mock('reactflow', async () => {
- const React = await import('react');
- return {
- default: ({ nodes, onNodeClick }: {
- nodes: Array<{ id: string; data: { label: string; description?: string } }>;
- onNodeClick: (event: React.MouseEvent, node: { id: string }) => void;
- }) => {nodes.map((node) => (
-
- ))}
,
- useNodesState: (initial: unknown[]) => {
- const [nodes, setNodes] = React.useState(initial);
- return [nodes, setNodes, vi.fn()];
- },
- useEdgesState: (initial: unknown[]) => {
- const [edges, setEdges] = React.useState(initial);
- return [edges, setEdges, vi.fn()];
- },
- addEdge: vi.fn(),
- Controls: (): null => null,
- Background: (): null => null,
- MiniMap: (): null => null,
- Handle: (): null => null,
- Position: { Top: 'top', Bottom: 'bottom' },
- MarkerType: { ArrowClosed: 'arrowclosed' },
- BackgroundVariant: { Dots: 'dots' },
- };
-});
-
-beforeEach(async () => {
- mockSavedFlowcharts.length = 0;
- mockToastSuccess.mockClear();
- vi.useRealTimers();
- await i18n.changeLanguage('en');
-});
-
-const renderPage = () => render();
-
-test('keeps a selected node when deletion is cancelled', () => {
- renderPage();
-
- fireEvent.click(screen.getByRole('button', { name: 'Argument 1' }));
- fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
-
- expect(screen.getByRole('alertdialog', { name: 'Delete node?' })).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
- expect(screen.getByRole('button', { name: 'Argument 1' })).toBeInTheDocument();
- expect(mockToastSuccess).not.toHaveBeenCalled();
-});
-
-test('deletes a selected node only after confirmation and reports success', () => {
- renderPage();
-
- fireEvent.click(screen.getByRole('button', { name: 'Argument 1' }));
- fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
- fireEvent.click(screen.getByRole('button', { name: 'Delete node' }));
-
- expect(screen.queryByRole('button', { name: 'Argument 1' })).not.toBeInTheDocument();
- expect(mockToastSuccess).toHaveBeenCalledWith('Node deleted');
-});
-
-test('clears a stale node selection when a saved flowchart is loaded', () => {
- mockSavedFlowcharts.push({
- id: 'saved-1',
- title: 'Loaded chart',
- createdAt: Date.now(),
- data: {
- nodes: [{
- id: 'loaded-node',
- type: 'custom',
- position: { x: 0, y: 0 },
- data: { label: 'Loaded argument', type: 'contention' },
- }],
- edges: [],
- },
- });
- renderPage();
-
- fireEvent.click(screen.getByRole('button', { name: 'Argument 1' }));
- fireEvent.click(screen.getByRole('button', { name: 'Load' }));
- fireEvent.click(screen.getByRole('button', { name: /Loaded chart/ }));
-
- expect(screen.getByRole('button', { name: 'Loaded argument' })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
-});
-
-test('formats saved-flowchart relative time in Hungarian', async () => {
- vi.useFakeTimers();
- vi.setSystemTime(new Date(2026, 4, 19, 13, 45));
- await i18n.changeLanguage('hu');
- mockSavedFlowcharts.push({
- id: 'saved-hu',
- title: 'Mentett ábra',
- createdAt: new Date(2026, 4, 19, 12, 45).getTime(),
- data: { nodes: [], edges: [] },
- });
-
- renderPage();
-
- fireEvent.click(screen.getByRole('button', { name: 'Betöltés' }));
- expect(screen.getByText('körülbelül 1 órával ezelőtt')).toBeInTheDocument();
-});
-
-test('localizes starter flowchart nodes in Hungarian', async () => {
- await i18n.changeLanguage('hu');
- renderPage();
-
- expect(screen.getByRole('button', { name: '1. érv' })).toBeInTheDocument();
- expect(screen.getByText('Az első érved')).toBeInTheDocument();
- expect(screen.getByText('Így válaszolhatnak')).toBeInTheDocument();
- expect(screen.getByText('Így válaszolsz a támadásukra')).toBeInTheDocument();
-});
diff --git a/src/test/pages/HistoryPage.test.tsx b/src/test/pages/HistoryPage.test.tsx
deleted file mode 100644
index 5d4ae0094..000000000
--- a/src/test/pages/HistoryPage.test.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import i18n from '@/i18n';
-
-const mockNavigate = vi.fn();
-vi.mock('react-router-dom', () => ({
- useNavigate: () => mockNavigate,
-}));
-
-const mockStore = {
- savedFlowcharts: [] as any[],
- savedCrossfires: [] as any[],
- sourceLibrary: [] as any[],
- savedCases: [] as any[],
- savedChats: [] as any[],
- savedAttacks: [] as any[],
- savedBrainstorms: [] as any[],
- savedFactoids: [] as any[],
- loadSavedCase: vi.fn(),
- loadSavedChat: vi.fn(),
- loadSavedAttack: vi.fn(),
- loadSavedBrainstorm: vi.fn(),
- loadSavedFactoids: vi.fn(),
-};
-
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: vi.fn((selector?: (state: typeof mockStore) => unknown) => selector ? selector(mockStore) : mockStore),
-}));
-
-import HistoryPage from '@/pages/HistoryPage';
-import { resetDebates, seedCurrentDebate, TEST_DEBATE_ID } from '@/test/helpers/debate';
-
-describe('HistoryPage', () => {
- beforeEach(async () => {
- vi.clearAllMocks();
- await i18n.changeLanguage('en');
- seedCurrentDebate();
- mockStore.savedCases = [];
- mockStore.savedChats = [];
- mockStore.savedAttacks = [];
- mockStore.savedBrainstorms = [];
- mockStore.savedFactoids = [];
- });
-
- it('renders page title', () => {
- render();
- expect(screen.getByText('History')).toBeDefined();
- });
-
- it('renders the page heading without a decorative subtitle', () => {
- render();
- expect(screen.getByRole('heading', { level: 1, name: 'History' })).toBeDefined();
- expect(screen.queryByText('Access your saved prep sessions')).toBeNull();
- });
-
- it('renders cases tab', () => {
- render();
- expect(screen.getByText('Cases')).toBeDefined();
- });
-
- it('renders chats tab', () => {
- render();
- expect(screen.getByText('Chats')).toBeDefined();
- });
-
- it('renders attacks tab', () => {
- render();
- expect(screen.getByText('Attacks')).toBeDefined();
- });
-
- it('renders brainstorms tab', () => {
- render();
- expect(screen.getByText('Brainstorms')).toBeDefined();
- });
-
- it('renders factoids tab', () => {
- render();
- expect(screen.getByText('Factoids')).toBeDefined();
- });
-
- it('shows empty state when no saved cases', () => {
- render();
- expect(screen.getByText('No saved cases yet')).toBeDefined();
- });
-
- it('renders with all tabs visible', () => {
- render();
- const tabs = screen.getAllByRole('tab');
- expect(tabs.length).toBe(6);
- });
-
- it('has container class', () => {
- const { container } = render();
- expect(container.querySelector('.container')).not.toBeNull();
- });
-
- it('renders saved cases when present', () => {
- mockStore.savedCases = [
- { id: '1', title: 'Test Case', timestamp: Date.now(), data: {}, debateId: TEST_DEBATE_ID },
- ];
- render();
- expect(screen.getByText('Test Case')).toBeDefined();
- });
-
- it('renders load button for saved cases', () => {
- mockStore.savedCases = [
- { id: '1', title: 'My Case', timestamp: Date.now(), data: {}, debateId: TEST_DEBATE_ID },
- ];
- render();
- expect(screen.getByText('Load')).toBeDefined();
- });
-
- it('localizes the saved-case delete label in Hungarian', async () => {
- await i18n.changeLanguage('hu');
- mockStore.savedCases = [{
- id: '1',
- title: 'Teszt eset',
- createdAt: Date.now(),
- data: { metadata: { side: 'pro' } },
- debateId: TEST_DEBATE_ID,
- }];
-
- render();
- expect(screen.getByRole('button', { name: 'Teszt eset törlése' })).toBeInTheDocument();
- });
-
- it('renders heading element', () => {
- const { container } = render();
- const h1 = container.querySelector('h1');
- expect(h1).not.toBeNull();
- expect(h1!.textContent).toBe('History');
- });
-
- it('renders with max-w-5xl container', () => {
- const { container } = render();
- expect(container.querySelector('.max-w-5xl')).not.toBeNull();
- });
-
- it('defaults to the Current Debate and never mixes in other debates', () => {
- mockStore.savedCases = [
- { id: '1', title: 'Homework case', createdAt: Date.now(), data: {}, debateId: TEST_DEBATE_ID },
- { id: '2', title: 'Zoo case', createdAt: Date.now(), data: {}, debateId: 'other-debate' },
- { id: '3', title: 'One-off case', createdAt: Date.now(), data: {}, debateId: null },
- ];
- render();
- expect(screen.getByText('Homework case')).toBeInTheDocument();
- expect(screen.queryByText('Zoo case')).toBeNull();
- expect(screen.queryByText('One-off case')).toBeNull();
- });
-
- it('shows one-off and unsorted work with an exact-match suggestion when there is no current debate scope', () => {
- resetDebates();
- mockStore.savedCases = [
- { id: '3', title: 'One-off case', createdAt: Date.now(), data: {}, debateId: null },
- ];
- render();
- expect(screen.getByText('One-off case')).toBeInTheDocument();
- expect(screen.getByText('These results are not in any debate. Nothing moves until you confirm.')).toBeInTheDocument();
- });
-});
diff --git a/src/test/pages/LibraryPage.test.tsx b/src/test/pages/LibraryPage.test.tsx
deleted file mode 100644
index a510f2abe..000000000
--- a/src/test/pages/LibraryPage.test.tsx
+++ /dev/null
@@ -1,351 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
-import i18n from '@/i18n';
-
-const { mockToast } = vi.hoisted(() => ({ mockToast: vi.fn() }));
-
-vi.mock('@/hooks/use-toast', () => ({
- useToast: () => ({ toast: mockToast }),
-}));
-
-const mockStore = {
- sourceLibrary: [] as any[],
- sourceFolders: ['General'] as string[],
- addSource: vi.fn(),
- createSourceFolder: vi.fn(),
- updateSource: vi.fn(),
- renameSourceFolder: vi.fn(),
- deleteSourceFolder: vi.fn(),
- setFolderActive: vi.fn(),
- removeSource: vi.fn(),
- toggleSource: vi.fn(),
- clearLibrary: vi.fn(),
-};
-
-const importSourceFromUrlMock = vi.fn();
-
-vi.mock('@/lib/stores/session-store', () => ({
- useSessionStore: vi.fn(() => mockStore),
-}));
-
-vi.mock('@/lib/source-import', () => ({
- importSourceFromUrl: (...args: unknown[]) => importSourceFromUrlMock(...args),
-}));
-
-import LibraryPage from '@/pages/LibraryPage';
-
-// Fixtures belong to the seeded Current Debate unless a test says otherwise.
-let rawSources: any[] = [];
-Object.defineProperty(mockStore, 'sourceLibrary', {
- get: () => rawSources,
- set: (sources: any[]) => { rawSources = sources.map((source) => ({ debateId: TEST_DEBATE_ID, ...source })); },
-});
-import { resetDebates, seedCurrentDebate, TEST_DEBATE_ID } from '@/test/helpers/debate';
-
-describe('LibraryPage', () => {
- beforeEach(async () => {
- vi.clearAllMocks();
- await i18n.changeLanguage('en');
- seedCurrentDebate({ motion: 'This House would ban homework' });
- mockStore.sourceLibrary = [];
- mockStore.sourceFolders = ['General'];
- mockStore.addSource.mockResolvedValue(undefined);
- mockStore.createSourceFolder.mockResolvedValue(undefined);
- mockStore.updateSource.mockResolvedValue(undefined);
- mockStore.renameSourceFolder.mockResolvedValue(undefined);
- mockStore.deleteSourceFolder.mockResolvedValue(undefined);
- mockStore.setFolderActive.mockResolvedValue(undefined);
- mockStore.removeSource.mockResolvedValue(undefined);
- mockStore.toggleSource.mockResolvedValue(undefined);
- mockStore.clearLibrary.mockResolvedValue(undefined);
- window.confirm = vi.fn(() => false);
- });
-
- it('renders page title', () => {
- render();
- expect(screen.getByText('Source Library')).toBeDefined();
- });
-
- it('renders title', () => {
- render();
- expect(screen.getByText('Source Library')).toBeDefined();
- });
-
- it('shows empty state when no sources', () => {
- render();
- expect(screen.getByText('No Sources Yet')).toBeDefined();
- });
-
- it('renders add source button', () => {
- render();
- expect(screen.getByText('Add Source')).toBeDefined();
- });
-
- it('shows add form on button click', () => {
- render();
- fireEvent.click(screen.getByText('Add Source'));
- expect(screen.getByText('Add New Source')).toBeDefined();
- });
-
- it('shows cancel text after clicking add', () => {
- render();
- fireEvent.click(screen.getByText('Add Source'));
- expect(screen.getByText('Cancel')).toBeDefined();
- });
-
- it('hides form on cancel click', () => {
- render();
- fireEvent.click(screen.getByText('Add Source'));
- fireEvent.click(screen.getByText('Cancel'));
- expect(screen.queryByText('Add New Source')).toBeNull();
- });
-
- it('renders sources when present', () => {
- mockStore.sourceLibrary = [
- { id: '1', title: 'Source 1', content: 'Content', type: 'text', isActive: true, createdAt: Date.now() },
- ];
- render();
- expect(screen.getByText('Source 1')).toBeDefined();
- });
-
- it('renders multiple sources', () => {
- mockStore.sourceLibrary = [
- { id: '1', title: 'Source A', content: 'A', type: 'text', isActive: true, createdAt: Date.now() },
- { id: '2', title: 'Source B', content: 'B', type: 'url', isActive: false, createdAt: Date.now() },
- ];
- render();
- expect(screen.getByText('Source A')).toBeDefined();
- expect(screen.getByText('Source B')).toBeDefined();
- });
-
- it('defaults to Smart ordering and puts case-used evidence first', () => {
- mockStore.sourceLibrary = [
- { id: 'new', title: 'New source', content: 'A', type: 'text', isActive: true, createdAt: 2 },
- { id: 'used', title: 'Used source', content: 'B', type: 'text', isActive: true, createdAt: 1, metadata: { semantic: { relevance: 0.8, importance: 2, strength: 3, evaluatedAt: '2026-09-21T00:00:00.000Z', useCount: 4, caseUseCount: 1, lastUsedInCase: true } } },
- ];
- render();
-
- expect(screen.getByRole('combobox', { name: 'Sort by' })).toHaveTextContent('Smart');
- const labels = screen.getAllByText(/source$/i).map((element) => element.textContent);
- expect(labels.indexOf('Used source')).toBeLessThan(labels.indexOf('New source'));
- expect(screen.getByText('Used in this case')).toBeInTheDocument();
- });
-
- it('shows grounding status', () => {
- mockStore.sourceLibrary = [
- { id: '1', title: 'Active Source', content: 'C', type: 'text', isActive: true, createdAt: Date.now() },
- ];
- render();
- expect(screen.getByText('Grounding Active')).toBeDefined();
- });
-
- it('shows grounding disabled when no active sources', () => {
- mockStore.sourceLibrary = [
- { id: '1', title: 'Inactive', content: 'C', type: 'text', isActive: false, createdAt: Date.now() },
- ];
- render();
- expect(screen.getByText('Grounding Disabled')).toBeDefined();
- });
-
- it('add form has save button', () => {
- render();
- fireEvent.click(screen.getByText('Add Source'));
- expect(screen.getByText('Save to Library')).toBeDefined();
- });
-
- it('localizes validation errors in Hungarian', async () => {
- await i18n.changeLanguage('hu');
- render();
-
- fireEvent.click(screen.getByText('Forrás Hozzáadása'));
- fireEvent.click(screen.getByText('Mentés a Könyvtárba'));
-
- expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Hiba' }));
- });
-
- it('imports a URL into a preview and saves it', async () => {
- importSourceFromUrlMock.mockResolvedValue({
- title: 'Imported Article',
- content: 'Imported content with enough debate evidence.',
- wordCount: 6,
- domain: 'example.com',
- excerpt: 'Imported content',
- url: 'https://example.com/article',
- });
-
- render();
- fireEvent.click(screen.getByText('Add Source'));
- fireEvent.change(screen.getByLabelText('Import from URL'), {
- target: { value: 'https://example.com/article' },
- });
- fireEvent.click(screen.getByRole('button', { name: 'Import URL' }));
-
- expect(await screen.findByText('Import preview')).toBeDefined();
- expect(screen.getByDisplayValue('Imported Article')).toBeDefined();
-
- fireEvent.click(screen.getByText('Save imported source'));
-
- await waitFor(() => {
- expect(mockStore.addSource).toHaveBeenCalledWith({
- title: 'Imported Article',
- content: 'Imported content with enough debate evidence.',
- type: 'url',
- origin: 'https://example.com/article',
- folder: 'General',
- debateId: TEST_DEBATE_ID,
- });
- });
- });
-
- it('imports a text file into a preview', async () => {
- const file = new File(['File source content for grounding.'], 'briefing.md', { type: 'text/markdown' });
- render();
- fireEvent.click(screen.getByText('Add Source'));
-
- const input = screen.getByLabelText('Import file');
- fireEvent.change(input, { target: { files: [file] } });
-
- expect(await screen.findByText('Import preview')).toBeDefined();
- expect(screen.getByDisplayValue('briefing')).toBeDefined();
- });
-
- it('renders with container class', () => {
- const { container } = render();
- expect(container.querySelector('.container')).not.toBeNull();
- });
-
- it('renders heading element', () => {
- const { container } = render();
- const h1 = container.querySelector('h1');
- expect(h1).not.toBeNull();
- expect(h1!.textContent).toBe('Source Library');
- });
-
- it('renders without crashing with empty library', () => {
- const { container } = render();
- expect(container).toBeDefined();
- });
-
- it('shows source count badge', () => {
- mockStore.sourceLibrary = [
- { id: '1', title: 'S1', content: 'C', type: 'text', isActive: true, createdAt: Date.now() },
- ];
- render();
- expect(screen.getByText(/1 sources/)).toBeDefined();
- });
-
- it('localizes the canonical General folder and source controls in Hungarian', async () => {
- await i18n.changeLanguage('hu');
- mockStore.sourceLibrary = [
- { id: '1', title: 'Forrás', content: 'Bizonyíték', type: 'text', folder: 'General', isActive: true, createdAt: Date.now() },
- { id: '2', title: 'Fájlforrás', content: 'Bizonyíték', type: 'file', folder: 'General', isActive: true, createdAt: Date.now() },
- { id: '3', title: 'Webforrás', content: 'Bizonyíték', type: 'url', folder: 'General', isActive: true, createdAt: Date.now() },
- ];
-
- render();
-
- expect(screen.getAllByText('Általános').length).toBeGreaterThan(0);
- expect(screen.getByText('Szöveg')).toBeInTheDocument();
- expect(screen.getByText('Fájl')).toBeInTheDocument();
- expect(screen.getByText('URL')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Forrás törlése' })).toBeInTheDocument();
- expect(screen.getByRole('switch', { name: 'Forrás be- vagy kikapcsolása' })).toBeInTheDocument();
- });
-
- it('opens a source detail sheet and saves edits', async () => {
- mockStore.sourceFolders = ['General', 'Round 1'];
- mockStore.sourceLibrary = [
- {
- id: '1',
- title: 'Editable Source',
- content: 'Original evidence text',
- type: 'text',
- folder: 'General',
- isActive: true,
- createdAt: Date.now(),
- },
- ];
- render();
-
- fireEvent.click(screen.getByText('Editable Source'));
- const titleInput = await screen.findByDisplayValue('Editable Source');
- fireEvent.change(titleInput, { target: { value: 'Updated Source' } });
- fireEvent.change(screen.getByLabelText('Content'), { target: { value: 'Updated evidence text' } });
- fireEvent.click(screen.getByRole('button', { name: 'Save' }));
-
- await waitFor(() => {
- expect(mockStore.updateSource).toHaveBeenCalledWith('1', expect.objectContaining({
- title: 'Updated Source',
- content: 'Updated evidence text',
- folder: 'General',
- }));
- });
- });
-
- it('renames the selected source folder', async () => {
- mockStore.sourceFolders = ['General', 'Round 1'];
- mockStore.sourceLibrary = [
- {
- id: '1',
- title: 'Round Source',
- content: 'Evidence text',
- type: 'text',
- folder: 'Round 1',
- isActive: true,
- createdAt: Date.now(),
- },
- ];
- render();
-
- fireEvent.click(screen.getByRole('button', { name: 'Round 1' }));
- const renameInput = (await screen.findAllByLabelText('Rename folder'))[0];
- fireEvent.change(renameInput, { target: { value: 'Finals' } });
- fireEvent.click(screen.getByRole('button', { name: 'Rename folder' }));
-
- await waitFor(() => {
- expect(mockStore.renameSourceFolder).toHaveBeenCalledWith('Round 1', 'Finals');
- });
- });
-
- it('can deactivate a selected folder', async () => {
- mockStore.sourceFolders = ['General', 'Round 1'];
- mockStore.sourceLibrary = [
- {
- id: '1',
- title: 'Round Source',
- content: 'Evidence text',
- type: 'text',
- folder: 'Round 1',
- isActive: true,
- createdAt: Date.now(),
- },
- ];
- render();
-
- fireEvent.click(screen.getByRole('button', { name: 'Round 1' }));
- fireEvent.click(await screen.findByRole('button', { name: 'Deactivate' }));
-
- await waitFor(() => {
- expect(mockStore.setFolderActive).toHaveBeenCalledWith('Round 1', false);
- });
- });
-
- it('shows only the Current Debate sources', () => {
- mockStore.sourceLibrary = [
- { id: 's1', title: 'Homework study', content: 'x', type: 'text', isActive: true, createdAt: 1 },
- { id: 's2', title: 'Zoo study', content: 'y', type: 'text', isActive: true, createdAt: 2, debateId: 'other-debate' },
- ];
- render();
- expect(screen.getByTestId('library-debate')).toHaveTextContent('This House would ban homework');
- expect(screen.getAllByText('Homework study').length).toBeGreaterThan(0);
- expect(screen.queryByText('Zoo study')).toBeNull();
- });
-
- it('requires a debate before adding or importing sources', () => {
- resetDebates();
- render();
- expect(screen.getByText('Select or create a debate before adding or importing sources.')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /Add Source/ })).toBeDisabled();
- });
-});
diff --git a/src/test/pages/SettingsAdminNavigation.test.tsx b/src/test/pages/SettingsAdminNavigation.test.tsx
deleted file mode 100644
index fe4d0a4d9..000000000
--- a/src/test/pages/SettingsAdminNavigation.test.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { MemoryRouter } from 'react-router-dom';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import SettingsPage from '@/pages/SettingsPage';
-
-const mocks = vi.hoisted(() => ({
- user: { id: 'admin-a', email: 'admin@example.com' } as { id: string; email: string } | null,
- role: vi.fn(),
- env: 'production' as 'production' | 'staging',
-}));
-vi.mock('@/lib/supabase/env', () => ({ getActiveEnv: () => mocks.env, isEnvPinned: () => false, setActiveEnv: vi.fn() }));
-vi.mock('@/lib/supabase/AuthProvider', () => ({ useAuth: () => ({ user: mocks.user, signOut: vi.fn() }) }));
-vi.mock('@/lib/supabase/client', () => ({
- SUPABASE_URL: 'https://example.invalid',
- supabase: {
- auth: { getSession: async (): Promise<{ data: { session: null } }> => ({ data: { session: null } }) },
- from: () => ({ select: () => ({ eq: (_: string, id: string) => ({
- abortSignal: () => ({ maybeSingle: () => mocks.role(id) }),
- }) }) }),
- },
-}));
-vi.mock('@/lib/stores/settings-store', () => ({ useSettingsStore: () => ({
- language: 'en', outputLanguage: 'en', theme: 'dark',
- setLanguage: vi.fn(), setOutputLanguage: vi.fn(), setTheme: vi.fn(),
-}) }));
-vi.mock('@/lib/stores/session-store', () => ({ useSessionStore: () => ({ clearSession: vi.fn(), clearAllData: vi.fn() }) }));
-vi.mock('@/lib/stores/sparring-store', () => ({ useSparringStore: () => vi.fn() }));
-vi.mock('@/components/features/subscription/PricingModal', () => ({ PricingModal: (): null => null }));
-vi.mock('@/hooks/use-subscription', () => ({ useSubscription: () => ({
- tier: 'trial', loading: false, isPro: false, dailyUsed: 0, dailyLimit: 5,
-}) }));
-vi.mock('@/lib/stripe', () => ({ redirectToPortal: vi.fn() }));
-
-const clients: QueryClient[] = [];
-function mount(client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
- clients.push(client);
- const ui = () => ;
- return { ...render(ui()), client, ui };
-}
-beforeEach(() => { mocks.user = { id: 'admin-a', email: 'admin@example.com' }; mocks.env = 'production'; mocks.role.mockReset(); });
-afterEach(() => { cleanup(); clients.splice(0).forEach(client => client.clear()); });
-
-describe('Settings admin navigation', () => {
- it('shows pending state, then role-based navigation and keeps it on return', async () => {
- let resolve!: (value: { data: { role: string }; error: null }) => void;
- mocks.role.mockReturnValueOnce(new Promise(done => { resolve = done; }));
- const first = mount();
- expect(screen.getByRole('status', { name: 'Loading...' })).toBeInTheDocument();
- expect(screen.queryByRole('link', { name: 'Open Admin Panel' })).not.toBeInTheDocument();
- await act(async () => { resolve({ data: { role: 'admin' }, error: null }); });
- expect(await screen.findByRole('link', { name: 'Open Admin Panel' })).toHaveAttribute('href', '/app/admin');
- first.unmount();
- mocks.role.mockReturnValue(new Promise(() => {}));
- mount(first.client);
- expect(screen.getByRole('link', { name: 'Open Admin Panel' })).toBeInTheDocument();
- });
-
- it('does not carry an admin role to another account or signed-out state', async () => {
- mocks.role.mockResolvedValue({ data: { role: 'admin' }, error: null });
- const view = mount();
- await screen.findByRole('link', { name: 'Open Admin Panel' });
- mocks.user = { id: 'member-b', email: 'member@example.com' };
- mocks.role.mockResolvedValue({ data: { role: 'user' }, error: null });
- view.rerender(view.ui());
- expect(screen.queryByRole('link', { name: 'Open Admin Panel' })).not.toBeInTheDocument();
- await waitFor(() => expect(screen.queryByRole('status', { name: 'Loading...' })).not.toBeInTheDocument());
- mocks.user = null;
- view.rerender(view.ui());
- expect(screen.queryByRole('link', { name: 'Open Admin Panel' })).not.toBeInTheDocument();
- });
-
- it('hides cached admin navigation on a failed recheck and lets the user retry', async () => {
- mocks.role.mockResolvedValue({ data: { role: 'admin' }, error: null });
- const view = mount();
- await screen.findByRole('link', { name: 'Open Admin Panel' });
- mocks.role.mockResolvedValue({ data: null, error: new Error('offline') });
- await act(async () => { await view.client.invalidateQueries({ queryKey: ['admin-role'] }); });
- expect(await screen.findByRole('alert')).toHaveTextContent('Could not check admin access.');
- expect(screen.queryByRole('link', { name: 'Open Admin Panel' })).not.toBeInTheDocument();
- mocks.role.mockResolvedValue({ data: { role: 'user' }, error: null });
- fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
- await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
- expect(screen.queryByRole('link', { name: 'Open Admin Panel' })).not.toBeInTheDocument();
- });
-
- it('ignores a late admin response after an account change', async () => {
- let resolve!: (value: { data: { role: string }; error: null }) => void;
- mocks.role.mockReturnValueOnce(new Promise(done => { resolve = done; }));
- const view = mount();
- mocks.user = { id: 'member-b', email: 'member@example.com' };
- mocks.role.mockResolvedValue({ data: null, error: null });
- view.rerender(view.ui());
- await act(async () => { resolve({ data: { role: 'admin' }, error: null }); });
- await waitFor(() => expect(screen.queryByRole('status', { name: 'Loading...' })).not.toBeInTheDocument());
- expect(screen.queryByRole('link', { name: 'Open Admin Panel' })).not.toBeInTheDocument();
- });
-
- it('shows the environment switcher to admins only, and to everyone on staging', async () => {
- mocks.role.mockResolvedValue({ data: { role: 'user' }, error: null });
- const view = mount();
- await waitFor(() => expect(screen.queryByRole('status', { name: 'Loading...' })).not.toBeInTheDocument());
- expect(screen.queryByRole('button', { name: 'Switch to Staging' })).not.toBeInTheDocument();
- view.unmount();
-
- mocks.role.mockResolvedValue({ data: { role: 'admin' }, error: null });
- const admin = mount();
- expect(await screen.findByRole('button', { name: 'Switch to Staging' })).toBeInTheDocument();
- admin.unmount();
-
- mocks.env = 'staging';
- mocks.role.mockResolvedValue({ data: null, error: null });
- mount();
- expect(screen.getByRole('button', { name: 'Switch to Production' })).toBeInTheDocument();
- });
-});
diff --git a/src/test/pages/SettingsPage.test.tsx b/src/test/pages/SettingsPage.test.tsx
deleted file mode 100644
index 2c4ecd945..000000000
--- a/src/test/pages/SettingsPage.test.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { MemoryRouter } from 'react-router-dom';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import i18n from '@/i18n';
-import SettingsPage from '@/pages/SettingsPage';
-
-const mocks = vi.hoisted(() => ({
- subscription: {
- tier: 'trial',
- loading: false,
- isPro: false,
- dailyUsed: 4,
- dailyLimit: 5,
- trialEndsAt: '2026-10-01T00:00:00.000Z',
- hasStripeBilling: false,
- refresh: vi.fn(),
- },
- redirectToPortal: vi.fn(),
- user: null as { id: string; email: string } | null,
- signOut: vi.fn(),
- deleteAccount: vi.fn(),
- downloadAccountData: vi.fn(),
-}));
-
-vi.mock('@/lib/supabase/AuthProvider', () => ({
- useAuth: () => ({ user: mocks.user, signOut: mocks.signOut }),
-}));
-vi.mock('@/lib/supabase/client', () => ({ supabase: { from: vi.fn() } }));
-vi.mock('@/lib/stores/settings-store', () => ({ useSettingsStore: () => ({
- language: 'en', outputLanguage: 'en', theme: 'dark',
- setLanguage: vi.fn(), setOutputLanguage: vi.fn(), setTheme: vi.fn(),
-}) }));
-vi.mock('@/lib/stores/session-store', () => ({ useSessionStore: () => ({ clearSession: vi.fn(), clearAllData: vi.fn() }) }));
-vi.mock('@/lib/stores/sparring-store', () => ({ useSparringStore: () => vi.fn() }));
-vi.mock('@/components/features/subscription/PricingModal', () => ({
- PricingModal: ({ open }: { open: boolean }) => open ? Pricing open
: null,
-}));
-vi.mock('@/hooks/use-subscription', () => ({ useSubscription: () => mocks.subscription }));
-vi.mock('@/lib/stripe', () => ({ redirectToPortal: mocks.redirectToPortal }));
-vi.mock('@/lib/account', () => ({ deleteAccount: mocks.deleteAccount, downloadAccountData: mocks.downloadAccountData }));
-
-function renderSettings() {
- const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- return render(
-
-
- ,
- );
-}
-
-beforeEach(async () => {
- await i18n.changeLanguage('en');
- Object.assign(mocks.subscription, { tier: 'trial', loading: false, hasStripeBilling: false });
- mocks.redirectToPortal.mockReset().mockResolvedValue(undefined);
- mocks.user = null;
- mocks.signOut.mockReset().mockResolvedValue(undefined);
- mocks.deleteAccount.mockReset().mockResolvedValue(undefined);
- mocks.downloadAccountData.mockReset().mockResolvedValue(undefined);
-});
-
-afterEach(() => cleanup());
-
-describe('Settings subscription card', () => {
- it.each([
- ['trial', false, 'Free Trial', 'AI access is active. Usage limits may apply.', 'Upgrade to Pro'],
- ['pro', true, 'Pro', 'AI access is active. Usage limits may apply.', 'Manage Subscription'],
- ['expired', false, 'Expired', 'Your access has expired. Upgrade to continue using AI tools.', 'Upgrade to Pro'],
- ])('shows non-numeric access status for %s users', (tier, hasStripeBilling, badge, status, action) => {
- Object.assign(mocks.subscription, { tier, hasStripeBilling });
-
- renderSettings();
-
- expect(screen.getByText(badge)).toBeInTheDocument();
- expect(screen.getByText(status)).toBeInTheDocument();
- expect(screen.getByRole('button', { name: action })).toBeInTheDocument();
- expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
- expect(screen.queryByText('4 / 5 uses')).not.toBeInTheDocument();
- expect(screen.queryByText(/Resets in/)).not.toBeInTheDocument();
- if (tier === 'trial') expect(screen.getByText('Ends 01/10/2026')).toBeInTheDocument();
- });
-
- it('does not claim access is active while the subscription is loading', () => {
- Object.assign(mocks.subscription, { loading: true });
-
- renderSettings();
-
- expect(screen.getByText('Loading...')).toBeInTheDocument();
- expect(screen.queryByText('AI access is active. Usage limits may apply.')).not.toBeInTheDocument();
- expect(screen.queryByText('Free Trial')).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Upgrade to Pro' })).not.toBeInTheDocument();
- });
-
- it('opens the upgrade flow', () => {
- Object.assign(mocks.subscription, { tier: 'expired' });
- renderSettings();
-
- fireEvent.click(screen.getByRole('button', { name: 'Upgrade to Pro' }));
-
- expect(screen.getByText('Pricing open')).toBeInTheDocument();
- });
-
- it('opens billing management for Stripe-backed Pro users', async () => {
- Object.assign(mocks.subscription, { tier: 'pro', hasStripeBilling: true });
- renderSettings();
-
- fireEvent.click(screen.getByRole('button', { name: 'Manage Subscription' }));
-
- await waitFor(() => expect(mocks.redirectToPortal).toHaveBeenCalledOnce());
- });
-});
-
-describe('Settings account controls', () => {
- const student = { id: '11111111-1111-4111-8111-111111111111', email: 'student@example.test' };
-
- function openDeleteAccount() {
- mocks.user = student;
- renderSettings();
- fireEvent.click(screen.getByRole('button', { name: 'Delete account' }));
- const confirm = screen.getAllByRole('button', { name: 'Delete account' }).at(-1)!;
- return { confirm, input: screen.getByLabelText('Type your email to confirm') };
- }
-
- it('downloads the account data', async () => {
- mocks.user = student;
- renderSettings();
-
- fireEvent.click(screen.getByRole('button', { name: 'Download my data' }));
-
- await waitFor(() => expect(mocks.downloadAccountData).toHaveBeenCalledTimes(1));
- });
-
- it('deletes the account only after the email is typed exactly', async () => {
- const { confirm, input } = openDeleteAccount();
- expect(confirm).toBeDisabled();
-
- fireEvent.change(input, { target: { value: 'STUDENT@example.test' } });
- expect(confirm).toBeDisabled();
-
- fireEvent.change(input, { target: { value: student.email } });
- fireEvent.click(confirm);
-
- await waitFor(() => expect(mocks.signOut).toHaveBeenCalledTimes(1));
- expect(mocks.deleteAccount).toHaveBeenCalledWith(student.email);
- });
-
- it('keeps the user signed in when deletion fails', async () => {
- mocks.deleteAccount.mockRejectedValue(new Error('Could not cancel your subscription'));
- const { confirm, input } = openDeleteAccount();
-
- fireEvent.change(input, { target: { value: student.email } });
- fireEvent.click(confirm);
-
- await waitFor(() => expect(confirm).toBeEnabled());
- expect(mocks.deleteAccount).toHaveBeenCalledTimes(1);
- expect(mocks.signOut).not.toHaveBeenCalled();
- expect(screen.getByRole('dialog', { name: 'Delete your account?' })).toBeInTheDocument();
- });
-});
diff --git a/src/test/pages/SourcesPage.test.tsx b/src/test/pages/SourcesPage.test.tsx
deleted file mode 100644
index b73121a79..000000000
--- a/src/test/pages/SourcesPage.test.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import SourcesPage from '@/pages/SourcesPage';
-import i18n from '@/i18n';
-
-test('does not present a stale consultation date as current source metadata', () => {
- render();
-
- expect(screen.queryByText(/Consulta: 8 de marzo de 2026/i)).not.toBeInTheDocument();
- expect(screen.getByRole('link', { name: /cambridge\.org/i })).toBeInTheDocument();
-});
-
-test('renders Hungarian page chrome without translating publication titles', async () => {
- await i18n.changeLanguage('hu');
- render();
-
- expect(screen.getByRole('heading', { level: 1, name: 'Források' })).toBeInTheDocument();
- expect(screen.getByRole('heading', { level: 2, name: 'Hivatkozások' })).toBeInTheDocument();
- expect(screen.getByText(/az alábbi forrásokat használtuk fel/i)).toBeInTheDocument();
- expect(screen.getByText('Enhancing critical thinking skills with AI-assisted technology.')).toBeInTheDocument();
-
- await i18n.changeLanguage('en');
-});
diff --git a/src/test/pages/SparringPage.test.tsx b/src/test/pages/SparringPage.test.tsx
deleted file mode 100644
index 6f16481cf..000000000
--- a/src/test/pages/SparringPage.test.tsx
+++ /dev/null
@@ -1,403 +0,0 @@
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { MemoryRouter } from 'react-router-dom';
-import type { ReactNode } from 'react';
-import type { ProviderConfig } from '@/lib/ai/providers/types';
-import type { SparringScorecard, SparringSpeech } from '@/lib/ai/schemas/sparring';
-
-const { generateMock, judgeMock } = vi.hoisted(() => ({
- generateMock: vi.fn(),
- judgeMock: vi.fn(),
-}));
-
-vi.mock('@/lib/supabase/client', () => ({
- supabase: {
- auth: { getUser: vi.fn(async () => ({ data: { user: null } })) },
- },
-}));
-
-vi.mock('@/lib/ai/client', () => ({
- generateSparringTurn: generateMock,
- judgeSparringSession: judgeMock,
-}));
-
-const CONFIG: ProviderConfig = { provider: 'mock', model: 'local', temperature: 0.4 };
-
-const SETTINGS = {
- outputLanguage: 'en' as const,
- getFeatureConfig: vi.fn((): ProviderConfig => CONFIG),
-};
-
-vi.mock('@/lib/stores/settings-store', () => ({
- useSettingsStore: (selector: (state: typeof SETTINGS) => unknown) => selector(SETTINGS),
-}));
-
-vi.mock('@/components/ModelSelector', () => ({
- ModelSelector: () => Local test model
,
-}));
-
-vi.mock('recharts', () => ({
- PolarAngleAxis: (): null => null,
- PolarGrid: (): null => null,
- PolarRadiusAxis: (): null => null,
- Radar: (): null => null,
- RadarChart: ({ children }: { children: ReactNode }) => {children}
,
- ResponsiveContainer: ({ children }: { children: ReactNode }) => {children}
,
- Tooltip: (): null => null,
-}));
-
-import SparringPage from '@/pages/SparringPage';
-import { useSparringStore } from '@/lib/stores/sparring-store';
-import { enableOneOffMode } from '@/test/helpers/debate';
-
-const SCORECARD: SparringScorecard = {
- userScore: 78,
- aiScore: 74,
- categories: [
- { name: 'Logic', userScore: 20, aiScore: 18, feedback: 'The causal chain was clear.' },
- { name: 'Evidence', userScore: 18, aiScore: 18, feedback: 'The factual warrant needed more support.' },
- { name: 'Delivery', userScore: 19, aiScore: 19, feedback: 'Judged from organization, not vocal performance.' },
- { name: 'Strategy', userScore: 21, aiScore: 19, feedback: 'The user prioritized the central clash.' },
- ],
- overallFeedback: 'The user won a narrow edge by weighing the main mechanism.',
- keyMoments: ['The rebuttal answered the opponent’s strongest objection.'],
- improvementAreas: ['Compare the alternative policy explicitly.'],
- winner: 'user',
- verdict: 'The user had the edge.',
-};
-
-const OPPONENT_RESPONSE = {
- action: 'respond' as const,
- speech: {
- content: 'A narrower policy could address the same concern with fewer tradeoffs.',
- strategy: 'Test the causal link and compare the alternatives.',
- targetedPoints: ['Explain why the alternative cannot solve the harm.'],
- },
-};
-
-function speech(overrides: Partial = {}): SparringSpeech {
- return {
- id: 'speech-1',
- turnId: 'standard-1',
- speaker: 'user',
- side: 'pro',
- role: 'Pro Introductor',
- content: 'Universal access removes the direct price barrier to participation.',
- createdAt: 1_000,
- timeLimitSeconds: 240,
- timeUsedSeconds: 45,
- ...overrides,
- };
-}
-
-function renderPage() {
- return render(
-
-
- ,
- );
-}
-
-describe('SparringPage', () => {
- beforeEach(() => {
- enableOneOffMode();
- vi.clearAllMocks();
- vi.stubGlobal('scrollTo', vi.fn());
- Object.defineProperty(navigator, 'onLine', { configurable: true, value: true });
- useSparringStore.setState({ currentSession: null, savedSessions: [], hydrated: false });
- generateMock.mockResolvedValue(OPPONENT_RESPONSE);
- judgeMock.mockResolvedValue({ action: 'score', scorecard: SCORECARD });
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
- it('renders setup, validates the motion, and starts a chosen round', async () => {
- renderPage();
-
- expect(screen.getByRole('heading', { name: 'Sparring' })).toBeInTheDocument();
- expect(screen.getByRole('combobox', { name: 'Round format' })).toHaveTextContent('School 4-6-6-3 · 8 speeches');
- expect(screen.getByText('Pro Introductor')).toBeInTheDocument();
- expect(screen.getByText('Pro Conclusor')).toBeInTheDocument();
- const motion = screen.getByLabelText('Motion');
- expect(screen.getByRole('button', { name: 'Start round' })).toBeDisabled();
-
- fireEvent.change(motion, { target: { value: 'Short' } });
- expect(screen.getByRole('alert')).toHaveTextContent('Add a little more detail');
- expect(screen.getByRole('button', { name: 'Start round' })).toBeDisabled();
-
- fireEvent.change(motion, { target: { value: 'This House would make public transport free.' } });
- fireEvent.click(screen.getByRole('button', { name: 'Con' }));
- fireEvent.click(screen.getByRole('button', { name: 'Start round' }));
-
- expect(useSparringStore.getState().currentSession).toMatchObject({
- motion: 'This House would make public transport free.',
- userSide: 'con',
- phase: 'round',
- });
- await waitFor(() => expect(screen.getByText('Round in progress')).toBeInTheDocument());
- expect(screen.getByText('Round in progress')).toBeInTheDocument();
- expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' });
- });
-
- it('handles an AI-first round and advances to the user speech', async () => {
- const session = useSparringStore.getState().startSession({
- motion: 'This House would make public transport free.',
- userSide: 'con',
- format: 'standard',
- difficulty: 'medium',
- });
- renderPage();
-
- await waitFor(() => expect(generateMock).toHaveBeenCalledTimes(1));
- await waitFor(() => expect(useSparringStore.getState().currentSession?.speeches).toHaveLength(1));
- expect(generateMock.mock.calls[0][0]).toMatchObject({
- action: 'respond',
- sessionId: session.id,
- currentTurn: { id: 'standard-1', side: 'pro' },
- });
- expect(screen.getByRole('heading', { name: 'Your speech' })).toBeInTheDocument();
- expect(screen.getByText(OPPONENT_RESPONSE.speech.content)).toBeInTheDocument();
- });
-
- it('submits a user-first speech, preserves it in the transcript, and requests the response', async () => {
- renderPage();
- fireEvent.change(screen.getByLabelText('Motion'), { target: { value: 'This House would make public transport free.' } });
- fireEvent.click(screen.getByRole('button', { name: 'Start round' }));
-
- expect(screen.getByText(/Open with an exordio, define the key terms/)).toBeInTheDocument();
- const draft = screen.getByRole('textbox', { name: /Draft for Pro Introductor/i });
- fireEvent.change(draft, { target: { value: 'Universal access removes the price barrier.' } });
- fireEvent.click(screen.getByRole('button', { name: 'Submit speech' }));
-
- await waitFor(() => expect(generateMock).toHaveBeenCalledTimes(1));
- expect(useSparringStore.getState().currentSession?.speeches[0]).toMatchObject({
- speaker: 'user',
- content: 'Universal access removes the price barrier.',
- });
- expect(generateMock.mock.calls[0][0]).toMatchObject({
- action: 'respond',
- speeches: [expect.objectContaining({ speaker: 'user' })],
- currentTurn: { id: 'standard-2', side: 'con' },
- });
- expect(screen.getByText('Universal access removes the price barrier.')).toBeInTheDocument();
- });
-
- it('blocks a speech after the turn deadline', () => {
- const session = useSparringStore.getState().startSession({
- motion: 'This House would make public transport free.',
- userSide: 'pro',
- format: 'standard',
- difficulty: 'medium',
- });
- useSparringStore.setState({
- currentSession: {
- ...session,
- draft: 'A speech submitted too late.',
- timerRemainingSeconds: 0,
- timerRunning: false,
- timerDeadline: null,
- },
- });
-
- renderPage();
- expect(screen.getByRole('button', { name: 'Submit speech' })).toBeDisabled();
- expect(screen.getByRole('alert')).toHaveTextContent('This speaking time has ended');
- });
-
- it('offers a retry after an opponent failure without duplicating the user speech', async () => {
- generateMock
- .mockRejectedValueOnce(new Error('Temporary provider failure'))
- .mockResolvedValueOnce(OPPONENT_RESPONSE);
-
- renderPage();
- fireEvent.change(screen.getByLabelText('Motion'), { target: { value: 'This House would make public transport free.' } });
- fireEvent.click(screen.getByRole('button', { name: 'Start round' }));
- fireEvent.change(screen.getByRole('textbox', { name: /Draft for Pro Introductor/i }), { target: { value: 'Universal access removes the price barrier.' } });
- fireEvent.click(screen.getByRole('button', { name: 'Submit speech' }));
-
- await waitFor(() => expect(screen.getByRole('button', { name: 'Retry opponent turn' })).toBeInTheDocument());
- fireEvent.click(screen.getByRole('button', { name: 'Retry opponent turn' }));
- await waitFor(() => expect(screen.getByText(OPPONENT_RESPONSE.speech.content)).toBeInTheDocument());
-
- expect(generateMock).toHaveBeenCalledTimes(2);
- expect(screen.getAllByText('Universal access removes the price barrier.')).toHaveLength(1);
- expect(useSparringStore.getState().currentSession?.speeches).toHaveLength(2);
- });
-
- it('enters judging and renders the completed scorecard', async () => {
- const session = useSparringStore.getState().startSession({
- motion: 'This House would make public transport free.',
- userSide: 'pro',
- format: 'standard',
- difficulty: 'hard',
- });
- useSparringStore.setState({
- currentSession: {
- ...session,
- speeches: [speech(), speech({ id: 'speech-2', turnId: 'standard-2', speaker: 'ai', side: 'con', role: 'Con Introductor', content: 'Targeted support costs less.', createdAt: 2_000 })],
- currentTurnIndex: session.turns.length,
- phase: 'judging',
- draft: '',
- timerRemainingSeconds: 0,
- timerRunning: false,
- timerDeadline: null,
- },
- });
-
- renderPage();
- expect(screen.getByText('Judge is weighing the round')).toBeInTheDocument();
- await waitFor(() => expect(judgeMock).toHaveBeenCalledTimes(1));
- await waitFor(() => expect(useSparringStore.getState().currentSession?.phase).toBe('complete'));
-
- expect(screen.getByRole('heading', { name: 'Here is what happened in the room.' })).toBeInTheDocument();
- expect(screen.getByText('You won the ballot')).toBeInTheDocument();
- expect(screen.getByText('The user had the edge.')).toBeInTheDocument();
- expect(screen.getByText('78')).toBeInTheDocument();
- expect(screen.getByText('Compare the alternative policy explicitly.')).toBeInTheDocument();
- });
-
- it('renders safely at a narrow viewport with reduced motion enabled', () => {
- vi.stubGlobal('matchMedia', vi.fn().mockImplementation((query: string) => ({
- matches: query === '(prefers-reduced-motion: reduce)',
- media: query,
- addEventListener: (): void => undefined,
- removeEventListener: (): void => undefined,
- })));
-
- renderPage();
- expect(screen.getByRole('heading', { name: 'Sparring' })).toBeInTheDocument();
- expect(document.body.textContent).toContain('Start round');
-
- act(() => {
- window.dispatchEvent(new Event('offline'));
- });
- expect(screen.getByText(/Reconnect to start a round/)).toBeInTheDocument();
- });
-
- it('gates setup and draft submission while offline, then resumes online', async () => {
- renderPage();
- fireEvent.change(screen.getByLabelText('Motion'), { target: { value: 'This House would make public transport free.' } });
-
- act(() => {
- window.dispatchEvent(new Event('offline'));
- });
- await waitFor(() => expect(screen.getByRole('button', { name: 'Start round' })).toBeDisabled());
-
- act(() => {
- window.dispatchEvent(new Event('online'));
- });
- await waitFor(() => expect(screen.getByRole('button', { name: 'Start round' })).not.toBeDisabled());
- fireEvent.click(screen.getByRole('button', { name: 'Start round' }));
-
- const draft = screen.getByRole('textbox', { name: /Draft for Pro Introductor/i });
- fireEvent.change(draft, { target: { value: 'Universal access removes the price barrier.' } });
- act(() => {
- window.dispatchEvent(new Event('offline'));
- });
- await waitFor(() => expect(screen.getByRole('button', { name: 'Submit speech' })).toBeDisabled());
- fireEvent.keyDown(draft, { key: 'Enter', code: 'Enter', ctrlKey: true });
- expect(useSparringStore.getState().currentSession?.speeches).toHaveLength(0);
-
- act(() => {
- window.dispatchEvent(new Event('online'));
- });
- await waitFor(() => expect(screen.getByRole('button', { name: 'Submit speech' })).not.toBeDisabled());
- fireEvent.keyDown(draft, { key: 'Enter', code: 'Enter', ctrlKey: true });
- await waitFor(() => expect(useSparringStore.getState().currentSession?.speeches).toHaveLength(1));
- });
-
- it('does not request an AI-first turn until the device is online', async () => {
- Object.defineProperty(navigator, 'onLine', { configurable: true, value: false });
- const session = useSparringStore.getState().startSession({
- motion: 'This House would make public transport free.',
- userSide: 'con',
- format: 'standard',
- difficulty: 'medium',
- });
- renderPage();
- await waitFor(() => expect(screen.getByText('Offline. Draft saved locally.')).toBeInTheDocument());
- expect(generateMock).not.toHaveBeenCalled();
-
- Object.defineProperty(navigator, 'onLine', { configurable: true, value: true });
- act(() => {
- window.dispatchEvent(new Event('online'));
- });
- await waitFor(() => expect(generateMock).toHaveBeenCalledTimes(1));
- expect(generateMock.mock.calls[0][0]).toMatchObject({ sessionId: session.id, action: 'respond' });
- });
-
- it('does not duplicate an in-flight opponent call across connectivity events', async () => {
- let resolveOpponent!: (value: typeof OPPONENT_RESPONSE) => void;
- generateMock.mockImplementationOnce(() => new Promise((resolve) => {
- resolveOpponent = resolve;
- }));
- useSparringStore.getState().startSession({
- motion: 'This House would make public transport free.',
- userSide: 'con',
- format: 'standard',
- difficulty: 'medium',
- });
- renderPage();
-
- await waitFor(() => expect(generateMock).toHaveBeenCalledTimes(1));
- act(() => window.dispatchEvent(new Event('offline')));
- act(() => window.dispatchEvent(new Event('online')));
- expect(generateMock).toHaveBeenCalledTimes(1);
-
- await act(async () => resolveOpponent(OPPONENT_RESPONSE));
- await waitFor(() => expect(useSparringStore.getState().currentSession?.speeches).toHaveLength(1));
- expect(generateMock).toHaveBeenCalledTimes(1);
- });
-
- it('commits an opponent response that completes while the page is unmounted', async () => {
- let resolveOpponent!: (value: typeof OPPONENT_RESPONSE) => void;
- generateMock.mockImplementationOnce(() => new Promise((resolve) => {
- resolveOpponent = resolve;
- }));
- useSparringStore.getState().startSession({
- motion: 'This House would make public transport free.',
- userSide: 'con',
- format: 'standard',
- difficulty: 'medium',
- });
- const view = renderPage();
- await waitFor(() => expect(generateMock).toHaveBeenCalledTimes(1));
- view.unmount();
-
- await act(async () => resolveOpponent(OPPONENT_RESPONSE));
- await waitFor(() => expect(useSparringStore.getState().currentSession?.speeches).toHaveLength(1));
- renderPage();
-
- expect(screen.getByRole('heading', { name: 'Your speech' })).toBeInTheDocument();
- expect(generateMock).toHaveBeenCalledTimes(1);
- });
-
- it('does not commit an old response into a replacement session', async () => {
- let resolveOpponent!: (value: typeof OPPONENT_RESPONSE) => void;
- generateMock.mockImplementationOnce(() => new Promise((resolve) => {
- resolveOpponent = resolve;
- }));
- const oldSession = useSparringStore.getState().startSession({
- motion: 'This House would make public transport free.',
- userSide: 'con',
- format: 'standard',
- difficulty: 'medium',
- });
- const view = renderPage();
- await waitFor(() => expect(generateMock).toHaveBeenCalledTimes(1));
- view.unmount();
- const replacement = useSparringStore.getState().startSession({
- motion: 'This House would ban private cars from city centres.',
- userSide: 'con',
- format: 'standard',
- difficulty: 'hard',
- });
-
- await act(async () => resolveOpponent(OPPONENT_RESPONSE));
-
- expect(oldSession.id).not.toBe(replacement.id);
- expect(useSparringStore.getState().currentSession).toMatchObject({ id: replacement.id, speeches: [] });
- });
-});
diff --git a/src/test/pages/TimerPage.test.tsx b/src/test/pages/TimerPage.test.tsx
deleted file mode 100644
index 65a227888..000000000
--- a/src/test/pages/TimerPage.test.tsx
+++ /dev/null
@@ -1,170 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen, fireEvent, act } from '@testing-library/react';
-import TimerPage from '@/pages/TimerPage';
-
-// Mock AudioContext
-const mockAudioContext = {
- createOscillator: () => ({
- connect: vi.fn(),
- start: vi.fn(),
- stop: vi.fn(),
- frequency: { setValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn() },
- }),
- createGain: () => ({
- connect: vi.fn(),
- gain: { setValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn() },
- }),
- currentTime: 0,
- destination: {},
- state: 'running',
- resume: vi.fn(),
-};
-
-// Mock UI components
-vi.mock('@/components/ui/select', () => ({
- Select: ({ onValueChange, children, value }: { onValueChange: (val: string) => void; children: React.ReactNode; value: string }) => (
-
-
- {children}
-
- ),
- SelectTrigger: ({ children }: { children: React.ReactNode }) => {children}
,
- SelectValue: () => Select Value,
- SelectContent: ({ children }: { children: React.ReactNode }) => {children}
,
- SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => {children}
,
-}));
-
-describe('TimerPage', () => {
- beforeEach(() => {
- // Do NOT set fake timers globaly. Only for specific tests.
- window.AudioContext = vi.fn(function MockAudioContext() {
- return mockAudioContext;
- }) as unknown as typeof AudioContext;
- global.ResizeObserver = vi.fn().mockImplementation(() => ({
- observe: vi.fn(),
- unobserve: vi.fn(),
- disconnect: vi.fn(),
- }));
- });
-
- afterEach(() => {
- vi.useRealTimers();
- vi.restoreAllMocks();
- });
-
- it('renders correctly with default standard format', () => {
- render();
- expect(screen.getByText('Debate Timer')).toBeInTheDocument();
- expect(screen.getByText('Introductor (4m)')).toBeInTheDocument();
- expect(screen.getByText('4:00')).toBeInTheDocument();
- });
-
- it('switches formats correctly', async () => {
- render();
-
- const trigger = screen.getByTestId('select-trigger');
-
- await act(async () => {
- fireEvent.click(trigger);
- });
-
- // Wait for both the buttons/label AND the time to update
- // The time update happens in a useEffect so it might be one tick later
- const newLabel = await screen.findByText('Aff Constructive (6m)');
- const newTime = await screen.findByText('6:00');
-
- expect(newLabel).toBeInTheDocument();
- expect(newTime).toBeInTheDocument();
- });
-
- it('starts and stops timer', () => {
- vi.useFakeTimers();
- render();
-
- const startButton = screen.getByText('Start');
- fireEvent.click(startButton);
-
- expect(screen.getByText('Pause')).toBeInTheDocument();
- expect(screen.getByText('SPEAKING')).toBeInTheDocument();
-
- act(() => {
- vi.advanceTimersByTime(1000);
- });
-
- expect(screen.getByText('3:59')).toBeInTheDocument();
-
- fireEvent.click(screen.getByText('Pause'));
- expect(screen.getByText('PAUSED')).toBeInTheDocument();
- });
-
- it('catches up to elapsed wall-clock time after a delayed tick', () => {
- vi.useFakeTimers();
- vi.setSystemTime(0);
- render();
- fireEvent.click(screen.getByText('Start'));
-
- vi.setSystemTime(4_000);
- act(() => {
- vi.advanceTimersByTime(1000);
- });
-
- expect(screen.getByText('3:55')).toBeInTheDocument();
- });
-
- it('reuses one audio context across countdown signals', () => {
- vi.useFakeTimers();
- render();
- fireEvent.click(screen.getByText('Start'));
-
- const totalSeconds = 4 * 60;
- act(() => {
- vi.advanceTimersByTime((totalSeconds - 61) * 1000);
- });
-
- act(() => {
- vi.advanceTimersByTime(2000);
- });
-
- expect(screen.getByText('0:59')).toBeInTheDocument();
- expect(window.AudioContext).toHaveBeenCalledTimes(1);
-
- act(() => {
- vi.advanceTimersByTime(59 * 1000);
- });
-
- expect(screen.getByText('TIME UP')).toBeInTheDocument();
- expect(window.AudioContext).toHaveBeenCalledTimes(1);
- });
-
- it('handles keyboard shortcuts', async () => {
- // Use real timers for interaction test to avoid conflict
- render();
-
- // Space to start
- await act(async () => {
- fireEvent.keyDown(window, { code: 'Space' });
- });
- expect(screen.getByText('SPEAKING')).toBeInTheDocument();
-
- // Space to pause
- await act(async () => {
- fireEvent.keyDown(window, { code: 'Space' });
- });
- expect(screen.getByText('PAUSED')).toBeInTheDocument();
-
- // Arrow Right to next speech
- await act(async () => {
- fireEvent.keyDown(window, { code: 'ArrowRight' });
- });
-
- // WaitFor allows some leniency for state updates
- const refutationLabel = await screen.findByText('Refutation 1 (6m)');
- expect(refutationLabel).toHaveClass('bg-orange-500');
- expect(screen.getByText('6:00')).toBeInTheDocument();
- });
-});
diff --git a/src/test/pages/pages.test.tsx b/src/test/pages/pages.test.tsx
deleted file mode 100644
index 875851951..000000000
--- a/src/test/pages/pages.test.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-// src/test/pages/pages.test.tsx
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { describe, it, expect, vi } from 'vitest';
-import { fireEvent, render, screen } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
-import LandingPage from '@/pages/LandingPage';
-import HomePage from '@/pages/HomePage';
-import NotFound from '@/pages/NotFound';
-
-// Mock framer-motion to avoid animation issues in tests
-vi.mock('framer-motion', () => ({
- motion: {
- div: ({ children, ...props }: React.PropsWithChildren>) => {children}
,
- section: ({ children, ...props }: React.PropsWithChildren>) => ,
- span: ({ children, ...props }: React.PropsWithChildren>) => {children},
- h1: ({ children, ...props }: React.PropsWithChildren>) => {children}
,
- h2: ({ children, ...props }: React.PropsWithChildren>) => {children}
,
- p: ({ children, ...props }: React.PropsWithChildren>) => {children}
,
- img: (props: Record) =>
,
- a: ({ children, ...props }: React.PropsWithChildren>) => {children},
- li: ({ children, ...props }: React.PropsWithChildren>) => {children},
- ul: ({ children, ...props }: React.PropsWithChildren>) => ,
- nav: ({ children, ...props }: React.PropsWithChildren>) => ,
- footer: ({ children, ...props }: React.PropsWithChildren>) => ,
- header: ({ children, ...props }: React.PropsWithChildren>) => ,
- button: ({ children, ...props }: React.PropsWithChildren>) => ,
- },
- AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}>,
- useAnimation: () => ({}),
- useInView: () => true,
- useReducedMotion: () => false,
-}));
-
-// Mock useAuth
-vi.mock('@/lib/auth', () => ({
- useAuth: () => ({
- user: null as any,
- loading: false,
- signInWithGoogle: vi.fn(),
- signOut: vi.fn(),
- }),
-}));
-
-const renderWithRouter = (component: React.ReactNode, route = '/') => {
- return render(
-
- {component}
-
- );
-};
-
-describe('Pages', () => {
- describe('LandingPage', () => {
- it('contains hero section', () => {
- renderWithRouter();
- expect(screen.getByRole('heading', { level: 1 })).toBeDefined();
- });
-
- it('contains navigation', () => {
- renderWithRouter();
- expect(screen.getAllByText(/DebateLab/i).length).toBeGreaterThan(0);
- });
-
- it('contains features section', () => {
- renderWithRouter();
- expect(screen.getAllByRole('heading').length).toBeGreaterThan(1);
- });
-
- it('shows the current eleven-tool inventory and interactive preview', () => {
- renderWithRouter();
-
- expect(screen.getByRole('heading', { name: 'Crossfire' })).toBeInTheDocument();
- expect(screen.getByRole('heading', { name: 'Sparring' })).toBeInTheDocument();
- expect(screen.getByText('11', { exact: true })).toBeInTheDocument();
- const generateMode = screen.getByRole('button', { name: 'Generate' });
- const crossfireMode = screen.getByRole('button', { name: 'Crossfire' });
- const sparringMode = screen.getByRole('button', { name: 'Sparring' });
- expect(generateMode).toHaveAttribute('aria-pressed', 'true');
- expect(crossfireMode).toHaveAttribute('aria-pressed', 'false');
-
- fireEvent.click(crossfireMode);
- expect(screen.getByText('Crossfire exchange')).toBeInTheDocument();
- expect(crossfireMode).toHaveAttribute('aria-pressed', 'true');
- expect(generateMode).toHaveAttribute('aria-pressed', 'false');
-
- fireEvent.click(sparringMode);
- expect(screen.getByText('Round plan')).toBeInTheDocument();
-
- expect(screen.getByRole('textbox', { name: 'Motion' })).toHaveValue('Should schools ban phones?');
- expect(screen.getByRole('textbox', { name: 'Motion' })).toHaveAttribute('readonly');
- });
- });
-
- describe('HomePage', () => {
- it('renders the mode selector heading', () => {
- renderWithRouter(, '/app');
- expect(screen.getByRole('heading', { name: 'DebateLab' })).toBeInTheDocument();
- });
-
- it('shows key mode cards', () => {
- renderWithRouter(, '/app');
- expect(screen.getByText('Generate')).toBeInTheDocument();
- expect(screen.getByText('Attack')).toBeInTheDocument();
- expect(screen.getByText('Brainstorm')).toBeInTheDocument();
- expect(screen.getByText('Factoids')).toBeInTheDocument();
- expect(screen.getByText('Ask')).toBeInTheDocument();
- });
-
- it('adds stable tour targets to mode cards', () => {
- const { container } = renderWithRouter(, '/app');
- expect(container.querySelector('[data-tour="mode-generate"]')).toBeInTheDocument();
- expect(container.querySelector('[data-tour="mode-flowchart"]')).toBeInTheDocument();
- });
-
- it('groups the homepage tools without the old subtitle', () => {
- renderWithRouter(, '/app');
- expect(screen.getByRole('heading', { name: 'Prepare' })).toBeInTheDocument();
- expect(screen.getByRole('heading', { name: 'Practice' })).toBeInTheDocument();
- expect(screen.getByRole('heading', { name: 'Utilities' })).toBeInTheDocument();
- expect(screen.queryByText('Choose a mode to start.')).not.toBeInTheDocument();
- expect(screen.getByRole('link', { name: /Generate/ })).toHaveAttribute('href', '/app/generate');
- expect(screen.getByRole('link', { name: /History/ })).toHaveAttribute('href', '/app/history');
- });
- });
-
- describe('NotFound', () => {
- it('renders 404 message', () => {
- renderWithRouter();
- expect(screen.getByText('404')).toBeDefined();
- });
- });
-});
diff --git a/src/test/pdf-export-utils.test.ts b/src/test/pdf-export-utils.test.ts
deleted file mode 100644
index bc1bc43c2..000000000
--- a/src/test/pdf-export-utils.test.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import i18n from '@/i18n';
-import type { AttackOutput } from '@/lib/ai/schemas/attack';
-import type { GenerateOutput } from '@/lib/ai/schemas/generate';
-
-const pdfMocks = vi.hoisted(() => {
- const docs: Array> = [];
- const autoTableCalls: Array> = [];
- return { docs, autoTableCalls };
-});
-
-vi.mock('jspdf', () => ({
- jsPDF: class {
- constructor() {
- let page = 1;
- const accentBars: Array<{ page: number; y: number; height: number }> = [];
- const doc = {
- internal: { pageSize: { height: 297, width: 210 } },
- accentBars,
- setFillColor: vi.fn(),
- rect: vi.fn(),
- setTextColor: vi.fn(),
- setFontSize: vi.fn(),
- setFont: vi.fn(),
- text: vi.fn(),
- setDrawColor: vi.fn(),
- setLineWidth: vi.fn(),
- line: vi.fn(),
- getTextWidth: vi.fn(() => 10),
- splitTextToSize: vi.fn((text: string) => text.startsWith('LONG') ? Array(60).fill('line') : [text]),
- addPage: vi.fn(() => { page += 1; }),
- roundedRect: vi.fn((_x: number, y: number, _width: number, height: number) => {
- accentBars.push({ page, y, height });
- }),
- getNumberOfPages: vi.fn(() => page),
- setPage: vi.fn(),
- save: vi.fn(),
- };
- pdfMocks.docs.push(doc);
- return doc;
- }
- },
-}));
-
-vi.mock('jspdf-autotable', () => ({
- default: vi.fn((doc: Record, options: Record) => {
- pdfMocks.autoTableCalls.push(options);
- doc.lastAutoTable = { finalY: 100 };
- }),
-}));
-
-import { exportAttackToPdf, exportCaseToPdf } from '@/lib/pdf-export-utils';
-
-const caseWithLongContention: GenerateOutput = {
- caseStructure: {
- thesis: 'Thesis',
- framework: 'Framework',
- keyDefinitions: [],
- burdenOfProof: 'Burden',
- },
- contentions: [{
- id: 'contention',
- claim: 'Claim',
- reasoning: 'LONG reasoning',
- evidence: '',
- needsSource: false,
- impact: '',
- }],
- predictedOpponentCase: { likelyArguments: [], strategicWeaknesses: [], keyClashes: [] },
- counters: [],
- metadata: { side: 'pro', format: 'bp', generatedAt: '2026-01-01T00:00:00Z' },
-};
-
-const attackWithTables: AttackOutput = {
- overallAssessment: { score: 75, summary: 'Summary', strengthAreas: [] },
- weaknesses: [{
- id: 'weakness',
- severity: 'major',
- type: 'logical',
- description: 'Description',
- whyWeak: 'Why',
- howOpponentAttacks: 'Attack',
- improvedVersion: 'Improve',
- }],
- fallaciesDetected: [{ name: 'Long named fallacy', location: 'Location', explanation: 'Explanation' }],
- crossExQuestions: [],
- weighingAnalysis: { currentWeighing: '', missingComparisons: [], improvementSuggestions: [] },
-};
-
-describe('PDF export utilities', () => {
- beforeEach(() => {
- pdfMocks.docs.length = 0;
- pdfMocks.autoTableCalls.length = 0;
- return i18n.changeLanguage('en');
- });
-
- it('draws contention accent bars on every page segment', async () => {
- await expect(exportCaseToPdf(caseWithLongContention)).resolves.toBe(true);
-
- const accentBars = pdfMocks.docs[0].accentBars as Array<{ page: number; height: number }>;
- expect(accentBars.map(({ page }) => page)).toEqual([1, 2]);
- expect(accentBars.every(({ height }) => height >= 0)).toBe(true);
- });
-
- it('limits only compact table lead columns', async () => {
- await expect(exportAttackToPdf(attackWithTables)).resolves.toBe(true);
-
- expect(pdfMocks.autoTableCalls[0].columnStyles).toEqual({ 0: { cellWidth: 22 } });
- expect(pdfMocks.autoTableCalls[1].columnStyles).toBeUndefined();
- });
-
- it('uses a stable side value in the PDF filename', async () => {
- await i18n.changeLanguage('es');
- await expect(exportCaseToPdf(caseWithLongContention)).resolves.toBe(true);
-
- expect(pdfMocks.docs[0].save).toHaveBeenCalledWith('DebateCase_pro.pdf');
- await i18n.changeLanguage('en');
- });
-
- it('localizes PDF footer boilerplate and date for Hungarian exports', async () => {
- await i18n.changeLanguage('hu');
- await expect(exportCaseToPdf(caseWithLongContention)).resolves.toBe(true);
-
- const footerText = (pdfMocks.docs[0].text as ReturnType).mock.calls
- .map(([text]) => text)
- .filter((text): text is string => typeof text === 'string');
- expect(footerText).toContainEqual(expect.stringContaining('A DebateLab készítette'));
- expect(footerText).toContainEqual(expect.stringMatching(/Oldal 1 \/ \d+/));
- expect(footerText).toContain('1. érv');
- expect(pdfMocks.docs[0].setFont).toHaveBeenCalledWith('LiberationSans', 'normal');
- });
-});
diff --git a/src/test/providers.test.ts b/src/test/providers.test.ts
deleted file mode 100644
index 7f138dfd0..000000000
--- a/src/test/providers.test.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { AVAILABLE_MODELS, PROVIDER_ID } from '@/lib/ai/providers/types';
-
-describe('AVAILABLE_MODELS', () => {
- // Model IDs key request routing and the stored per-session model choice, so a
- // duplicate would silently route to the wrong catalog entry.
- it('has no duplicate model IDs', () => {
- const allIds = AVAILABLE_MODELS.map((model) => model.id);
- expect(new Set(allIds).size).toBe(allIds.length);
- });
-
- it('exposes the approved model catalog through OpenRouter', () => {
- expect(PROVIDER_ID).toBe('openrouter');
- expect(AVAILABLE_MODELS.map((model) => model.id)).toEqual([
- 'openai/gpt-5.6-luna',
- 'google/gemini-3.8-flash',
- 'deepseek/deepseek-v4-flash-0731',
- 'anthropic/claude-sonnet-5',
- 'anthropic/claude-opus-5',
- 'openai/gpt-5.6-sol',
- 'openai/gpt-5.6-terra',
- 'deepseek/deepseek-v4-pro-0813',
- 'z-ai/glm-5.3-flash',
- 'meta/muse-spark-1.3',
- ]);
- });
-});
diff --git a/src/test/register-service-worker.test.ts b/src/test/register-service-worker.test.ts
deleted file mode 100644
index fc1293896..000000000
--- a/src/test/register-service-worker.test.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-vi.mock('@/lib/native', () => ({ isNativeApp: () => false }));
-
-import { registerServiceWorker } from '@/register-service-worker';
-
-describe('registerServiceWorker', () => {
- const originalServiceWorker = Object.getOwnPropertyDescriptor(navigator, 'serviceWorker');
- const addEventListener = vi.fn();
- const registrationAddEventListener = vi.fn();
- const postMessage = vi.fn();
-
- beforeEach(() => {
- vi.stubEnv('PROD', true);
- addEventListener.mockClear();
- registrationAddEventListener.mockClear();
- postMessage.mockClear();
- vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]);
- Object.defineProperty(navigator, 'serviceWorker', {
- configurable: true,
- value: {
- controller: null,
- register: vi.fn().mockResolvedValue({ addEventListener: registrationAddEventListener }),
- ready: Promise.resolve({ active: { postMessage } }),
- addEventListener,
- },
- });
- });
-
- afterEach(() => {
- vi.unstubAllEnvs();
- vi.restoreAllMocks();
- if (originalServiceWorker) {
- Object.defineProperty(navigator, 'serviceWorker', originalServiceWorker);
- } else {
- Reflect.deleteProperty(navigator, 'serviceWorker');
- }
- });
-
- it('registers without forcing an update activation or document reload', async () => {
- registerServiceWorker();
- window.dispatchEvent(new Event('load'));
- await Promise.resolve();
-
- expect(navigator.serviceWorker.register).toHaveBeenCalledWith('/sw.js');
- expect(addEventListener).not.toHaveBeenCalledWith('controllerchange', expect.any(Function));
- expect(registrationAddEventListener).not.toHaveBeenCalledWith('updatefound', expect.any(Function));
- });
-
- it('asks the active worker to cache assets loaded before it took control', async () => {
- vi.mocked(performance.getEntriesByType).mockReturnValue([
- { name: `${window.location.origin}/assets/index-hash.js` } as PerformanceResourceTiming,
- { name: `${window.location.origin}/assets/GeneratePage-hash.js` } as PerformanceResourceTiming,
- { name: `${window.location.origin}/rest/v1/cases` } as PerformanceResourceTiming,
- { name: 'https://example.com/assets/external.js' } as PerformanceResourceTiming,
- ]);
-
- registerServiceWorker();
- window.dispatchEvent(new Event('load'));
- await Promise.resolve();
- await Promise.resolve();
-
- expect(postMessage).toHaveBeenCalledWith({
- type: 'CACHE_VISITED_ASSETS',
- urls: [
- `${window.location.origin}/assets/index-hash.js`,
- `${window.location.origin}/assets/GeneratePage-hash.js`,
- ],
- });
- });
-});
diff --git a/src/test/route-modules.test.ts b/src/test/route-modules.test.ts
deleted file mode 100644
index 9a5112517..000000000
--- a/src/test/route-modules.test.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { preloadRoute, routeLoaders } from "@/lib/route-modules";
-
-const rejectionState = vi.hoisted(() => ({
- failure: new Error("route import failed"),
- attempts: 0,
-}));
-
-describe("route module preloading", () => {
- beforeEach(() => {
- vi.restoreAllMocks();
- });
-
- it("invokes a known route loader once", async () => {
- const loader = vi.spyOn(routeLoaders, "/app/generate");
-
- await preloadRoute("/app/generate");
- expect(loader).toHaveBeenCalledTimes(1);
- });
-
- it("returns the same promise for repeated calls", async () => {
- const first = preloadRoute("/app/attack");
- const second = preloadRoute("/app/attack");
-
- expect(second).toBe(first);
- expect(routeLoaders["/app/attack"]()).toBe(first);
- await first;
- });
-
- it("shares the preload promise when preload runs before the App loader", async () => {
- const preload = preloadRoute("/app/flowchart");
- const lazyLoad = routeLoaders["/app/flowchart"]();
-
- expect(lazyLoad).toBe(preload);
- await preload;
- });
-
- it("shares the preload promise when the App loader runs first", async () => {
- const lazyLoad = routeLoaders["/app/settings"]();
- const preload = preloadRoute("/app/settings");
-
- expect(preload).toBe(lazyLoad);
- await lazyLoad;
- });
-
- it("evicts a rejected shared import so the next call retries", async () => {
- rejectionState.attempts = 0;
- vi.doMock("@/pages/AdminPage", () => {
- rejectionState.attempts += 1;
- if (rejectionState.attempts === 1) throw rejectionState.failure;
- return { default: (): null => null };
- });
- vi.resetModules();
-
- try {
- const isolated = await import("@/lib/route-modules");
- const first = isolated.routeLoaders["/app/admin"]();
- await expect(first).rejects.toThrow();
-
- const second = isolated.preloadRoute("/app/admin");
- expect(second).not.toBe(first);
- await expect(second).resolves.toBeDefined();
- expect(rejectionState.attempts).toBe(2);
- } finally {
- vi.doUnmock("@/pages/AdminPage");
- vi.resetModules();
- }
- });
-
- it("returns undefined for unknown paths", () => {
- expect(preloadRoute("/app/unknown")).toBeUndefined();
- expect(preloadRoute("toString")).toBeUndefined();
- });
-});
diff --git a/src/test/route-skeleton.test.tsx b/src/test/route-skeleton.test.tsx
deleted file mode 100644
index 32122b46e..000000000
--- a/src/test/route-skeleton.test.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-import { RouteSkeleton, type RouteSkeletonVariant } from "@/components/loading/RouteSkeleton";
-
-const variants: RouteSkeletonVariant[] = [
- "public",
- "home-grid",
- "tool-form",
- "chat",
- "settings/list",
- "admin/table",
-];
-
-describe("RouteSkeleton", () => {
- it.each(variants)("renders the %s route shape as one polite busy region", (variant) => {
- const { container } = render();
- const status = screen.getByRole("status", { name: `Opening ${variant}` });
- const busyRegions = container.querySelectorAll("[data-route-skeleton]");
- const busyRegion = busyRegions[0];
-
- expect(screen.getAllByRole("status")).toHaveLength(1);
- expect(status).toHaveAttribute("aria-live", "polite");
- expect(status).toHaveClass("sr-only");
- expect(status).toHaveTextContent(`Opening ${variant}`);
- expect(status).not.toHaveAttribute("aria-busy");
- expect(status.closest('[aria-busy="true"]')).toBeNull();
- expect(busyRegions).toHaveLength(1);
- expect(busyRegion).toHaveAttribute("aria-busy", "true");
- expect(busyRegion).toHaveAttribute("data-route-skeleton", variant);
- expect(busyRegion).not.toHaveAttribute("role", "status");
- expect(busyRegion).toHaveClass("min-h-[60vh]");
- expect(busyRegion).toHaveTextContent(/^$/);
- expect(container.querySelectorAll("[data-route-skeleton-shape]").length).toBeGreaterThan(0);
- });
-
- it.each(variants)("keeps %s decorative shapes hidden and inert", (variant) => {
- const { container } = render();
-
- expect(container.querySelectorAll("a, button, input, select, textarea")).toHaveLength(0);
- expect(container.querySelectorAll('[tabindex]:not([tabindex="-1"])')).toHaveLength(0);
-
- for (const shape of container.querySelectorAll("[data-route-skeleton-shape]")) {
- expect(shape).toHaveAttribute("aria-hidden", "true");
- expect(shape).not.toHaveAttribute("tabindex");
- }
- });
-
- it("keeps the main landmark while a public route loads", () => {
- render();
-
- expect(screen.getByRole("main")).toHaveAttribute("data-route-skeleton", "public");
- });
-
- it.each(variants)("keeps %s static without animation hooks", (variant) => {
- const { container } = render();
-
- expect(container.querySelector('[class*="animate-"]')).toBeNull();
- expect(container.querySelector('[class*="motion-"]')).toBeNull();
- expect(container.querySelector("[style*='animation']")).toBeNull();
- });
-
- it("includes a destination label without adding visible fake text", () => {
- const { container } = render();
-
- const status = screen.getByRole("status", { name: "Opening Attack" });
- expect(status).toHaveTextContent("Opening Attack");
- expect(container.querySelector('[data-route-skeleton="tool-form"]')).toHaveTextContent(/^$/);
- });
-
- it.each([
- ["es", "Atacar", "Abriendo Atacar"],
- ["hu", "Támadás", "Támadás megnyitása"],
- ])("accepts the %s localized route announcement", (_locale, label, statusText) => {
- render();
-
- const status = screen.getByRole("status", { name: statusText });
- expect(status).toHaveTextContent(statusText);
- expect(status).not.toHaveTextContent("Opening");
- expect(status).not.toHaveTextContent("Attack");
- });
-});
diff --git a/src/test/runtime-contracts.test.ts b/src/test/runtime-contracts.test.ts
deleted file mode 100644
index 803221030..000000000
--- a/src/test/runtime-contracts.test.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { readFileSync } from 'node:fs';
-import path from 'node:path';
-import * as Sentry from '@sentry/react';
-import { describe, expect, it } from 'vitest';
-import { STAGING_CAPTURE_POLICY } from '../../playwright.config';
-
-describe('runtime integration contracts', () => {
- it('uses Sentry SDK capabilities that exist in the installed package', () => {
- expect(typeof Sentry.logger.info).toBe('function');
- expect(typeof Sentry.metrics.count).toBe('function');
- expect(typeof Sentry.metrics.distribution).toBe('function');
- });
-
- // Staging e2e signs in as a real user, so captures would carry that session.
- it('disables every authenticated staging browser capture channel', () => {
- expect(STAGING_CAPTURE_POLICY).toEqual({
- trace: 'off',
- screenshot: 'off',
- video: 'off',
- });
-
- const workflow = readFileSync(path.resolve(process.cwd(), '.github/workflows/staging-e2e.yml'), 'utf8');
- expect(workflow).not.toContain('actions/upload-artifact');
- expect(workflow).toContain('head_sha=${GITHUB_SHA}&event=push&status=success');
- expect(workflow).toContain('if [ "$successful_runs" -ne 1 ]');
- });
-});
diff --git a/src/test/schemas.test.ts b/src/test/schemas.test.ts
deleted file mode 100644
index 39785c83a..000000000
--- a/src/test/schemas.test.ts
+++ /dev/null
@@ -1,664 +0,0 @@
-// src/test/schemas.test.ts
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { describe, it, expect } from 'vitest';
-import { GenerateInputSchema, GenerateOutputSchema, ContentionSchema } from '@/lib/ai/schemas/generate';
-import { AttackInputSchema, AttackOutputSchema, WeaknessSchema } from '@/lib/ai/schemas/attack';
-import { BrainstormInputSchema, BrainstormOutputSchema, IdeaSchema, BucketSchema } from '@/lib/ai/schemas/brainstorm';
-import { FactoidsInputSchema, FactoidsOutputSchema, FactoidSchema, CitationSchema } from '@/lib/ai/schemas/factoids';
-import { AskInputSchema, AskOutputSchema } from '@/lib/ai/schemas/ask';
-
-// ============================================================================
-// GENERATE SCHEMA TESTS
-// ============================================================================
-
-describe('Generate Schema', () => {
- describe('GenerateInputSchema', () => {
- const validInput = {
- motion: 'This house believes technology improves education',
- side: 'pro',
- format: 'bp',
- role: 'intro',
- length: 'standard',
- audience: 'intermediate',
- tone: 'formal',
- };
-
- it('accepts valid input', () => {
- const result = GenerateInputSchema.safeParse(validInput);
- expect(result.success).toBe(true);
- });
-
- it('rejects missing motion', () => {
- const result = GenerateInputSchema.safeParse({ ...validInput, motion: '' });
- expect(result.success).toBe(false);
- });
-
- it('rejects invalid side', () => {
- const result = GenerateInputSchema.safeParse({ ...validInput, side: 'invalid' });
- expect(result.success).toBe(false);
- });
-
- it('rejects invalid format', () => {
- const result = GenerateInputSchema.safeParse({ ...validInput, format: 'invalid' });
- expect(result.success).toBe(false);
- });
-
- it('rejects invalid role', () => {
- const result = GenerateInputSchema.safeParse({ ...validInput, role: 'invalid' });
- expect(result.success).toBe(false);
- });
-
- it('rejects invalid length', () => {
- const result = GenerateInputSchema.safeParse({ ...validInput, length: 'invalid' });
- expect(result.success).toBe(false);
- });
-
- it('rejects invalid audience', () => {
- const result = GenerateInputSchema.safeParse({ ...validInput, audience: 'invalid' });
- expect(result.success).toBe(false);
- });
-
- it('rejects invalid tone', () => {
- const result = GenerateInputSchema.safeParse({ ...validInput, tone: 'invalid' });
- expect(result.success).toBe(false);
- });
- });
-
- describe('ContentionSchema', () => {
- const validContention = {
- id: 'c1',
- claim: 'Technology increases access to education',
- reasoning: 'Online platforms reach remote areas',
- evidence: 'UNESCO reports 50% increase in online learning',
- needsSource: true,
- sourceQuery: 'UNESCO online learning statistics',
- impact: 'More people can access quality education',
- };
-
- it('accepts valid contention', () => {
- expect(ContentionSchema.safeParse(validContention).success).toBe(true);
- });
-
- it('accepts contention without optional sourceQuery', () => {
- const { sourceQuery: _, ...withoutQuery } = validContention;
- expect(ContentionSchema.safeParse(withoutQuery).success).toBe(true);
- });
-
- it('rejects missing claim', () => {
- const result = ContentionSchema.safeParse({ ...validContention, claim: undefined });
- expect(result.success).toBe(false);
- });
- });
-
- describe('GenerateOutputSchema', () => {
- const validOutput = {
- caseStructure: {
- thesis: 'Technology fundamentally improves education',
- framework: 'Utilitarian - greatest good for greatest number',
- keyDefinitions: ['Technology', 'Education', 'Improvement'],
- burdenOfProof: 'Show net positive impact on learning outcomes',
- },
- contentions: [
- {
- id: 'c1',
- claim: 'Increases access',
- reasoning: 'Online reaches more people',
- evidence: 'UNESCO data',
- needsSource: false,
- impact: 'More educated population',
- },
- ],
- predictedOpponentCase: {
- likelyArguments: ['Digital divide', 'Screen time concerns'],
- strategicWeaknesses: ['Relies on infrastructure'],
- keyClashes: ['Definition of improvement'],
- },
- counters: [
- {
- targetArgument: 'Digital divide',
- counterpoint: 'Mobile adoption closing gap',
- rebuttal: 'Infrastructure improving rapidly',
- },
- ],
- metadata: {
- side: 'pro',
- format: 'bp',
- generatedAt: '2024-01-01T00:00:00Z',
- },
- };
-
- it('accepts valid output', () => {
- expect(GenerateOutputSchema.safeParse(validOutput).success).toBe(true);
- });
-
- it('rejects missing caseStructure', () => {
- const { caseStructure: _, ...without } = validOutput;
- expect(GenerateOutputSchema.safeParse(without).success).toBe(false);
- });
-
- it('rejects missing contentions', () => {
- const { contentions: _, ...without } = validOutput;
- expect(GenerateOutputSchema.safeParse(without).success).toBe(false);
- });
-
- it('accepts empty contentions array', () => {
- expect(GenerateOutputSchema.safeParse({ ...validOutput, contentions: [] }).success).toBe(true);
- });
-
- it('rejects invalid metadata side', () => {
- const result = GenerateOutputSchema.safeParse({
- ...validOutput,
- metadata: { ...validOutput.metadata, side: 'invalid' },
- });
- expect(result.success).toBe(false);
- });
- });
-});
-
-// ============================================================================
-// ATTACK SCHEMA TESTS
-// ============================================================================
-
-describe('Attack Schema', () => {
- describe('AttackInputSchema', () => {
- const validInput = {
- caseText: 'This is a debate case about technology in education...',
- aggressiveness: 'moderate',
- judgeParadigm: 'logic',
- detectFallacies: true,
- analyzeWeighing: true,
- };
-
- it('accepts valid input', () => {
- expect(AttackInputSchema.safeParse(validInput).success).toBe(true);
- });
-
- it('rejects empty caseText', () => {
- expect(AttackInputSchema.safeParse({ ...validInput, caseText: '' }).success).toBe(false);
- });
-
- it('rejects invalid aggressiveness', () => {
- expect(AttackInputSchema.safeParse({ ...validInput, aggressiveness: 'extreme' }).success).toBe(false);
- });
-
- it('rejects invalid judgeParadigm', () => {
- expect(AttackInputSchema.safeParse({ ...validInput, judgeParadigm: 'invalid' }).success).toBe(false);
- });
-
- it('accepts all valid aggressiveness levels', () => {
- expect(AttackInputSchema.safeParse({ ...validInput, aggressiveness: 'mild' }).success).toBe(true);
- expect(AttackInputSchema.safeParse({ ...validInput, aggressiveness: 'moderate' }).success).toBe(true);
- expect(AttackInputSchema.safeParse({ ...validInput, aggressiveness: 'ruthless' }).success).toBe(true);
- });
-
- it('accepts all valid judgeParadigms', () => {
- expect(AttackInputSchema.safeParse({ ...validInput, judgeParadigm: 'logic' }).success).toBe(true);
- expect(AttackInputSchema.safeParse({ ...validInput, judgeParadigm: 'values' }).success).toBe(true);
- expect(AttackInputSchema.safeParse({ ...validInput, judgeParadigm: 'policy' }).success).toBe(true);
- });
- });
-
- describe('WeaknessSchema', () => {
- const validWeakness = {
- id: 'w1',
- severity: 'major',
- type: 'logical',
- description: 'Circular reasoning detected',
- whyWeak: 'Assumes the conclusion',
- howOpponentAttacks: 'Point out the circularity',
- improvedVersion: 'Add independent premises',
- affectedQuote: 'Technology is good because it helps',
- };
-
- it('accepts valid weakness', () => {
- expect(WeaknessSchema.safeParse(validWeakness).success).toBe(true);
- });
-
- it('accepts without optional affectedQuote', () => {
- const { affectedQuote: _, ...without } = validWeakness;
- expect(WeaknessSchema.safeParse(without).success).toBe(true);
- });
-
- it('rejects invalid severity', () => {
- expect(WeaknessSchema.safeParse({ ...validWeakness, severity: 'catastrophic' }).success).toBe(false);
- });
-
- it('rejects invalid type', () => {
- expect(WeaknessSchema.safeParse({ ...validWeakness, type: 'invalid' }).success).toBe(false);
- });
- });
-
- describe('AttackOutputSchema', () => {
- const validOutput = {
- overallAssessment: {
- score: 75,
- summary: 'Solid case with some logical gaps',
- strengthAreas: ['Evidence quality', 'Clear structure'],
- },
- weaknesses: [] as any[],
- fallaciesDetected: [] as any[],
- crossExQuestions: [] as any[],
- weighingAnalysis: {
- currentWeighing: 'Basic comparison provided',
- missingComparisons: ['Timeframe comparison'],
- improvementSuggestions: ['Add magnitude analysis'],
- },
- };
-
- it('accepts valid output', () => {
- expect(AttackOutputSchema.safeParse(validOutput).success).toBe(true);
- });
-
- it('rejects score below 0', () => {
- const result = AttackOutputSchema.safeParse({
- ...validOutput,
- overallAssessment: { ...validOutput.overallAssessment, score: -1 },
- });
- expect(result.success).toBe(false);
- });
-
- it('rejects score above 100', () => {
- const result = AttackOutputSchema.safeParse({
- ...validOutput,
- overallAssessment: { ...validOutput.overallAssessment, score: 101 },
- });
- expect(result.success).toBe(false);
- });
-
- it('accepts score at boundaries', () => {
- expect(AttackOutputSchema.safeParse({
- ...validOutput,
- overallAssessment: { ...validOutput.overallAssessment, score: 0 },
- }).success).toBe(true);
- expect(AttackOutputSchema.safeParse({
- ...validOutput,
- overallAssessment: { ...validOutput.overallAssessment, score: 100 },
- }).success).toBe(true);
- });
- });
-});
-
-// ============================================================================
-// BRAINSTORM SCHEMA TESTS
-// ============================================================================
-
-describe('Brainstorm Schema', () => {
- describe('BrainstormInputSchema', () => {
- const validInput = {
- motion: 'Universal basic income should be implemented',
- side: 'both',
- context: 'For a policy debate',
- outputMode: 'mindmap',
- };
-
- it('accepts valid input', () => {
- expect(BrainstormInputSchema.safeParse(validInput).success).toBe(true);
- });
-
- it('accepts without optional context', () => {
- const { context: _, ...without } = validInput;
- expect(BrainstormInputSchema.safeParse(without).success).toBe(true);
- });
-
- it('rejects empty motion', () => {
- expect(BrainstormInputSchema.safeParse({ ...validInput, motion: '' }).success).toBe(false);
- });
-
- it('rejects invalid side', () => {
- expect(BrainstormInputSchema.safeParse({ ...validInput, side: 'neutral' }).success).toBe(false);
- });
-
- it('rejects invalid outputMode', () => {
- expect(BrainstormInputSchema.safeParse({ ...validInput, outputMode: 'list' }).success).toBe(false);
- });
-
- it('accepts all valid sides', () => {
- expect(BrainstormInputSchema.safeParse({ ...validInput, side: 'pro' }).success).toBe(true);
- expect(BrainstormInputSchema.safeParse({ ...validInput, side: 'con' }).success).toBe(true);
- expect(BrainstormInputSchema.safeParse({ ...validInput, side: 'both' }).success).toBe(true);
- });
-
- it('accepts all valid outputModes', () => {
- expect(BrainstormInputSchema.safeParse({ ...validInput, outputMode: 'mindmap' }).success).toBe(true);
- expect(BrainstormInputSchema.safeParse({ ...validInput, outputMode: 'buckets' }).success).toBe(true);
- expect(BrainstormInputSchema.safeParse({ ...validInput, outputMode: 'topfive' }).success).toBe(true);
- });
- });
-
- describe('IdeaSchema', () => {
- const validIdea = {
- id: 'i1',
- title: 'Economic security',
- description: 'Provides financial safety net',
- side: 'pro',
- strength: 'strong',
- tradeoffs: ['Inflation risk', 'Tax burden'],
- relatedTo: ['i2', 'i3'],
- };
-
- it('accepts valid idea', () => {
- expect(IdeaSchema.safeParse(validIdea).success).toBe(true);
- });
-
- it('accepts empty tradeoffs', () => {
- expect(IdeaSchema.safeParse({ ...validIdea, tradeoffs: [] }).success).toBe(true);
- });
-
- it('accepts empty relatedTo', () => {
- expect(IdeaSchema.safeParse({ ...validIdea, relatedTo: [] }).success).toBe(true);
- });
-
- it('rejects invalid side', () => {
- expect(IdeaSchema.safeParse({ ...validIdea, side: 'both' }).success).toBe(false);
- });
-
- it('rejects invalid strength', () => {
- expect(IdeaSchema.safeParse({ ...validIdea, strength: 'very-strong' }).success).toBe(false);
- });
- });
-
- describe('BucketSchema', () => {
- const validBucket = {
- name: 'Economic Arguments',
- ideas: [] as any[],
- keyInsight: 'Focus on long-term economic impact',
- };
-
- it('accepts valid bucket', () => {
- expect(BucketSchema.safeParse(validBucket).success).toBe(true);
- });
-
- it('rejects missing name', () => {
- const { name: _, ...without } = validBucket;
- expect(BucketSchema.safeParse(without).success).toBe(false);
- });
- });
-
- describe('BrainstormOutputSchema', () => {
- const validOutput = {
- needsMoreInfo: false,
- nextSteps: ['Research economic data', 'Find case studies'],
- warningsAndTradeoffs: ['May be politically controversial'],
- };
-
- it('accepts valid output', () => {
- expect(BrainstormOutputSchema.safeParse(validOutput).success).toBe(true);
- });
-
- it('accepts with optional mindmap', () => {
- expect(BrainstormOutputSchema.safeParse({
- ...validOutput,
- mindmap: [{ category: 'Economics', items: [] }],
- }).success).toBe(true);
- });
-
- it('accepts with optional buckets', () => {
- expect(BrainstormOutputSchema.safeParse({
- ...validOutput,
- buckets: [{ name: 'Economic', ideas: [], keyInsight: 'Focus on jobs' }],
- }).success).toBe(true);
- });
-
- it('accepts with optional clarifyingQuestions', () => {
- expect(BrainstormOutputSchema.safeParse({
- ...validOutput,
- clarifyingQuestions: ['What country?', 'What amount?'],
- }).success).toBe(true);
- });
- });
-});
-
-// ============================================================================
-// FACTOIDS SCHEMA TESTS
-// ============================================================================
-
-describe('Factoids Schema', () => {
- describe('FactoidsInputSchema', () => {
- const validInput = {
- claim: 'Renewable energy is more cost effective',
- region: 'United States',
- sourceType: 'academic',
- timeframe: '2020-2024',
- };
-
- it('accepts valid input', () => {
- expect(FactoidsInputSchema.safeParse(validInput).success).toBe(true);
- });
-
- it('accepts without optional timeframe', () => {
- const { timeframe: _, ...without } = validInput;
- expect(FactoidsInputSchema.safeParse(without).success).toBe(true);
- });
-
- it('rejects empty claim', () => {
- expect(FactoidsInputSchema.safeParse({ ...validInput, claim: '' }).success).toBe(false);
- });
-
- it('accepts empty region (defaults to Global)', () => {
- expect(FactoidsInputSchema.safeParse({ ...validInput, region: '' }).success).toBe(true);
- });
-
- it('rejects invalid sourceType', () => {
- expect(FactoidsInputSchema.safeParse({ ...validInput, sourceType: 'blog' }).success).toBe(false);
- });
-
- it('rejects fractional source counts', () => {
- expect(FactoidsInputSchema.safeParse({ ...validInput, sourceCount: 3.5 }).success).toBe(false);
- });
-
- it('accepts all valid sourceTypes', () => {
- expect(FactoidsInputSchema.safeParse({ ...validInput, sourceType: 'academic' }).success).toBe(true);
- expect(FactoidsInputSchema.safeParse({ ...validInput, sourceType: 'government' }).success).toBe(true);
- expect(FactoidsInputSchema.safeParse({ ...validInput, sourceType: 'news' }).success).toBe(true);
- expect(FactoidsInputSchema.safeParse({ ...validInput, sourceType: 'any' }).success).toBe(true);
- });
- });
-
- describe('CitationSchema', () => {
- const validCitation = {
- title: 'Renewable Energy Cost Analysis 2024',
- publisher: 'Nature Energy',
- date: '2024-01-15',
- url: 'https://nature.com/energy/2024/cost-analysis',
- verified: 'verified',
- verifiedAt: '2024-01-20T10:00:00Z',
- };
-
- it('accepts valid citation', () => {
- expect(CitationSchema.safeParse(validCitation).success).toBe(true);
- });
-
- it('accepts without optional fields', () => {
- expect(CitationSchema.safeParse({
- title: 'Report',
- publisher: 'Publisher',
- verified: 'pending',
- }).success).toBe(true);
- });
-
- it('rejects invalid verified status', () => {
- expect(CitationSchema.safeParse({ ...validCitation, verified: 'unknown' }).success).toBe(false);
- });
-
- it.each(['javascript:alert(1)', 'mailto:editor@example.com', 'ftp://example.com/report'])('rejects non-HTTP(S) citation URLs', (url) => {
- expect(CitationSchema.safeParse({ ...validCitation, url }).success).toBe(false);
- });
-
- it('accepts all verified statuses', () => {
- expect(CitationSchema.safeParse({ ...validCitation, verified: 'verified' }).success).toBe(true);
- expect(CitationSchema.safeParse({ ...validCitation, verified: 'unverified' }).success).toBe(true);
- expect(CitationSchema.safeParse({ ...validCitation, verified: 'pending' }).success).toBe(true);
- });
- });
-
- describe('FactoidSchema', () => {
- const validFactoid = {
- id: 'f1',
- shortClaim: 'Solar costs dropped 90% since 2010',
- keyFigure: '90%',
- context: 'Global average for utility-scale solar',
- limitations: 'Varies by region',
- citation: {
- title: 'IRENA Report',
- publisher: 'IRENA',
- verified: 'verified',
- },
- howToUse: 'Use in opening to establish economic viability',
- relevanceScore: 85,
- };
-
- it('accepts valid factoid', () => {
- expect(FactoidSchema.safeParse(validFactoid).success).toBe(true);
- });
-
- it('rejects relevanceScore below 0', () => {
- expect(FactoidSchema.safeParse({ ...validFactoid, relevanceScore: -1 }).success).toBe(false);
- });
-
- it('rejects relevanceScore above 100', () => {
- expect(FactoidSchema.safeParse({ ...validFactoid, relevanceScore: 101 }).success).toBe(false);
- });
-
- it('accepts relevanceScore at boundaries', () => {
- expect(FactoidSchema.safeParse({ ...validFactoid, relevanceScore: 0 }).success).toBe(true);
- expect(FactoidSchema.safeParse({ ...validFactoid, relevanceScore: 100 }).success).toBe(true);
- });
- });
-
- describe('FactoidsOutputSchema', () => {
- const validOutput = {
- factoids: [] as any[],
- searchSuggestions: ['Try searching for specific years'],
- warnings: ['Limited data available'],
- verificationStatus: 'pending',
- };
-
- it('accepts valid output', () => {
- expect(FactoidsOutputSchema.safeParse(validOutput).success).toBe(true);
- });
-
- it('rejects invalid verificationStatus', () => {
- expect(FactoidsOutputSchema.safeParse({ ...validOutput, verificationStatus: 'done' }).success).toBe(false);
- });
-
- it('accepts all valid verificationStatuses', () => {
- expect(FactoidsOutputSchema.safeParse({ ...validOutput, verificationStatus: 'pending' }).success).toBe(true);
- expect(FactoidsOutputSchema.safeParse({ ...validOutput, verificationStatus: 'inProgress' }).success).toBe(true);
- expect(FactoidsOutputSchema.safeParse({ ...validOutput, verificationStatus: 'complete' }).success).toBe(true);
- });
- });
-});
-
-// ============================================================================
-// ASK SCHEMA TESTS
-// ============================================================================
-
-describe('Ask Schema', () => {
- describe('AskInputSchema', () => {
- const validInput = {
- message: 'How should I structure my rebuttal?',
- mode: 'general',
- citationMode: true,
- conversationHistory: [
- { role: 'user', content: 'I need help with my case' },
- { role: 'assistant', content: 'What topic are you working on?' },
- ],
- };
-
- it('accepts valid input', () => {
- expect(AskInputSchema.safeParse(validInput).success).toBe(true);
- });
-
- it('rejects empty message', () => {
- expect(AskInputSchema.safeParse({ ...validInput, message: '' }).success).toBe(false);
- });
-
- it('rejects invalid mode', () => {
- expect(AskInputSchema.safeParse({ ...validInput, mode: 'invalid' }).success).toBe(false);
- });
-
- it('accepts all valid modes', () => {
- expect(AskInputSchema.safeParse({ ...validInput, mode: 'general' }).success).toBe(true);
- expect(AskInputSchema.safeParse({ ...validInput, mode: 'clarify' }).success).toBe(true);
- expect(AskInputSchema.safeParse({ ...validInput, mode: 'summarize' }).success).toBe(true);
- expect(AskInputSchema.safeParse({ ...validInput, mode: 'proscons' }).success).toBe(true);
- expect(AskInputSchema.safeParse({ ...validInput, mode: 'argumentmap' }).success).toBe(true);
- });
-
- it('accepts empty conversationHistory', () => {
- expect(AskInputSchema.safeParse({ ...validInput, conversationHistory: [] }).success).toBe(true);
- });
-
- it('accepts optional webSearch flag', () => {
- expect(AskInputSchema.safeParse({ ...validInput, webSearch: true }).success).toBe(true);
- expect(AskInputSchema.safeParse({ ...validInput, webSearch: false }).success).toBe(true);
- });
-
- it('rejects invalid role in conversationHistory', () => {
- expect(AskInputSchema.safeParse({
- ...validInput,
- conversationHistory: [{ role: 'system', content: 'test' }],
- }).success).toBe(false);
- });
- });
-
- describe('AskOutputSchema', () => {
- const validOutput = {
- response: 'Here is how you can structure your rebuttal...',
- followUpQuestions: ['What is your main argument?', 'Who is your opponent?'],
- uncertainties: ['May depend on judge preferences'],
- };
-
- it('accepts valid output', () => {
- expect(AskOutputSchema.safeParse(validOutput).success).toBe(true);
- });
-
- it('accepts with optional citations', () => {
- expect(AskOutputSchema.safeParse({
- ...validOutput,
- citations: [{ claim: 'Debate structure matters', verified: true }],
- }).success).toBe(true);
- });
-
- it('accepts with optional prosConsAnalysis', () => {
- expect(AskOutputSchema.safeParse({
- ...validOutput,
- prosConsAnalysis: {
- pros: ['Clear structure', 'Good flow'],
- cons: ['Time consuming'],
- },
- }).success).toBe(true);
- });
-
- it('accepts with optional argumentMap', () => {
- expect(AskOutputSchema.safeParse({
- ...validOutput,
- argumentMap: {
- mainClaim: 'Rebuttals need structure',
- supportingPoints: ['Helps judge follow'],
- counterpoints: ['May seem formulaic'],
- assumptions: ['Judge values organization'],
- },
- }).success).toBe(true);
- });
-
- it('accepts empty arrays', () => {
- expect(AskOutputSchema.safeParse({
- ...validOutput,
- followUpQuestions: [],
- uncertainties: [],
- }).success).toBe(true);
- });
-
- it.each(['javascript:alert(1)', 'mailto:editor@example.com', 'ftp://example.com/report'])('rejects non-HTTP(S) search source URLs', (url) => {
- expect(AskOutputSchema.safeParse({
- ...validOutput,
- searchInfo: {
- webSearchUsed: true,
- queries: ['debate evidence'],
- sources: [{ title: 'Source', url, verified: true }],
- },
- }).success).toBe(false);
- });
- });
-});
diff --git a/src/test/scripts/smoke-jev.test.ts b/src/test/scripts/smoke-jev.test.ts
deleted file mode 100644
index f741e830a..000000000
--- a/src/test/scripts/smoke-jev.test.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { runJevSmoke } from '../../../scripts/smoke-jev.mjs';
-
-describe('Jev smoke command', () => {
- it('sends the pinned model and returns only a sanitized summary', async () => {
- let requestBody: Record | undefined;
- const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
- requestBody = JSON.parse(String(init?.body));
- return new Response(JSON.stringify({
- model: 'typesafe/jev-1.13',
- answers: {
- fit: { type: 'choice', choice: 'specific', confidence: 0.91, probabilities: { specific: 0.91, vague: 0.09 } },
- },
- usage: { input_tokens: 12, output_tokens: 1 },
- }), { status: 200, headers: { 'Content-Type': 'application/json' } });
- });
-
- const summary = await runJevSmoke({ apiKey: 'secret-key', fetchImpl, now: () => 100 });
- expect(requestBody).toMatchObject({ model: 'typesafe/jev-1.13' });
- expect(summary).toEqual({ model: 'typesafe/jev-1.13', answers: { fit: 'specific' }, latencyMs: 0, usage: { inputTokens: 12, outputTokens: 1 } });
- expect(JSON.stringify(summary)).not.toContain('secret-key');
- expect(JSON.stringify(summary)).not.toContain('DebateLab smoke test');
- });
-
- it('rejects a missing key before network access', async () => {
- const fetchImpl = vi.fn();
- await expect(runJevSmoke({ apiKey: '', fetchImpl })).rejects.toThrow('OPENROUTER_API_KEY missing');
- expect(fetchImpl).not.toHaveBeenCalled();
- });
-
- it('rejects unsuccessful and malformed responses', async () => {
- await expect(runJevSmoke({
- apiKey: 'key',
- fetchImpl: vi.fn(async () => new Response('unavailable', { status: 503 })),
- })).rejects.toThrow('OpenRouter Jev smoke failed with 503');
- await expect(runJevSmoke({
- apiKey: 'key',
- fetchImpl: vi.fn(async () => new Response(JSON.stringify({ model: 'typesafe/jev-1.13', answers: {} }), { status: 200 })),
- })).rejects.toThrow('malformed');
- });
-});
diff --git a/src/test/sentry-event-filter.test.ts b/src/test/sentry-event-filter.test.ts
deleted file mode 100644
index b03920515..000000000
--- a/src/test/sentry-event-filter.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import * as Sentry from "@sentry/react";
-import { describe, expect, it, vi } from "vitest";
-
-describe("Sentry event filtering", () => {
- it("drops only the exact synthetic observability verification error", async () => {
- const send = vi.fn(async () => ({}));
-
- Sentry.init({
- dsn: "https://0123456789abcdef0123456789abcdef@o0.ingest.sentry.io/1",
- ignoreErrors: [/^Synthetic observability verification$/],
- transport: () => ({
- send,
- flush: async () => true,
- }),
- });
-
- Sentry.captureException(new Error("Synthetic observability verification"));
- await Sentry.flush(1_000);
- expect(send).not.toHaveBeenCalled();
-
- Sentry.captureException(new Error("Synthetic observability verification failed"));
- await Sentry.flush(1_000);
- expect(send).toHaveBeenCalledOnce();
-
- Sentry.captureException(new Error("Real privacy route failure"));
- await Sentry.flush(1_000);
- expect(send).toHaveBeenCalledTimes(2);
-
- await Sentry.close(1_000);
- });
-});
diff --git a/src/test/source-file-import.test.ts b/src/test/source-file-import.test.ts
deleted file mode 100644
index 64cb1d0a3..000000000
--- a/src/test/source-file-import.test.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { importSourceFile, SourceFileImportError } from '@/lib/source-file-import';
-
-const pdfMocks = vi.hoisted(() => {
- const cleanup = vi.fn();
- const destroy = vi.fn();
- const getPage = vi.fn(async () => ({
- getTextContent: async () => ({
- items: [{ str: 'A readable PDF page with enough extracted debate evidence for testing.' }],
- }),
- cleanup,
- }));
- return { cleanup, destroy, getPage };
-});
-
-vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
- GlobalWorkerOptions: { workerSrc: '' },
- getDocument: vi.fn(() => ({
- promise: Promise.resolve({
- numPages: 1,
- getPage: pdfMocks.getPage,
- }),
- destroy: pdfMocks.destroy,
- })),
-}));
-
-describe('source file import', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
- it('imports text-like files with word counts', async () => {
- const file = new File(['A concise debate brief with enough words to be useful in round prep.'], 'brief.md', {
- type: 'text/markdown',
- });
-
- const imported = await importSourceFile(file);
-
- expect(imported.title).toBe('brief');
- expect(imported.fileType).toBe('markdown');
- expect(imported.type).toBe('file');
- expect(imported.content).toContain('debate brief');
- expect(imported.wordCount).toBeGreaterThan(5);
- });
-
- it('formats valid JSON instead of importing one long line', async () => {
- const file = new File(['{"claim":"AI improves research","source":"study"}'], 'evidence.json', {
- type: 'application/json',
- });
-
- const imported = await importSourceFile(file);
-
- expect(imported.fileType).toBe('json');
- expect(imported.content).toContain('\n "claim"');
- });
-
- it('keeps invalid JSON as raw text with a warning', async () => {
- const file = new File(['{"claim":'], 'broken.json', { type: 'application/json' });
-
- const imported = await importSourceFile(file);
-
- expect(imported.content).toBe('{"claim":');
- expect(imported.warnings).toContain('Invalid JSON. Imported as raw text.');
- });
-
- it('extracts PDF pages and releases page and document resources', async () => {
- const file = new File([new Uint8Array([1, 2, 3])], 'evidence.pdf', { type: 'application/pdf' });
-
- const imported = await importSourceFile(file);
-
- expect(imported.fileType).toBe('pdf');
- expect(imported.content).toContain('readable PDF page');
- expect(pdfMocks.getPage).toHaveBeenCalledWith(1);
- expect(pdfMocks.cleanup).toHaveBeenCalledTimes(1);
- expect(pdfMocks.destroy).toHaveBeenCalledTimes(1);
- });
-
- it('removes a dangling high surrogate when truncating extracted text', async () => {
- const file = new File([
- `${'a'.repeat(119_999)}😀 remaining text`,
- ], 'long.txt', { type: 'text/plain' });
-
- const imported = await importSourceFile(file);
-
- expect(imported.content).toHaveLength(119_999);
- expect(imported.content.charCodeAt(imported.content.length - 1)).not.toBeGreaterThanOrEqual(0xD800);
- expect(imported.warnings).toContain('Large file truncated to the first readable section.');
- });
-
- it('rejects unsupported files with a typed error', async () => {
- const file = new File(['not an image that should import'], 'image.png', { type: 'image/png' });
-
- await expect(importSourceFile(file)).rejects.toMatchObject({
- code: 'unsupported',
- name: 'SourceFileImportError',
- } satisfies Partial);
- });
-});
diff --git a/src/test/sparring-ai.test.ts b/src/test/sparring-ai.test.ts
deleted file mode 100644
index a9e8676dc..000000000
--- a/src/test/sparring-ai.test.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import {
- SparringInputSchema,
- SparringScorecardSchema,
- SparringScoreResponseSchema,
- SparringTurnResponseSchema,
- type SparringSpeech,
-} from '@/lib/ai/schemas/sparring';
-
-const session = {
- sessionId: '00000000-0000-4000-8000-000000000001',
- motion: 'This house would regulate social media algorithms',
- userSide: 'pro' as const,
- format: 'pf' as const,
- difficulty: 'medium' as const,
- language: 'en',
- speeches: [] as SparringSpeech[],
-};
-
-describe('Sparring AI contract', () => {
- it('accepts both discriminated request actions', () => {
- expect(SparringInputSchema.safeParse({
- ...session,
- action: 'respond',
- currentTurn: { id: 'pf-2', label: 'Con constructive', side: 'con', kind: 'constructive', timeLimitSeconds: 240 },
- }).success).toBe(true);
- expect(SparringInputSchema.safeParse({ ...session, action: 'score' }).success).toBe(true);
- });
-
- it('accepts every first-class school speaker role', () => {
- for (const [kind, label, seconds] of [
- ['introductor', 'Con Introductor', 240],
- ['r1', 'Con R1', 360],
- ['r2', 'Con R2', 360],
- ['conclusor', 'Con Conclusor', 180],
- ] as const) {
- expect(SparringInputSchema.safeParse({
- ...session,
- format: 'standard',
- action: 'respond',
- currentTurn: { id: `standard-${kind}`, label, side: 'con', kind, timeLimitSeconds: seconds },
- }).success).toBe(true);
- }
- });
-
- it('caps session context at twelve speeches and requires all score categories', () => {
- const speeches = Array.from({ length: 13 }, (_, index) => ({
- id: `speech-${index + 1}`,
- turnId: `turn-${index + 1}`,
- speaker: index % 2 === 0 ? 'user' as const : 'ai' as const,
- side: index % 2 === 0 ? 'pro' as const : 'con' as const,
- role: `Speaker ${index + 1}`,
- content: `Complete argument ${index + 1}.`,
- createdAt: index + 1,
- timeLimitSeconds: 240,
- timeUsedSeconds: 20,
- ...(index % 2 === 1 ? { integrityToken: `${'x'.repeat(40)}${index + 1}` } : {}),
- }));
- expect(SparringInputSchema.safeParse({ ...session, action: 'score', speeches: speeches.slice(0, 12) }).success).toBe(true);
- expect(SparringInputSchema.safeParse({ ...session, action: 'score', speeches }).success).toBe(false);
- const category = (name: 'Logic' | 'Evidence' | 'Delivery' | 'Strategy') => ({ name, userScore: 20, aiScore: 18, feedback: 'Clear comparison.' });
- const scorecard = {
- userScore: 80, aiScore: 72,
- categories: ['Logic', 'Evidence', 'Delivery', 'Strategy'].map((name) => category(name as 'Logic' | 'Evidence' | 'Delivery' | 'Strategy')),
- overallFeedback: 'The user wins the central comparison.', keyMoments: ['The opening claim'], improvementAreas: ['Add a clearer warrant'], winner: 'user', verdict: 'User edge',
- };
- expect(SparringScorecardSchema.safeParse(scorecard).success).toBe(true);
- expect(SparringScorecardSchema.safeParse({ ...scorecard, categories: [category('Logic'), category('Logic'), category('Delivery'), category('Strategy')] }).success).toBe(false);
- expect(SparringScorecardSchema.safeParse({ ...scorecard, userScore: 79 }).success).toBe(false);
- expect(SparringScorecardSchema.safeParse({ ...scorecard, winner: 'ai' }).success).toBe(false);
- });
-});
diff --git a/src/test/sparring/exports.test.ts b/src/test/sparring/exports.test.ts
deleted file mode 100644
index f2c9c89da..000000000
--- a/src/test/sparring/exports.test.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import i18n from '@/i18n';
-import { buildSparringTurns } from '@/lib/sparring/formats';
-import type { SparringScorecard } from '@/lib/ai/schemas/sparring';
-import type { SparringSession } from '@/lib/stores/sparring-store';
-
-const exportMocks = vi.hoisted(() => {
- const paragraphs: Array> = [];
- const textRuns: Array> = [];
- const documents: Array> = [];
- const pdfDocuments: Array> = [];
- const autoTableCalls: Array> = [];
- return { paragraphs, textRuns, documents, pdfDocuments, autoTableCalls };
-});
-
-vi.mock('docx', () => ({
- Document: vi.fn(function DocumentMock(options: Record) {
- exportMocks.documents.push(options);
- return { options };
- }),
- Packer: {
- toBlob: vi.fn().mockResolvedValue(new Blob(['sparring-docx'])),
- },
- Paragraph: vi.fn(function ParagraphMock(options: Record) {
- exportMocks.paragraphs.push(options);
- return options;
- }),
- TextRun: vi.fn(function TextRunMock(options: Record | string) {
- const normalized = typeof options === 'string' ? { text: options } : options;
- exportMocks.textRuns.push(normalized);
- return normalized;
- }),
- HeadingLevel: { HEADING_1: 'HEADING_1', HEADING_2: 'HEADING_2' },
- AlignmentType: { CENTER: 'CENTER' },
-}));
-
-vi.mock('jspdf', () => ({
- jsPDF: class MockJsPdf {
- constructor() {
- let page = 1;
- const doc: Record = {
- internal: { pageSize: { height: 297, width: 210 } },
- lastAutoTable: { finalY: 100 },
- setFillColor: vi.fn(),
- rect: vi.fn(),
- setTextColor: vi.fn(),
- setFontSize: vi.fn(),
- setFont: vi.fn(),
- text: vi.fn((text: unknown) => {
- const calls = (doc.textCalls as unknown[] | undefined) ?? [];
- calls.push(text);
- doc.textCalls = calls;
- }),
- textCalls: [],
- setDrawColor: vi.fn(),
- setLineWidth: vi.fn(),
- line: vi.fn(),
- getTextWidth: vi.fn(() => 10),
- splitTextToSize: vi.fn((text: string) => [text]),
- addPage: vi.fn(() => { page += 1; }),
- getNumberOfPages: vi.fn(() => page),
- setPage: vi.fn(),
- save: vi.fn(),
- };
- exportMocks.pdfDocuments.push(doc);
- return doc;
- }
- },
-}));
-
-vi.mock('jspdf-autotable', () => ({
- default: vi.fn((doc: Record, options: Record) => {
- exportMocks.autoTableCalls.push(options);
- doc.lastAutoTable = { finalY: 100 };
- }),
-}));
-
-import { captureDownloads } from '../helpers/downloads';
-import { exportSparringToDocx } from '@/lib/export-utils';
-import { exportSparringToPdf } from '@/lib/pdf-export-utils';
-
-const SCORECARD: SparringScorecard = {
- userScore: 78,
- aiScore: 74,
- categories: [
- { name: 'Logic', userScore: 20, aiScore: 18, feedback: 'The causal chain was clear.' },
- { name: 'Evidence', userScore: 18, aiScore: 18, feedback: 'The factual warrant needed more support.' },
- { name: 'Delivery', userScore: 19, aiScore: 19, feedback: 'Judged from organization, not vocal performance.' },
- { name: 'Strategy', userScore: 21, aiScore: 19, feedback: 'The user prioritized the central clash.' },
- ],
- overallFeedback: 'The user won a narrow edge by weighing the main mechanism.',
- keyMoments: ['The rebuttal answered the opponent’s strongest objection.'],
- improvementAreas: ['Compare the alternative policy explicitly.'],
- winner: 'user',
- verdict: 'The user had the edge.',
-};
-
-const SESSION: SparringSession = {
- id: '00000000-0000-4000-8000-000000000010',
- motion: 'This House would make public transport free at the point of use.',
- userSide: 'pro',
- format: 'pf',
- difficulty: 'hard',
- turns: buildSparringTurns('pf', 'pro'),
- speeches: [
- {
- id: 'speech-1',
- turnId: 'pf-1',
- speaker: 'user',
- side: 'pro',
- role: 'Pro constructive',
- content: 'Universal access removes the direct price barrier to participation.',
- createdAt: 1_000,
- timeLimitSeconds: 240,
- timeUsedSeconds: 45,
- },
- {
- id: 'speech-2',
- turnId: 'pf-2',
- speaker: 'ai',
- side: 'con',
- role: 'Con constructive',
- content: 'Targeted support can preserve access while funding service quality.',
- createdAt: 2_000,
- timeLimitSeconds: 240,
- timeUsedSeconds: 60,
- },
- ],
- currentTurnIndex: 8,
- phase: 'complete',
- draft: '',
- timerRemainingSeconds: 0,
- timerRunning: false,
- timerDeadline: null,
- scorecard: SCORECARD,
- createdAt: 1_000,
- updatedAt: 3_000,
-};
-
-const SCHOOL_SESSION: SparringSession = {
- ...SESSION,
- format: 'standard',
- turns: buildSparringTurns('standard', 'pro'),
- speeches: [
- { ...SESSION.speeches[0], turnId: 'standard-1', role: 'Pro Introductor' },
- { ...SESSION.speeches[1], turnId: 'standard-2', role: 'Con Introductor' },
- ],
-};
-
-describe('sparring transcript exports', () => {
- let downloads: ReturnType;
-
- beforeEach(async () => {
- vi.clearAllMocks();
- downloads = captureDownloads();
- exportMocks.paragraphs.length = 0;
- exportMocks.textRuns.length = 0;
- exportMocks.documents.length = 0;
- exportMocks.pdfDocuments.length = 0;
- exportMocks.autoTableCalls.length = 0;
- await i18n.changeLanguage('en');
- });
-
- it('exports a translated DOCX transcript with motion, turn, and category labels', async () => {
- await i18n.changeLanguage('es');
-
- await expect(exportSparringToDocx(SESSION)).resolves.toBe(true);
- expect(downloads.at(-1)?.filename).toBe('SparringTranscript_1970-01-01.docx');
-
- const paragraphText = exportMocks.paragraphs
- .map((paragraph) => paragraph.text)
- .filter((value): value is string => typeof value === 'string');
- const runText = exportMocks.textRuns
- .map((run) => run.text)
- .filter((value): value is string => typeof value === 'string');
-
- const documentText = runText.join('');
- expect(documentText).toContain('Moción:');
- expect(documentText).toContain(SESSION.motion);
- expect(documentText).toContain('1. Constructivo a favor');
- expect(runText).toContain('2. Constructivo en contra');
- expect(runText.some((value) => value.startsWith('Lógica:'))).toBe(true);
- expect(runText).toContain('A favor');
- expect(paragraphText).toContain('PUNTUACIONES POR CATEGORÍA');
- });
-
- it('exports a translated PDF with category rows and translated turn headings', async () => {
- await i18n.changeLanguage('hu');
-
- await expect(exportSparringToPdf(SESSION)).resolves.toBe(true);
- const pdf = exportMocks.pdfDocuments[0];
- expect(pdf.save).toHaveBeenCalledWith('SparringTranscript_1970-01-01.pdf');
-
- const textCalls = (pdf.textCalls as unknown[]).flatMap((value) => Array.isArray(value) ? value : [value]);
- expect(textCalls).toContain('Vitapárbaj átirata');
- expect(textCalls).toContain('1. Pro érvelés');
- expect(textCalls).toContain('2. Kontra érvelés');
- expect(textCalls).toContain('Indítvány: This House would make public transport free at the point of use.');
-
- const categoryTable = exportMocks.autoTableCalls.find((call) => {
- const body = call.body;
- return Array.isArray(body) && body.some((row) => Array.isArray(row) && row[0] === 'Logika');
- });
- expect(categoryTable).toBeDefined();
- });
-
- it('names the school format and speaker roles in DOCX and PDF exports', async () => {
- await expect(exportSparringToDocx(SCHOOL_SESSION)).resolves.toBe(true);
- const docxText = exportMocks.textRuns.map((run) => run.text).filter((value): value is string => typeof value === 'string');
- expect(docxText).toContain('School 4-6-6-3');
- expect(docxText).toContain('1. Pro Introductor');
-
- await expect(exportSparringToPdf(SCHOOL_SESSION)).resolves.toBe(true);
- const pdf = exportMocks.pdfDocuments.at(-1);
- const textCalls = ((pdf?.textCalls as unknown[] | undefined) ?? []).flatMap((value) => Array.isArray(value) ? value : [value]);
- expect(textCalls.some((value) => typeof value === 'string' && value.includes('School 4-6-6-3'))).toBe(true);
- expect(textCalls).toContain('1. Pro Introductor');
- });
-});
diff --git a/src/test/stores/session-store.test.ts b/src/test/stores/session-store.test.ts
deleted file mode 100644
index ca2d8a75c..000000000
--- a/src/test/stores/session-store.test.ts
+++ /dev/null
@@ -1,1279 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import {
- resetMockState,
- setMockAuthUser,
- setMockTableResponse,
- supabaseMock,
-} from "@/test/mocks/supabase";
-
-vi.mock("@/lib/supabase/client", () => ({ supabase: supabaseMock }));
-
-import { useSessionStore } from "@/lib/stores/session-store";
-import { useDebateStore } from "@/lib/stores/debate-store";
-import { resetDebates as resetDebatesForTest, seedCurrentDebate, TEST_DEBATE_ID } from "@/test/helpers/debate";
-import i18n from "@/i18n";
-
-import type { GenerateOutput } from "@/lib/ai/schemas/generate";
-import type { AttackOutput } from "@/lib/ai/schemas/attack";
-import type { BrainstormOutput } from "@/lib/ai/schemas/brainstorm";
-import type { FactoidsOutput } from "@/lib/ai/schemas/factoids";
-
-const sampleGenerate: GenerateOutput = {
- caseStructure: {
- thesis: "Test thesis",
- framework: "Test framework",
- keyDefinitions: ["A"],
- burdenOfProof: "Test burden",
- },
- contentions: [],
- predictedOpponentCase: { likelyArguments: [], strategicWeaknesses: [], keyClashes: [] },
- counters: [],
- metadata: { side: "pro", format: "Test", generatedAt: new Date().toISOString() },
-};
-
-const sampleAttack: AttackOutput = {
- overallAssessment: { score: 50, summary: "ok", strengthAreas: [] },
- weaknesses: [],
- fallaciesDetected: [],
- crossExQuestions: [],
- weighingAnalysis: { currentWeighing: "none", missingComparisons: [], improvementSuggestions: [] },
-};
-
-const nowIso = '2026-02-12T10:00:00.000Z';
-
-const brainstormOutput: BrainstormOutput = {
- needsMoreInfo: false,
- clarifyingQuestions: [],
- nextSteps: ['step'],
- warningsAndTradeoffs: [],
-};
-
-const factoidsOutput: FactoidsOutput = {
- factoids: [],
- searchSuggestions: [],
- warnings: [],
- verificationStatus: 'complete' as const,
-};
-
-describe("session-store", () => {
- beforeEach(() => {
- resetMockState();
- localStorage.clear();
- seedCurrentDebate();
- useSessionStore.setState({
- generateResult: null,
- generateLoading: false,
- attackResult: null,
- attackLoading: false,
- brainstormResult: null,
- brainstormLoading: false,
- factoidsResult: null,
- factoidsLoading: false,
- askMessages: [],
- askLoading: false,
- askChatId: null,
- askChatDebateId: null,
- askPersistedCount: 0,
- savedCases: [],
- savedAttacks: [],
- savedBrainstorms: [],
- savedFactoids: [],
- savedChats: [],
- savedFlowcharts: [],
- autosavedCases: [],
- autosavedAttacks: [],
- autosavedBrainstorms: [],
- autosavedFactoids: [],
- sourceFolders: ['General'],
- sourceLibrary: [],
- pendingFlowchartData: null,
- generateForm: {},
- attackForm: {},
- brainstormForm: {},
- factoidsForm: {},
- } as Partial>);
- });
-
- beforeEach(() => {
- resetMockState();
- localStorage.clear();
- seedCurrentDebate();
- useSessionStore.setState({
- generateResult: null,
- generateLoading: false,
- attackResult: null,
- attackLoading: false,
- brainstormResult: null,
- brainstormLoading: false,
- factoidsResult: null,
- factoidsLoading: false,
- askMessages: [],
- askLoading: false,
- askChatId: null,
- askChatDebateId: null,
- askPersistedCount: 0,
- savedCases: [],
- savedAttacks: [],
- savedBrainstorms: [],
- savedFactoids: [],
- savedChats: [],
- sourceLibrary: [],
- });
- });
-
- afterEach(async () => {
- await i18n.changeLanguage("en");
- });
-
- it("adds ask messages with id and timestamp", () => {
- const store = useSessionStore.getState();
- store.addAskMessage({ role: "user", content: "Hello" });
- const messages = useSessionStore.getState().askMessages;
- expect(messages).toHaveLength(1);
- expect(messages[0].id).toBeTruthy();
- expect(messages[0].timestamp).toBeGreaterThan(0);
- });
-
- it("saves and loads a case with server id replacement", async () => {
- setMockTableResponse("cases", {
- insert: {
- id: "case_1",
- title: "My Case",
- output: sampleGenerate,
- created_at: new Date().toISOString(),
- },
- });
-
- useSessionStore.getState().setGenerateResult(sampleGenerate);
- await useSessionStore.getState().saveCurrentCase("My Case");
-
- const saved = useSessionStore.getState().savedCases;
- expect(saved).toHaveLength(1);
- expect(saved[0].id).toBe("case_1");
-
- useSessionStore.getState().setGenerateResult(null);
- useSessionStore.getState().loadSavedCase("case_1");
- expect(useSessionStore.getState().generateResult?.caseStructure.thesis).toBe("Test thesis");
- });
-
- it("saves and loads an attack result", async () => {
- setMockTableResponse("attacks", {
- insert: {
- id: "attack_1",
- title: "Attack",
- output: sampleAttack,
- created_at: new Date().toISOString(),
- },
- });
-
- useSessionStore.getState().setAttackResult(sampleAttack);
- await useSessionStore.getState().saveCurrentAttack("Attack");
-
- const saved = useSessionStore.getState().savedAttacks;
- expect(saved).toHaveLength(1);
- expect(saved[0].id).toBe("attack_1");
-
- useSessionStore.getState().setAttackResult(null);
- useSessionStore.getState().loadSavedAttack("attack_1");
- expect(useSessionStore.getState().attackResult?.overallAssessment.score).toBe(50);
- });
-
- it("adds sources and toggles active state", async () => {
- setMockTableResponse("sources", {
- insert: {
- id: "source_1",
- title: "Doc",
- content: "Test",
- type: "text",
- origin: null,
- is_active: true,
- created_at: new Date().toISOString(),
- },
- update: { is_active: false },
- });
-
- await useSessionStore.getState().addSource({ title: "Doc", content: "Test", type: "text" });
- let sources = useSessionStore.getState().sourceLibrary;
- expect(sources).toHaveLength(1);
- expect(sources[0].id).toBe("source_1");
- expect(sources[0].isActive).toBe(true);
-
- await useSessionStore.getState().toggleSource("source_1");
- sources = useSessionStore.getState().sourceLibrary;
- expect(sources[0].isActive).toBe(false);
- });
-
- it("inserts an explicitly inactive source as inactive", async () => {
- setMockTableResponse("sources", {
- insert: {
- id: "source_inactive",
- title: "Inactive source",
- content: "Test",
- type: "text",
- origin: null,
- is_active: false,
- created_at: new Date().toISOString(),
- },
- });
- const insertCalls: Array> = [];
- const originalFrom = supabaseMock.from.getMockImplementation() as unknown as (table: string) => ReturnType;
- supabaseMock.from.mockImplementation(((table: string) => {
- const builder = originalFrom(table);
- if (table === "sources") {
- const originalInsert = builder.insert;
- builder.insert = ((payload: Record) => {
- insertCalls.push(payload);
- return (originalInsert as unknown as (p: Record) => ReturnType)(payload);
- }) as typeof builder.insert;
- }
- return builder;
- }) as never);
-
- try {
- await useSessionStore.getState().addSource({
- title: "Inactive source",
- content: "Test",
- type: "text",
- isActive: false,
- });
- } finally {
- supabaseMock.from.mockImplementation(originalFrom as never);
- }
-
- expect(insertCalls[0]).toEqual(expect.objectContaining({ is_active: false }));
- expect(useSessionStore.getState().sourceLibrary[0].isActive).toBe(false);
- });
-
- it("hydrates server data when available", async () => {
- setMockTableResponse("cases", {
- order: [{ id: "case_2", title: "Server Case", output: sampleGenerate, created_at: new Date().toISOString() }],
- });
- setMockTableResponse("attacks", { order: [] });
- setMockTableResponse("brainstorms", { order: [] });
- setMockTableResponse("factoids", { order: [] });
- setMockTableResponse("chats", { order: [] });
- setMockTableResponse("sources", { order: [] });
-
- await useSessionStore.getState().hydrateFromServer();
- expect(useSessionStore.getState().savedCases).toHaveLength(1);
- expect(useSessionStore.getState().savedCases[0].title).toBe("Server Case");
- });
-
- it("skips server writes when user is not signed in", async () => {
- setMockAuthUser(null);
- useSessionStore.getState().setGenerateResult(sampleGenerate);
- await useSessionStore.getState().saveCurrentCase("Local only");
- expect(supabaseMock.from).not.toHaveBeenCalled();
- });
-
- it("clearSession resets transient results", () => {
- useSessionStore.getState().setGenerateResult(sampleGenerate);
- useSessionStore.getState().setAttackResult(sampleAttack);
- useSessionStore.getState().addAskMessage({ role: "user", content: "Hi" });
-
- useSessionStore.getState().clearSession();
- const state = useSessionStore.getState();
- expect(state.generateResult).toBeNull();
- expect(state.attackResult).toBeNull();
- expect(state.askMessages).toHaveLength(0);
- });
-
- it("keeps a source locally and rejects when its server deletion fails", async () => {
- useSessionStore.setState({
- sourceLibrary: [{
- id: "source_1",
- title: "Source",
- content: "Content",
- type: "text",
- createdAt: Date.now(),
- isActive: true,
- }],
- } as Partial>);
- supabaseMock.from.mockImplementationOnce((() => ({
- delete: () => ({ eq: async () => ({ data: null as null, error: { message: "delete failed" } }) }),
- })) as never);
-
- await expect(useSessionStore.getState().removeSource("source_1")).rejects.toThrow("delete failed");
- expect(useSessionStore.getState().sourceLibrary).toHaveLength(1);
- });
-
- it("sends provenance metadata when saving a discovered source", async () => {
- setMockAuthUser({ id: "user_1" });
- setMockTableResponse("sources", {
- insert: { id: "source_9", title: "Gov report", content: "c", type: "url", origin: "https://gov.example/data", folder: "Auto-saved", is_active: true, created_at: new Date().toISOString(), metadata: { qualityLabel: "strong" } },
- });
- const insertCalls: Array<{ table: string; payload: Record }> = [];
- const originalFrom = supabaseMock.from.getMockImplementation() as unknown as (table: string) => ReturnType;
- supabaseMock.from.mockImplementation(((table: string) => {
- const builder = originalFrom(table);
- if (table === "sources") {
- const originalInsert = builder.insert;
- builder.insert = ((payload: Record) => {
- insertCalls.push({ table, payload });
- return (originalInsert as unknown as (p: Record) => ReturnType)(payload);
- }) as typeof builder.insert;
- }
- return builder;
- }) as never);
-
- try {
- await useSessionStore.getState().addSource({
- title: "Gov report",
- content: "c",
- type: "url",
- origin: "https://gov.example/data",
- folder: "Auto-saved",
- metadata: {
- url: "https://gov.example/data",
- sourceType: "government",
- qualityScore: 82,
- qualityLabel: "strong",
- query: "policy statistics 2026",
- capturedVia: "generate",
- },
- });
- } finally {
- supabaseMock.from.mockImplementation(originalFrom as never);
- }
-
- expect(insertCalls).toHaveLength(1);
- expect(insertCalls[0].payload.metadata).toEqual({
- url: "https://gov.example/data",
- sourceType: "government",
- qualityScore: 82,
- qualityLabel: "strong",
- query: "policy statistics 2026",
- capturedVia: "generate",
- });
- });
-
- it("round-trips provenance metadata from the database into state", async () => {
- setMockAuthUser({ id: "user_1" });
- const metadata = { url: "https://gov.example/data", qualityLabel: "moderate" as const, capturedVia: "brainstorm" };
- setMockTableResponse("sources", {
- insert: { id: "source_10", title: "News", content: "c", type: "url", origin: "https://news.example/a", folder: "Auto-saved", is_active: true, created_at: new Date().toISOString(), metadata },
- });
-
- await useSessionStore.getState().addSource({
- title: "News",
- content: "c",
- type: "url",
- origin: "https://news.example/a",
- folder: "Auto-saved",
- metadata,
- });
-
- const saved = useSessionStore.getState().sourceLibrary[0];
- expect(saved.metadata).toEqual(metadata);
- });
-
- it("hydrates valid provenance metadata from the database", async () => {
- setMockTableResponse("sources", {
- order: [{
- id: "source_11",
- title: "Policy source",
- content: "c",
- type: "url",
- origin: "https://example.com/policy",
- folder: "Auto-saved",
- is_active: true,
- created_at: new Date().toISOString(),
- metadata: {
- url: "https://example.com/policy",
- sourceType: "government",
- qualityScore: 91,
- qualityLabel: "strong",
- query: "policy update",
- capturedVia: "ask",
- },
- }],
- });
-
- await useSessionStore.getState().hydrateFromServer();
-
- expect(useSessionStore.getState().sourceLibrary[0].metadata).toEqual({
- url: "https://example.com/policy",
- sourceType: "government",
- qualityScore: 91,
- qualityLabel: "strong",
- query: "policy update",
- capturedVia: "ask",
- });
- });
-
- it("drops malformed provenance fields while hydrating valid fields", async () => {
- setMockTableResponse("sources", {
- order: [{
- id: "source_12",
- title: "Mixed source",
- content: "c",
- type: "url",
- origin: "https://example.com/mixed",
- is_active: true,
- created_at: new Date().toISOString(),
- metadata: {
- url: "https://example.com/mixed",
- sourceType: 42,
- qualityScore: "91",
- qualityLabel: "invalid",
- query: "valid query",
- capturedVia: null,
- },
- }],
- });
-
- await useSessionStore.getState().hydrateFromServer();
-
- expect(useSessionStore.getState().sourceLibrary[0].metadata).toEqual({
- url: "https://example.com/mixed",
- query: "valid query",
- });
- });
-
- it("persists local collections but excludes pending session state", () => {
- const partialize = useSessionStore.persist.getOptions().partialize!;
- const savedCases = [{ id: "case_1", title: "Case", createdAt: Date.now(), data: sampleGenerate }];
- const sourceLibrary = [{
- id: "source_1",
- title: "Source",
- content: "Content",
- type: "text" as const,
- createdAt: Date.now(),
- isActive: true,
- }];
- const persisted = partialize({
- ...useSessionStore.getState(),
- savedCases,
- sourceLibrary,
- pendingFlowchartData: { nodes: [], edges: [] },
- }) as Partial>;
-
- expect(persisted.savedCases).toEqual(savedCases);
- expect(persisted.sourceLibrary).toEqual(sourceLibrary);
- expect(persisted).not.toHaveProperty("pendingFlowchartData");
- });
-
- it("keeps local data when atomic server erasure is rejected", async () => {
- useSessionStore.setState({
- savedCases: [{ id: "1", title: "Case", createdAt: Date.now(), data: sampleGenerate }],
- savedAttacks: [{ id: "2", title: "Attack", createdAt: Date.now(), data: sampleAttack }],
- } as Partial>);
- supabaseMock.rpc.mockResolvedValueOnce({
- data: null,
- error: { message: "erase failed" },
- });
-
- await expect(useSessionStore.getState().clearAllData()).rejects.toThrow("erase failed");
-
- const state = useSessionStore.getState();
- expect(state.savedCases).toHaveLength(1);
- expect(state.savedAttacks).toHaveLength(1);
- expect(supabaseMock.rpc).toHaveBeenCalledWith("clear_current_user_data");
- expect(supabaseMock.from).not.toHaveBeenCalled();
- });
-
- const seedSources = (...sources: Array<{ id: string; folder: string; isActive?: boolean }>) => {
- useSessionStore.setState({
- sourceFolders: ['General', ...sources.map((s) => s.folder)],
- sourceLibrary: sources.map((s) => ({
- id: s.id,
- title: `Source ${s.id}`,
- content: 'Evidence',
- type: 'text' as const,
- folder: s.folder,
- isActive: s.isActive ?? true,
- debateId: TEST_DEBATE_ID,
- createdAt: Date.now(),
- })),
- } as Partial>);
- };
-
- it("merges nested form updates without dropping sibling fields", () => {
- const store = useSessionStore.getState();
- store.setGenerateForm({ options: { depth: "standard", citations: true } } as any);
- store.setGenerateForm({ options: { depth: "comprehensive" } } as any);
- store.setAttackForm({ caseText: "Attack case" });
- store.setBrainstormForm({ motion: "Brainstorm motion" });
- store.setFactoidsForm({ claim: "Factoid claim" });
-
- expect(useSessionStore.getState().generateForm).toEqual({
- options: { depth: "comprehensive", citations: true },
- });
- expect(useSessionStore.getState().attackForm).toEqual({ caseText: "Attack case" });
- expect(useSessionStore.getState().brainstormForm).toEqual({ motion: "Brainstorm motion" });
- expect(useSessionStore.getState().factoidsForm).toEqual({ claim: "Factoid claim" });
- });
-
- it("flips each feature's loading flag", () => {
- const store = useSessionStore.getState();
- store.setGenerateLoading(true);
- store.setAttackLoading(true);
- store.setBrainstormLoading(true);
- store.setFactoidsLoading(true);
- store.setAskLoading(true);
-
- const state = useSessionStore.getState();
- expect([
- state.generateLoading,
- state.attackLoading,
- state.brainstormLoading,
- state.factoidsLoading,
- state.askLoading,
- ]).toEqual([true, true, true, true, true]);
- });
-
- it("updates a source and syncs its new folder into the folder list", async () => {
- seedSources({ id: "src_1", folder: "General" });
-
- await useSessionStore.getState().updateSource("src_1", {
- title: "Updated source",
- content: "Updated content",
- folder: "Round 2",
- isActive: false,
- });
-
- const updated = useSessionStore.getState().sourceLibrary[0];
- expect(updated.title).toBe("Updated source");
- expect(updated.content).toBe("Updated content");
- expect(updated.folder).toBe("Round 2");
- expect(updated.isActive).toBe(false);
- expect(useSessionStore.getState().sourceFolders).toContain("Round 2");
- });
-
- it("renames a folder and moves its sources to General when it is deleted", async () => {
- seedSources({ id: "src_1", folder: "Round 1" });
-
- await useSessionStore.getState().renameSourceFolder("Round 1", "Finals");
- expect(useSessionStore.getState().sourceFolders).toContain("Finals");
- expect(useSessionStore.getState().sourceLibrary[0].folder).toBe("Finals");
-
- await useSessionStore.getState().deleteSourceFolder("Finals", "move-to-general");
- expect(useSessionStore.getState().sourceFolders).not.toContain("Finals");
- expect(useSessionStore.getState().sourceLibrary[0].folder).toBe("General");
- });
-
- it("sets active state for every source in a folder, and clearLibrary empties it", async () => {
- seedSources({ id: "src_1", folder: "Batch" }, { id: "src_2", folder: "Batch" });
-
- await useSessionStore.getState().setFolderActive("Batch", false);
- expect(useSessionStore.getState().sourceLibrary.every((source) => !source.isActive)).toBe(true);
-
- await useSessionStore.getState().removeSource("src_1");
- expect(useSessionStore.getState().sourceLibrary.map((source) => source.id)).toEqual(["src_2"]);
-
- await useSessionStore.getState().clearLibrary();
- expect(useSessionStore.getState().sourceLibrary).toHaveLength(0);
- });
-
- it('persists local collections but excludes transient loading and streaming state', () => {
- const source = {
- id: 'local-source',
- title: 'Local source',
- content: 'Guest and offline content must survive reloads.',
- type: 'text' as const,
- createdAt: Date.now(),
- isActive: true,
- };
- const state = {
- ...useSessionStore.getState(),
- generateLoading: true,
- sourceFolders: ['General', 'Offline folder'],
- sourceLibrary: [source],
- savedCases: [{ id: 'offline-case', title: 'Offline case', createdAt: Date.now(), data: { offline: true } as any }],
- autosavedCases: [{ id: 'autosave', title: 'Draft', createdAt: Date.now(), data: { draft: true } as any }],
- askStreamingAssistant: {
- id: 'streaming',
- role: 'assistant' as const,
- content: 'Partial response',
- timestamp: Date.now(),
- isStreaming: true as const,
- },
- };
- const partialize = useSessionStore.persist.getOptions().partialize;
-
- expect(partialize).toBeTypeOf('function');
- const persisted = partialize!(state) as Partial;
-
- expect(persisted.sourceLibrary).toEqual([source]);
- expect(persisted.sourceFolders).toEqual(['General', 'Offline folder']);
- expect(persisted.savedCases).toEqual(state.savedCases);
- expect(persisted.autosavedCases).toEqual(state.autosavedCases);
- expect(persisted).not.toHaveProperty('generateLoading');
- expect(persisted).not.toHaveProperty('askStreamingAssistant');
- });
-
- it('early-returns save helpers when there is no current result/message', async () => {
- await useSessionStore.getState().saveCurrentCase('no-case');
- await useSessionStore.getState().saveCurrentAttack('no-attack');
- await useSessionStore.getState().saveCurrentBrainstorm('no-brainstorm');
- await useSessionStore.getState().saveCurrentFactoids('no-factoids');
- await useSessionStore.getState().saveCurrentChat('no-chat');
-
- expect(useSessionStore.getState().savedCases).toHaveLength(0);
- expect(useSessionStore.getState().savedAttacks).toHaveLength(0);
- expect(useSessionStore.getState().savedBrainstorms).toHaveLength(0);
- expect(useSessionStore.getState().savedFactoids).toHaveLength(0);
- expect(useSessionStore.getState().savedChats).toHaveLength(0);
- });
-
- it('keeps only local saves when user is not authenticated', async () => {
- setMockAuthUser(null);
-
- useSessionStore.getState().setBrainstormResult(brainstormOutput as any);
- useSessionStore.getState().setFactoidsResult(factoidsOutput as any);
- useSessionStore.getState().addAskMessage({ role: 'user', content: 'Q' });
-
- await useSessionStore.getState().saveCurrentBrainstorm('Local brainstorm');
- await useSessionStore.getState().saveCurrentFactoids('Local factoids');
- await useSessionStore.getState().saveCurrentChat('Local chat');
- await useSessionStore.getState().saveFlowchart('Local flow', { nodes: [], edges: [] });
-
- expect(useSessionStore.getState().savedBrainstorms).toHaveLength(1);
- expect(useSessionStore.getState().savedFactoids).toHaveLength(1);
- expect(useSessionStore.getState().savedChats).toHaveLength(1);
- expect(useSessionStore.getState().savedFlowcharts).toHaveLength(1);
- });
-
- it('saves and loads brainstorm + factoids items with server id replacement', async () => {
- setMockTableResponse('brainstorms', {
- insert: {
- id: 'brain_1',
- title: 'Brainstorm 1',
- output: brainstormOutput,
- created_at: nowIso,
- },
- });
- setMockTableResponse('factoids', {
- insert: {
- id: 'fact_1',
- title: 'Factoids 1',
- output: factoidsOutput,
- created_at: nowIso,
- },
- });
-
- useSessionStore.getState().setBrainstormResult(brainstormOutput);
- useSessionStore.getState().setFactoidsResult(factoidsOutput);
-
- await useSessionStore.getState().saveCurrentBrainstorm('Brainstorm 1');
- await useSessionStore.getState().saveCurrentFactoids('Factoids 1');
-
- expect(useSessionStore.getState().savedBrainstorms[0].id).toBe('brain_1');
- expect(useSessionStore.getState().savedFactoids[0].id).toBe('fact_1');
-
- useSessionStore.getState().setBrainstormResult(null);
- useSessionStore.getState().setFactoidsResult(null);
-
- useSessionStore.getState().loadSavedBrainstorm('brain_1');
- useSessionStore.getState().loadSavedFactoids('fact_1');
-
- expect(useSessionStore.getState().brainstormResult?.nextSteps).toEqual(['step']);
- expect(useSessionStore.getState().factoidsResult?.verificationStatus).toBe('complete');
- });
-
- it('saves chats including message rows and restores chat history', async () => {
- setMockTableResponse('chats', {
- insert: {
- id: 'chat_1',
- title: 'Saved chat',
- created_at: nowIso,
- },
- });
-
- useSessionStore.getState().addAskMessage({ role: 'user', content: 'Question' });
- useSessionStore.getState().addAskMessage({ role: 'assistant', content: 'Answer' });
-
- await useSessionStore.getState().saveCurrentChat('Saved chat');
-
- expect(useSessionStore.getState().savedChats).toHaveLength(1);
- expect(useSessionStore.getState().savedChats[0].id).toBe('chat_1');
- expect(supabaseMock.from).toHaveBeenCalledWith('messages');
-
- useSessionStore.getState().clearAskMessages();
- useSessionStore.getState().loadSavedChat('chat_1');
- expect(useSessionStore.getState().askMessages).toHaveLength(2);
- });
-
- it('rejects server saves that return no record without adding local items', async () => {
- setMockTableResponse('brainstorms', { insert: null });
- setMockTableResponse('factoids', { insert: null });
- setMockTableResponse('chats', { insert: null });
- setMockTableResponse('flowcharts', { insert: null });
-
- useSessionStore.getState().setBrainstormResult(brainstormOutput as any);
- useSessionStore.getState().setFactoidsResult(factoidsOutput as any);
- useSessionStore.getState().addAskMessage({ role: 'user', content: 'Needs local id' });
-
- await expect(useSessionStore.getState().saveCurrentBrainstorm('Brain no server')).rejects.toThrow('No record returned');
- await expect(useSessionStore.getState().saveCurrentFactoids('Fact no server')).rejects.toThrow('No record returned');
- await expect(useSessionStore.getState().saveCurrentChat('Chat no server')).rejects.toThrow('No record returned');
- await expect(useSessionStore.getState().saveFlowchart('Flow no server', { nodes: [], edges: [] })).rejects.toThrow('No record returned');
-
- const state = useSessionStore.getState();
- expect(state.savedBrainstorms).toHaveLength(0);
- expect(state.savedFactoids).toHaveLength(0);
- expect(state.savedChats).toHaveLength(0);
- expect(state.savedFlowcharts).toHaveLength(0);
- });
-
- it('saves and deletes flowcharts', async () => {
- setMockTableResponse('flowcharts', {
- insert: {
- id: 'flow_1',
- title: 'Flowchart A',
- nodes: [{ id: 'n1', position: { x: 0, y: 0 }, data: { label: 'A' }, type: 'default' }],
- edges: [],
- created_at: nowIso,
- },
- });
-
- await useSessionStore.getState().saveFlowchart('Flowchart A', {
- nodes: [{ id: 'n1', position: { x: 0, y: 0 }, data: { label: 'A' }, type: 'default' }],
- edges: [],
- });
-
- expect(useSessionStore.getState().savedFlowcharts).toHaveLength(1);
- expect(useSessionStore.getState().savedFlowcharts[0].id).toBe('flow_1');
-
- await useSessionStore.getState().deleteSavedFlowchart('flow_1');
- expect(useSessionStore.getState().savedFlowcharts).toHaveLength(0);
- });
-
- it('hydrates server data across all supported entities', async () => {
- setMockTableResponse('cases', {
- order: [{ id: 'case_1', title: 'Case', output: { key: 'case' }, created_at: nowIso }],
- });
- setMockTableResponse('attacks', {
- order: [{ id: 'attack_1', title: 'Attack', output: { key: 'attack' }, created_at: nowIso }],
- });
- setMockTableResponse('brainstorms', {
- order: [{ id: 'brain_1', title: 'Brainstorm', output: brainstormOutput, created_at: nowIso }],
- });
- setMockTableResponse('factoids', {
- order: [{ id: 'fact_1', title: 'Factoids', output: factoidsOutput, created_at: nowIso }],
- });
- setMockTableResponse('chats', {
- order: [{ id: 'chat_1', title: 'Chat', created_at: nowIso }],
- });
- setMockTableResponse('messages', {
- in: [
- {
- id: 'msg_1',
- chat_id: 'chat_1',
- role: 'user',
- content: 'Hello',
- output: null,
- created_at: nowIso,
- },
- ],
- });
- setMockTableResponse('flowcharts', {
- order: [{ id: 'flow_1', title: 'Flow', nodes: [], edges: [], created_at: nowIso }],
- });
- setMockTableResponse('sources', {
- order: [
- {
- id: 'source_1',
- title: 'Source',
- content: 'Evidence',
- type: 'url',
- origin: 'https://example.com',
- is_active: true,
- created_at: nowIso,
- },
- ],
- });
- setMockTableResponse('source_folders', {
- order: [{ id: 'folder_1', name: 'Tournament', created_at: nowIso }],
- });
-
- await useSessionStore.getState().hydrateFromServer();
-
- const state = useSessionStore.getState();
- expect(state.savedCases).toHaveLength(1);
- expect(state.savedAttacks).toHaveLength(1);
- expect(state.savedBrainstorms).toHaveLength(1);
- expect(state.savedFactoids).toHaveLength(1);
- expect(state.savedChats[0].data).toHaveLength(1);
- expect(state.savedFlowcharts).toHaveLength(1);
- expect(state.sourceLibrary).toHaveLength(1);
- expect(state.sourceFolders).toContain('Tournament');
- });
-
- it('persists empty source folders for cross-device sync', async () => {
- await useSessionStore.getState().createSourceFolder('Round 1');
-
- expect(useSessionStore.getState().sourceFolders).toContain('Round 1');
- expect(supabaseMock.from).toHaveBeenCalledWith('source_folders');
- });
-
- it('replaces local saved work with the server so purged rows cannot reappear', async () => {
- useSessionStore.setState({
- savedCases: [{ id: 'local-case', title: 'Local', createdAt: Date.now(), data: { x: 1 } as any }],
- } as Partial>);
-
- setMockTableResponse('cases', { order: [] });
- setMockTableResponse('attacks', { order: [] });
- setMockTableResponse('brainstorms', { order: [] });
- setMockTableResponse('factoids', { order: [] });
- setMockTableResponse('chats', { order: [] });
- setMockTableResponse('flowcharts', { order: [] });
- setMockTableResponse('sources', { order: [] });
- setMockTableResponse('source_folders', { order: [] });
-
- await useSessionStore.getState().hydrateFromServer();
-
- expect(useSessionStore.getState().savedCases).toEqual([]);
- });
-
- it('hydrates safely when Supabase query responses contain null data arrays', async () => {
- const originalFrom = supabaseMock.from.getMockImplementation();
- supabaseMock.from.mockImplementation((table: string) => ({
- select: vi.fn(() => ({
- order: vi.fn(async () => ({ data: null, error: null })),
- in: vi.fn(() => ({
- order: vi.fn(async () => ({ data: null, error: null })),
- })),
- })),
- insert: vi.fn(() => ({
- select: vi.fn(() => ({
- single: vi.fn(async () => ({ data: null, error: null })),
- })),
- })),
- update: vi.fn(() => ({ eq: vi.fn(async () => ({ data: null, error: null })) })),
- delete: vi.fn(() => ({ eq: vi.fn(async () => ({ data: null, error: null })), neq: vi.fn(async () => ({ data: null, error: null })) })),
- eq: vi.fn(async () => ({ data: null, error: null })),
- neq: vi.fn(async () => ({ data: null, error: null })),
- in: vi.fn(() => ({ order: vi.fn(async () => ({ data: null, error: null })) })),
- order: vi.fn(async () => ({ data: null, error: null })),
- table,
- }) as any);
-
- try {
- await useSessionStore.getState().hydrateFromServer();
- expect(useSessionStore.getState().savedCases).toHaveLength(0);
- expect(useSessionStore.getState().sourceLibrary).toHaveLength(0);
- } finally {
- if (originalFrom) supabaseMock.from.mockImplementation(originalFrom);
- }
- });
-
- it('clearAllData clears local state after successful atomic server erasure', async () => {
- useSessionStore.setState({
- savedCases: [{ id: 'case', title: 'c', createdAt: Date.now(), data: {} as any }],
- savedAttacks: [{ id: 'attack', title: 'a', createdAt: Date.now(), data: {} as any }],
- savedBrainstorms: [{ id: 'brain', title: 'b', createdAt: Date.now(), data: brainstormOutput as any }],
- savedFactoids: [{ id: 'fact', title: 'f', createdAt: Date.now(), data: factoidsOutput as any }],
- savedChats: [{ id: 'chat', title: 'chat', createdAt: Date.now(), data: [] as any }],
- savedFlowcharts: [{ id: 'flow', title: 'flow', createdAt: Date.now(), data: { nodes: [], edges: [] } as any }],
- autosavedCases: [{ id: 'autosave-case', title: 'draft', createdAt: Date.now(), data: {} as any }],
- autosavedAttacks: [{ id: 'autosave-attack', title: 'draft', createdAt: Date.now(), data: {} as any }],
- autosavedBrainstorms: [{ id: 'autosave-brainstorm', title: 'draft', createdAt: Date.now(), data: {} as any }],
- autosavedFactoids: [{ id: 'autosave-factoid', title: 'draft', createdAt: Date.now(), data: {} as any }],
- sourceLibrary: [{ id: 'src', title: 's', content: 'x', type: 'text', createdAt: Date.now(), isActive: true }],
- } as Partial>);
-
- await useSessionStore.getState().clearAllData();
-
- const state = useSessionStore.getState();
- expect(state.savedCases).toHaveLength(0);
- expect(state.savedAttacks).toHaveLength(0);
- expect(state.savedBrainstorms).toHaveLength(0);
- expect(state.savedFactoids).toHaveLength(0);
- expect(state.savedChats).toHaveLength(0);
- expect(state.savedFlowcharts).toHaveLength(0);
- expect(state.autosavedCases).toHaveLength(0);
- expect(state.autosavedAttacks).toHaveLength(0);
- expect(state.autosavedBrainstorms).toHaveLength(0);
- expect(state.autosavedFactoids).toHaveLength(0);
- expect(state.sourceLibrary).toHaveLength(0);
-
- expect(supabaseMock.rpc).toHaveBeenCalledWith('clear_current_user_data');
- expect(supabaseMock.from).not.toHaveBeenCalled();
- });
-
- it('throws clearAllData safety error when user is missing', async () => {
- setMockAuthUser(null);
- await expect(useSessionStore.getState().clearAllData()).rejects.toThrow('Cannot delete data: No authenticated user');
- });
-
- it('resets local/transient state and form fields', () => {
- useSessionStore.setState({
- generateResult: { a: 1 } as any,
- attackResult: { b: 1 } as any,
- brainstormResult: brainstormOutput as any,
- factoidsResult: factoidsOutput as any,
- askMessages: [{ id: 'm1', role: 'user', content: 'q', timestamp: Date.now() }],
- sourceLibrary: [{ id: 'src', title: 's', content: 'x', type: 'text', createdAt: Date.now(), isActive: true }],
- pendingFlowchartData: { nodes: [], edges: [] },
- generateForm: { motion: 'M' } as any,
- attackForm: { caseText: 'C' } as any,
- brainstormForm: { motion: 'B' } as any,
- factoidsForm: { claim: 'F' } as any,
- } as Partial>);
-
- useSessionStore.getState().resetLocalData();
-
- expect(useSessionStore.getState().generateResult).toBeNull();
- expect(useSessionStore.getState().sourceLibrary).toHaveLength(0);
-
- useSessionStore.getState().clearSession();
- expect(useSessionStore.getState().pendingFlowchartData).toBeNull();
- expect(useSessionStore.getState().generateForm).toEqual({});
- expect(useSessionStore.getState().attackForm).toEqual({});
- expect(useSessionStore.getState().brainstormForm).toEqual({});
- expect(useSessionStore.getState().factoidsForm).toEqual({});
- });
-
- it('handles no-op source toggles and delete/load helpers', async () => {
- await useSessionStore.getState().toggleSource('missing');
-
- setMockTableResponse('cases', {
- insert: {
- id: 'case_del',
- title: 'Delete me',
- output: { key: 1 },
- created_at: nowIso,
- },
- });
-
- useSessionStore.getState().setGenerateResult({ key: 1 } as any);
- await useSessionStore.getState().saveCurrentCase('Delete me');
- expect(useSessionStore.getState().savedCases).toHaveLength(1);
-
- await useSessionStore.getState().deleteSavedCase('case_del');
- expect(useSessionStore.getState().savedCases).toHaveLength(0);
-
- // Load helpers should no-op when id does not exist
- useSessionStore.getState().loadSavedCase('missing');
- useSessionStore.getState().loadSavedAttack('missing');
- useSessionStore.getState().loadSavedBrainstorm('missing');
- useSessionStore.getState().loadSavedFactoids('missing');
- useSessionStore.getState().loadSavedChat('missing');
- });
-
- it("localizes automatic Brainstorm and Factoids titles", async () => {
- const brainstorm: BrainstormOutput = {
- needsMoreInfo: false,
- buckets: [],
- nextSteps: [],
- warningsAndTradeoffs: [],
- };
- const factoids: FactoidsOutput = {
- factoids: [],
- searchSuggestions: [],
- warnings: [],
- verificationStatus: 'complete',
- };
-
- await i18n.changeLanguage('hu');
- useSessionStore.getState().setBrainstormResult(brainstorm);
- useSessionStore.getState().setFactoidsResult(factoids);
-
- expect(useSessionStore.getState().autosavedBrainstorms[0].title).toBe('Ötletelés');
- expect(useSessionStore.getState().autosavedFactoids[0].title).toBe('Tények');
- });
-
- it("parses valid semantic metadata and discards only an invalid semantic object", async () => {
- setMockTableResponse("sources", {
- order: [
- {
- id: "semantic-valid", title: "Valid", content: "c", type: "url", origin: "https://example.com/valid", is_active: true, created_at: new Date().toISOString(),
- metadata: { qualityLabel: "strong", semantic: { relevance: 0.9, importance: 3, strength: 2, evaluatedAt: "2026-09-21T00:00:00.000Z", useCount: 4, caseUseCount: 2, lastUsedInCase: true } },
- },
- {
- id: "semantic-invalid", title: "Invalid", content: "c", type: "url", origin: "https://example.com/invalid", is_active: true, created_at: new Date().toISOString(),
- metadata: { qualityLabel: "moderate", semantic: { relevance: 7, importance: 5 } },
- },
- ],
- });
-
- await useSessionStore.getState().hydrateFromServer();
- expect(useSessionStore.getState().sourceLibrary.find((source) => source.id === "semantic-valid")?.metadata?.semantic?.caseUseCount).toBe(2);
- expect(useSessionStore.getState().sourceLibrary.find((source) => source.id === "semantic-invalid")?.metadata).toEqual({ qualityLabel: "moderate" });
- });
-
- it("persists metadata when updating a source", async () => {
- setMockAuthUser({ id: "user_1" });
- useSessionStore.setState({ sourceLibrary: [{ id: "source_1", title: "Source", content: "Content", type: "text", createdAt: 1, isActive: true }] });
- const updates: Array> = [];
- const originalFrom = supabaseMock.from.getMockImplementation() as unknown as (table: string) => ReturnType;
- supabaseMock.from.mockImplementation(((table: string) => {
- const builder = originalFrom(table);
- if (table === "sources") {
- const originalUpdate = builder.update;
- builder.update = ((payload: Record) => {
- updates.push(payload);
- return (originalUpdate as unknown as (p: Record) => ReturnType)(payload);
- }) as typeof builder.update;
- }
- return builder;
- }) as never);
- const semantic = { relevance: 0.8, importance: 2 as const, strength: 3 as const, evaluatedAt: "2026-09-21T00:00:00.000Z", useCount: 1, caseUseCount: 1, lastUsedInCase: true };
- try {
- await useSessionStore.getState().updateSource("source_1", { metadata: { semantic } });
- } finally {
- supabaseMock.from.mockImplementation(originalFrom as never);
- }
- expect(updates[0].metadata).toEqual({ semantic });
- });
-
- it("merges semantic assessments without losing source provenance", async () => {
- useSessionStore.setState({ sourceLibrary: [{
- id: "source_1",
- title: "Source",
- content: "Content",
- type: "url",
- origin: "https://example.com/source",
- createdAt: 1,
- isActive: true,
- debateId: TEST_DEBATE_ID,
- metadata: { url: "https://example.com/source", query: "original query", qualityLabel: "strong" },
- }] });
-
- await useSessionStore.getState().applySourceAssessments([{
- key: "source_1",
- id: "source_1",
- relevance: 0.9,
- importance: 3,
- strength: 2,
- evaluatedAt: "2026-09-21T00:00:00.000Z",
- retained: true,
- usedInCase: true,
- }]);
-
- expect(useSessionStore.getState().sourceLibrary[0].metadata).toEqual(expect.objectContaining({
- url: "https://example.com/source",
- query: "original query",
- qualityLabel: "strong",
- semantic: expect.objectContaining({ useCount: 1, caseUseCount: 1, lastUsedInCase: true }),
- }));
- });
-
- describe('Current Debate attachment', () => {
- it('records a result into the debate with its side and newest first', async () => {
- setMockTableResponse('cases', {
- insert: { id: 'case-1', title: 'Motion', output: sampleGenerate, created_at: nowIso, debate_id: TEST_DEBATE_ID, side: 'pro' },
- });
- const id = await useSessionStore.getState().recordResult('case', {
- title: 'Motion', input: { motion: 'Motion' }, output: sampleGenerate, debateId: TEST_DEBATE_ID, side: 'pro',
- });
- expect(id).toBe('case-1');
- const insert = supabaseMock.from.mock.results.find((result) => result.value.insert.mock.calls.length > 0)!.value.insert;
- expect(insert).toHaveBeenCalledWith(expect.objectContaining({ user_id: 'user_1', debate_id: TEST_DEBATE_ID, side: 'pro', title: 'Motion' }));
- expect(useSessionStore.getState().savedCases[0]).toEqual(expect.objectContaining({ id: 'case-1', debateId: TEST_DEBATE_ID, side: 'pro' }));
- });
-
- it('attaches one-off work only through the ownership-checked RPC', async () => {
- useSessionStore.setState({ savedAttacks: [{ id: 'a1', title: 'One-off', createdAt: 1, data: sampleAttack, debateId: null }] });
- await useSessionStore.getState().attachItem('attack', 'a1', TEST_DEBATE_ID, 'con');
- expect(supabaseMock.rpc).toHaveBeenCalledWith('attach_to_debate', { p_kind: 'attack', p_id: 'a1', p_debate_id: TEST_DEBATE_ID, p_side: 'con' });
- expect(useSessionStore.getState().savedAttacks[0]).toEqual(expect.objectContaining({ debateId: TEST_DEBATE_ID, side: 'con' }));
- });
-
- it('records an Ask conversation as one chat and appends later messages to it', async () => {
- setMockTableResponse('chats', { insert: { id: 'chat-1', title: 'Question', created_at: nowIso, debate_id: TEST_DEBATE_ID, side: 'neutral' } });
- const store = useSessionStore.getState();
- store.addAskMessage({ role: 'user', content: 'Question' });
- store.addAskMessage({ role: 'assistant', content: 'Answer' });
- await Promise.all([store.syncAskChat(TEST_DEBATE_ID), store.syncAskChat(TEST_DEBATE_ID)]);
-
- const chatInserts = supabaseMock.from.mock.calls.filter(([table]) => table === 'chats');
- expect(chatInserts).toHaveLength(1);
- expect(useSessionStore.getState().askChatId).toBe('chat-1');
- expect(useSessionStore.getState().askPersistedCount).toBe(2);
-
- useSessionStore.getState().addAskMessage({ role: 'user', content: 'Follow-up' });
- useSessionStore.getState().addAskMessage({ role: 'assistant', content: 'More' });
- await useSessionStore.getState().syncAskChat(TEST_DEBATE_ID);
- expect(supabaseMock.from.mock.calls.filter(([table]) => table === 'chats')).toHaveLength(1);
- const messageInserts = supabaseMock.from.mock.results
- .filter((_, index) => supabaseMock.from.mock.calls[index][0] === 'messages')
- .map((result) => result.value.insert.mock.calls[0][0]);
- expect(messageInserts.map((rows: Array<{ content: string }>) => rows.map((row) => row.content)))
- .toEqual([['Question', 'Answer'], ['Follow-up', 'More']]);
- expect(useSessionStore.getState().savedChats[0]).toEqual(expect.objectContaining({ id: 'chat-1', debateId: TEST_DEBATE_ID }));
- });
-
- it('continues a loaded unsorted chat in place instead of copying it into the debate', async () => {
- const earlier = [
- { id: 'm1', role: 'user' as const, content: 'Old question', timestamp: 1 },
- { id: 'm2', role: 'assistant' as const, content: 'Old answer', timestamp: 2 },
- ];
- useSessionStore.setState({ savedChats: [{ id: 'old-chat', title: 'Old', createdAt: 1, data: earlier, debateId: null }] });
- useSessionStore.getState().loadSavedChat('old-chat');
- useSessionStore.getState().addAskMessage({ role: 'user', content: 'New question' });
- useSessionStore.getState().addAskMessage({ role: 'assistant', content: 'New answer' });
- await useSessionStore.getState().syncAskChat(TEST_DEBATE_ID);
-
- expect(supabaseMock.from.mock.calls.filter(([table]) => table === 'chats')).toHaveLength(0);
- const messages = supabaseMock.from.mock.results.find((_, index) => supabaseMock.from.mock.calls[index][0] === 'messages')!.value.insert.mock.calls[0][0];
- expect(messages.map((row: { chat_id: string; content: string }) => [row.chat_id, row.content]))
- .toEqual([['old-chat', 'New question'], ['old-chat', 'New answer']]);
- });
-
- it('does not hand an in-flight chat to a conversation started after clearing', async () => {
- setMockTableResponse('chats', { insert: { id: 'chat-1', title: 'First', created_at: nowIso, debate_id: TEST_DEBATE_ID, side: 'neutral' } });
- useSessionStore.getState().addAskMessage({ role: 'user', content: 'First' });
- useSessionStore.getState().addAskMessage({ role: 'assistant', content: 'Reply' });
- let releaseUser!: () => void;
- supabaseMock.auth.getUser.mockImplementationOnce(() => new Promise((resolve) => {
- releaseUser = () => resolve({ data: { user: { id: 'user_1' } } } as never);
- }));
- const pending = useSessionStore.getState().syncAskChat(TEST_DEBATE_ID);
- await vi.waitFor(() => expect(releaseUser).toBeTypeOf('function'));
- // The save has captured the first conversation and is waiting on the server.
- useSessionStore.getState().clearAskMessages();
- useSessionStore.getState().addAskMessage({ role: 'user', content: 'Second' });
- releaseUser();
- await pending;
-
- expect(useSessionStore.getState().askChatId).toBeNull();
- expect(useSessionStore.getState().askPersistedCount).toBe(0);
- expect(useSessionStore.getState().savedChats[0]).toEqual(expect.objectContaining({ id: 'chat-1' }));
- });
-
- it('starts a fresh Ask conversation when the Current Debate changes', () => {
- useSessionStore.setState({
- askMessages: [{ id: 'm1', role: 'user', content: 'In A', timestamp: 1 }],
- askChatId: 'chat-a',
- askChatDebateId: TEST_DEBATE_ID,
- askPersistedCount: 1,
- });
- const other = { ...useDebateStore.getState().debates[0], id: 'debate-b', motion: 'Other motion' };
- useDebateStore.setState((state) => ({ debates: [...state.debates, other], currentDebateId: 'debate-b' }));
-
- expect(useSessionStore.getState()).toEqual(expect.objectContaining({ askMessages: [], askChatId: null, askChatDebateId: null, askPersistedCount: 0 }));
- });
-
- it('never carries an Ask conversation across one-off mode', async () => {
- // A one-off conversation must not be saved into the debate on return.
- useDebateStore.setState({ oneOff: true });
- useSessionStore.getState().addAskMessage({ role: 'user', content: 'One-off question' });
- useSessionStore.getState().addAskMessage({ role: 'assistant', content: 'One-off answer' });
- useDebateStore.setState({ oneOff: false });
- expect(useSessionStore.getState().askMessages).toEqual([]);
-
- // And an open debate chat does not continue in one-off mode.
- useSessionStore.setState({
- askMessages: [{ id: 'm1', role: 'user', content: 'In debate', timestamp: 1 }],
- askChatId: 'chat-a',
- askChatDebateId: TEST_DEBATE_ID,
- askPersistedCount: 1,
- });
- useDebateStore.setState({ oneOff: true });
- expect(useSessionStore.getState()).toEqual(expect.objectContaining({ askMessages: [], askChatId: null, askPersistedCount: 0 }));
- });
-
- it('drops local work of a deleted debate and keeps unsorted work', () => {
- useSessionStore.setState({
- savedCases: [
- { id: 'in-debate', title: 'A', createdAt: 1, data: sampleGenerate, debateId: TEST_DEBATE_ID },
- { id: 'unsorted', title: 'B', createdAt: 1, data: sampleGenerate, debateId: null },
- ],
- });
- seedSources({ id: 'src_1', folder: 'General' });
- useDebateStore.setState({ debates: [], currentDebateId: null });
-
- expect(useSessionStore.getState().savedCases.map((item) => item.id)).toEqual(['unsorted']);
- expect(useSessionStore.getState().sourceLibrary).toEqual([]);
- });
-
- it('renames and deletes a folder only for the Current Debate', async () => {
- seedSources({ id: 'mine', folder: 'Auto-saved' });
- useSessionStore.setState((state) => ({
- sourceLibrary: [...state.sourceLibrary, { ...state.sourceLibrary[0], id: 'theirs', debateId: 'debate-b' }],
- }));
-
- await useSessionStore.getState().renameSourceFolder('Auto-saved', 'Research');
- const folderOf = (id: string) => useSessionStore.getState().sourceLibrary.find((source) => source.id === id)!.folder;
- expect([folderOf('mine'), folderOf('theirs')]).toEqual(['Research', 'Auto-saved']);
- expect(useSessionStore.getState().sourceFolders).toEqual(expect.arrayContaining(['Auto-saved', 'Research']));
-
- // Empty in this debate, even though another debate still uses the name.
- await useSessionStore.getState().renameSourceFolder('Research', 'Auto-saved');
- await expect(useSessionStore.getState().deleteSourceFolder('Research', 'empty-only')).resolves.toBeUndefined();
- expect(folderOf('theirs')).toBe('Auto-saved');
- });
-
- it('refuses to save a source without a debate', async () => {
- resetDebatesForTest();
- await expect(useSessionStore.getState().addSource({ title: 'Loose', content: 'x', type: 'text' }))
- .rejects.toThrow('Select or create a debate before adding sources');
- });
-
- it('keeps a tool source out of the retained debate in one-off mode', async () => {
- useDebateStore.setState({ oneOff: true });
- await expect(useSessionStore.getState().addSource({ title: 'Loose', content: 'x', type: 'text' }))
- .rejects.toThrow('Select or create a debate before adding sources');
- expect(useSessionStore.getState().sourceLibrary).toEqual([]);
- });
-
- it('clears the Library of the debate it was confirmed in', async () => {
- seedSources({ id: 'mine', folder: 'General' });
- useSessionStore.setState((state) => ({
- sourceLibrary: [...state.sourceLibrary, { ...state.sourceLibrary[0], id: 'theirs', debateId: 'debate-b' }],
- }));
- let releaseUser!: () => void;
- supabaseMock.auth.getUser.mockImplementationOnce(() => new Promise((resolve) => {
- releaseUser = () => resolve({ data: { user: { id: 'user_1' } } } as never);
- }));
- const pending = useSessionStore.getState().clearLibrary();
- await vi.waitFor(() => expect(releaseUser).toBeTypeOf('function'));
- useDebateStore.setState({ currentDebateId: 'debate-b' });
- releaseUser();
- await pending;
-
- expect(useSessionStore.getState().sourceLibrary.map((source) => source.id)).toEqual(['theirs']);
- });
-
- it('does not let an older debate load undo a switch made while it ran', async () => {
- const row = (id: string, motion: string) => ({ id, motion, settings: {}, instructions: '', last_activity_at: nowIso, created_at: nowIso });
- const server = { debates: [row('debate-a', 'A'), row('debate-b', 'B')], current: 'debate-a' };
- let releaseFirstLoad!: () => void;
- const firstLoad = new Promise((resolve) => { releaseFirstLoad = resolve; });
- let debateLoads = 0;
- const originalFrom = supabaseMock.from.getMockImplementation();
- // A chainable query whose result is read when awaited, so it reflects server state at that time.
- const query = (read: () => unknown, gate?: Promise) => {
- const builder: Record = {};
- for (const method of ['select', 'order', 'eq', 'update', 'upsert', 'maybeSingle']) builder[method] = () => builder;
- builder.then = (resolve: (value: unknown) => void) => (gate ?? Promise.resolve()).then(() => resolve({ data: read(), error: null }));
- return builder;
- };
- supabaseMock.from.mockImplementation(((table: string) => {
- if (table === 'debates') {
- debateLoads += 1;
- const snapshot = server.debates;
- return query(() => snapshot, debateLoads === 2 ? firstLoad : undefined);
- }
- const snapshot = server.current;
- return { ...query(() => ({ debate_id: snapshot })), upsert: (value: { debate_id: string }) => { server.current = value.debate_id; return query(() => null); } };
- }) as never);
- try {
- resetDebatesForTest();
- await useDebateStore.getState().hydrate();
- expect(useDebateStore.getState().currentDebateId).toBe('debate-a');
- // A refresh starts, then the user switches before it returns.
- const loading = useDebateStore.getState().hydrate();
- await vi.waitFor(() => expect(debateLoads).toBe(2));
- await useDebateStore.getState().switchDebate('debate-b');
- releaseFirstLoad();
- await loading;
- expect(useDebateStore.getState().currentDebateId).toBe('debate-b');
- } finally {
- supabaseMock.from.mockImplementation(originalFrom as never);
- }
- });
-
- it('drops server-backed lists persisted by older clients', () => {
- const migrate = useSessionStore.persist.getOptions().migrate!;
- const migrated = migrate({ savedCases: [{ id: 'old' }], sourceLibrary: [{ id: 'legacy' }], askMessages: [{ id: 'm' }], generateForm: { motion: 'kept' } }, 0) as Record;
- expect(migrated).not.toHaveProperty('savedCases');
- expect(migrated).not.toHaveProperty('askMessages');
- expect(migrated).not.toHaveProperty('sourceLibrary');
- expect(migrated.generateForm).toEqual({ motion: 'kept' });
- });
- });
-});
diff --git a/src/test/stores/settings-store.test.ts b/src/test/stores/settings-store.test.ts
deleted file mode 100644
index 319916b92..000000000
--- a/src/test/stores/settings-store.test.ts
+++ /dev/null
@@ -1,748 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import {
- resetMockState,
- setMockAuthUser,
- setMockTableResponse,
- supabaseMock,
-} from "@/test/mocks/supabase";
-
-vi.mock("@/lib/supabase/client", () => ({ supabase: supabaseMock }));
-
-import { useSettingsStore } from "@/lib/stores/settings-store";
-import i18n from "@/i18n";
-
-describe("settings-store", () => {
- beforeEach(() => {
- resetMockState();
- localStorage.clear();
- useSettingsStore.getState().resetLocalData();
- });
-
- it("uses GPT-5.6 Luna through OpenRouter for all features", () => {
- const state = useSettingsStore.getState();
- expect(state.featureConfigs.generate.provider).toBe("openrouter");
- expect(state.featureConfigs.generate.model).toBe("openai/gpt-5.6-luna");
- expect(state.featureConfigs.generate.reasoningEffort).toBe("low");
- expect(state.featureConfigs.brainstorm.temperature).toBe(0.9);
- expect(state.featureConfigs.factoids.temperature).toBe(0.3);
- });
-
- it("setFeatureConfig merges updates and persists", async () => {
- const store = useSettingsStore.getState();
- store.setFeatureConfig("generate", { temperature: 0.2 });
- await new Promise((resolve) => setTimeout(resolve, 0));
- expect(useSettingsStore.getState().featureConfigs.generate.temperature).toBe(0.2);
- expect(supabaseMock.from).toHaveBeenCalledWith("user_settings");
- });
-
- it("setSystemPrompt updates local state and persists settings", async () => {
- const store = useSettingsStore.getState();
- store.setSystemPrompt("Custom system");
- await new Promise((resolve) => setTimeout(resolve, 0));
- expect(useSettingsStore.getState().customSystemPrompt).toBe("Custom system");
- expect(supabaseMock.from).toHaveBeenCalledWith("user_settings");
- });
-
- it("setOutputLanguage updates state and persists", async () => {
- useSettingsStore.getState().setOutputLanguage("hu");
- await new Promise((resolve) => setTimeout(resolve, 0));
- expect(useSettingsStore.getState().outputLanguage).toBe("hu");
- expect(supabaseMock.from).toHaveBeenCalledWith("user_settings");
- });
-
- it("hydrateFromServer merges defaults with server configs", async () => {
- setMockTableResponse("user_settings", {
- single: {
- language: "es",
- theme: "dark",
- custom_system_prompt: "Server prompt",
- feature_configs: {
- generate: { temperature: 0.4 },
- },
- },
- });
- const store = useSettingsStore.getState();
- await store.hydrateFromServer();
-
- const state = useSettingsStore.getState();
- expect(state.language).toBe("es");
- expect(state.theme).toBe("dark");
- expect(state.customSystemPrompt).toBe("Server prompt");
- expect(state.featureConfigs.generate.temperature).toBe(0.4);
- expect(state.featureConfigs.generate.provider).toBe("openrouter");
- expect(state.featureConfigs.generate.model).toBe("openai/gpt-5.6-luna");
- expect(state.featureConfigs.attack.model).toBe("openai/gpt-5.6-luna");
- });
-
- it("keeps the language and theme a new account chose before its first sign-in", async () => {
- setMockAuthUser({ id: "user_1" } as any);
- useSettingsStore.getState().setLanguage("hu");
- useSettingsStore.getState().setOutputLanguage("hu");
- useSettingsStore.getState().setTheme("dark");
- await new Promise((resolve) => setTimeout(resolve, 0));
- resetMockState();
- setMockAuthUser({ id: "user_1" } as any);
- // The sign-up trigger provisions this row; the client has never saved it.
- setMockTableResponse("user_settings", {
- single: { language: "en", output_language: "en", theme: "system", custom_system_prompt: null, feature_configs: {} },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().language).toBe("hu");
- expect(useSettingsStore.getState().outputLanguage).toBe("hu");
- expect(i18n.language).toBe("hu");
- const settingsWrite = supabaseMock.from.mock.results.find(({ value }) => value.update.mock.calls.length)?.value;
- expect(useSettingsStore.getState().theme).toBe("dark");
- expect(settingsWrite.update).toHaveBeenCalledWith(expect.objectContaining({ language: "hu", output_language: "hu", theme: "dark" }));
- });
-
- it("applies the server language once the account has saved settings", async () => {
- useSettingsStore.getState().setLanguage("hu");
- setMockTableResponse("user_settings", {
- single: { language: "es", output_language: "es", theme: "system", feature_configs: { generate: { temperature: 0.4 } } },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().language).toBe("es");
- });
-
- it("resetLocalData restores defaults", () => {
- const store = useSettingsStore.getState();
- store.setTheme("dark");
- store.setSystemPrompt("Custom");
- store.setWikiResearchEnabled(true);
- store.resetLocalData();
-
- const state = useSettingsStore.getState();
- expect(state.theme).toBe("system");
- expect(state.customSystemPrompt).toBeUndefined();
- expect(state.featureConfigs.generate.model).toBe("openai/gpt-5.6-luna");
- expect(state.wikiResearchEnabled).toBe(false);
- });
-
- it("keeps wiki research local and omits it from server writes", async () => {
- const store = useSettingsStore.getState();
- store.setWikiResearchEnabled(true);
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- expect(useSettingsStore.getState().wikiResearchEnabled).toBe(true);
- expect(supabaseMock.from).not.toHaveBeenCalled();
-
- store.setTheme("dark");
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- const settingsQuery = supabaseMock.from.mock.results.find(({ value }) => value.update.mock.calls.length)?.value;
- expect(settingsQuery.update).toHaveBeenCalledWith(expect.objectContaining({ theme: "dark" }));
- expect(settingsQuery.update.mock.results[0].value.eq).toHaveBeenCalledWith("user_id", "user_1");
- expect(settingsQuery.upsert).not.toHaveBeenCalled();
- expect(settingsQuery.update.mock.calls[0][0]).not.toHaveProperty("wiki_research_enabled");
- });
-
- it("reports settings persistence failures", async () => {
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- const eq = vi.fn(async () => ({ data: null, error: { message: "database unavailable" } }));
- supabaseMock.from.mockImplementationOnce((() => ({ update: () => ({ eq }) })) as never);
-
- useSettingsStore.getState().setTheme("dark");
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- expect(warnSpy).toHaveBeenCalledWith("[SettingsStore] Failed to persist settings:", "database unavailable");
- warnSpy.mockRestore();
- });
-
- it("getFeatureConfig returns the stored config, or defaults when missing", () => {
- useSettingsStore.getState().setFeatureConfig("ask", { temperature: 0.42 });
- expect(useSettingsStore.getState().getFeatureConfig("ask").temperature).toBe(0.42);
-
- useSettingsStore.setState({ featureConfigs: {} } as Partial>);
- const config = useSettingsStore.getState().getFeatureConfig("ask");
- expect(config.provider).toBe("openrouter");
- expect(config.model).toBe("openai/gpt-5.6-luna");
- });
-
- it("canonicalizes legacy and unknown server configs without client credentials", async () => {
- setMockTableResponse("user_settings", {
- single: {
- feature_configs: {
- generate: {
- provider: "openai",
- model: "gpt-5.5",
- temperature: 0.4,
- reasoningEffort: "high",
- apiKey: "legacy-secret",
- baseUrl: "https://legacy.example.test",
- },
- attack: {
- provider: "unknown-provider",
- model: "unknown-model",
- temperature: 0.8,
- reasoningEffort: "invalid",
- apiKey: "another-secret",
- },
- },
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- const state = useSettingsStore.getState();
- const generate = state.featureConfigs.generate;
- const attack = state.featureConfigs.attack;
- expect(generate).toMatchObject({
- provider: "openrouter",
- model: "openai/gpt-5.6-luna",
- temperature: 0.4,
- reasoningEffort: "high",
- });
- expect(generate).not.toHaveProperty("apiKey");
- expect(generate).not.toHaveProperty("baseUrl");
- expect(attack).toMatchObject({ provider: "openrouter", model: "openai/gpt-5.6-luna", reasoningEffort: "low" });
- expect(attack).not.toHaveProperty("apiKey");
-
- const settingsWrite = supabaseMock.from.mock.results.find(({ value }) => value.update.mock.calls.length)?.value;
- expect(settingsWrite.update).toHaveBeenCalledWith(
- expect.objectContaining({
- feature_configs: expect.objectContaining({
- generate: expect.objectContaining({ provider: "openrouter", model: "openai/gpt-5.6-luna" }),
- attack: expect.objectContaining({ provider: "openrouter", model: "openai/gpt-5.6-luna" }),
- }),
- }),
- );
- expect(settingsWrite.update.mock.calls[0][0].feature_configs.generate).not.toHaveProperty("apiKey");
- expect(settingsWrite.update.mock.calls[0][0].feature_configs.generate).not.toHaveProperty("baseUrl");
- expect(settingsWrite.update.mock.results[0].value.eq).toHaveBeenCalledWith("user_id", "user_1");
- });
-
- it("moves a trial user's former GLM default to Luna", async () => {
- setMockTableResponse("user_settings", {
- single: {
- subscription_tier: "trial",
- feature_configs: {
- generate: {
- provider: "openrouter",
- model: "z-ai/glm-5.3-flash",
- temperature: 0.7,
- reasoningEffort: "low",
- },
- },
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().featureConfigs.generate.model).toBe("openai/gpt-5.6-luna");
- const settingsWrite = supabaseMock.from.mock.results.find(({ value }) => value.update.mock.calls.length)?.value;
- expect(settingsWrite.update).toHaveBeenCalledWith(expect.objectContaining({
- feature_configs: expect.objectContaining({
- generate: expect.objectContaining({ model: "openai/gpt-5.6-luna" }),
- }),
- }));
- });
-
- it("keeps GLM selected for Pro users", async () => {
- setMockTableResponse("subscriptions", {
- select: [{ status: "active", current_period_end: "2099-01-01T00:00:00.000Z" }],
- });
- setMockTableResponse("user_settings", {
- single: {
- subscription_tier: "pro",
- feature_configs: {
- generate: {
- provider: "openrouter",
- model: "z-ai/glm-5.3-flash",
- temperature: 0.7,
- reasoningEffort: "medium",
- },
- },
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().featureConfigs.generate).toMatchObject({
- model: "z-ai/glm-5.3-flash",
- reasoningEffort: "medium",
- });
- });
-
- it("keeps Auto Fast for trial users", async () => {
- setMockTableResponse("user_settings", {
- single: {
- subscription_tier: "trial",
- trial_ends_at: "2099-01-01T00:00:00.000Z",
- feature_configs: {
- generate: {
- provider: "openrouter",
- model: "auto-fast",
- temperature: 0.7,
- reasoningEffort: "high",
- },
- },
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().featureConfigs.generate).toMatchObject({
- model: "auto-fast",
- reasoningEffort: "high",
- });
- });
-
- it("moves an inaccessible Auto Pro selection to Auto Fast", async () => {
- setMockTableResponse("user_settings", {
- single: {
- subscription_tier: "trial",
- trial_ends_at: "2099-01-01T00:00:00.000Z",
- feature_configs: {
- generate: {
- provider: "openrouter",
- model: "auto-pro",
- temperature: 0.7,
- reasoningEffort: "high",
- },
- },
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().featureConfigs.generate.model).toBe("auto-fast");
- });
-
- it("keeps Auto Pro for active Pro users", async () => {
- setMockTableResponse("subscriptions", {
- select: [{ status: "active", current_period_end: "2099-01-01T00:00:00.000Z" }],
- });
- setMockTableResponse("user_settings", {
- single: {
- subscription_tier: "pro",
- feature_configs: {
- generate: {
- provider: "openrouter",
- model: "auto-pro",
- temperature: 0.7,
- reasoningEffort: "high",
- },
- },
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().featureConfigs.generate.model).toBe("auto-pro");
- });
-
- it("uses Sonnet when an active Pro user has no saved feature configs", async () => {
- setMockTableResponse("subscriptions", {
- select: [{ status: "trialing", current_period_end: "2099-01-01T00:00:00.000Z" }],
- });
- setMockTableResponse("user_settings", {
- single: {
- subscription_tier: "pro",
- feature_configs: {},
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().featureConfigs.generate.model).toBe("anthropic/claude-sonnet-5");
- expect(useSettingsStore.getState().featureConfigs.sparring.model).toBe("anthropic/claude-sonnet-5");
- });
-
- it("does not rewrite model settings when subscription access cannot be read", async () => {
- setMockTableResponse("subscriptions", { error: { message: "subscription read failed" } });
- setMockTableResponse("user_settings", {
- single: {
- subscription_tier: "pro",
- feature_configs: {
- generate: {
- provider: "openrouter",
- model: "z-ai/glm-5.3-flash",
- temperature: 0.7,
- reasoningEffort: "high",
- },
- },
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().featureConfigs.generate.model).toBe("openai/gpt-5.6-luna");
- const settingsWrites = supabaseMock.from.mock.results.filter(({ value }) =>
- value.upsert.mock.calls.length || value.update.mock.calls.length
- );
- expect(settingsWrites).toHaveLength(0);
- });
-
- it("moves a former Pro user on a trial from GLM to Luna", async () => {
- setMockTableResponse("subscriptions", { select: [] });
- setMockTableResponse("user_settings", {
- single: {
- subscription_tier: "pro",
- grace_period_ends_at: "2020-01-01T00:00:00.000Z",
- trial_ends_at: "2099-01-01T00:00:00.000Z",
- feature_configs: {
- generate: {
- provider: "openrouter",
- model: "z-ai/glm-5.3-flash",
- temperature: 0.7,
- reasoningEffort: "low",
- },
- },
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().featureConfigs.generate.model).toBe("openai/gpt-5.6-luna");
- });
-
- it("does not rewrite server feature configs that are already canonical", async () => {
- const featureConfigs = structuredClone(useSettingsStore.getState().featureConfigs);
- setMockTableResponse("user_settings", {
- single: {
- language: "en",
- output_language: "en",
- theme: "system",
- custom_system_prompt: null,
- feature_configs: featureConfigs,
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- const settingsWrites = supabaseMock.from.mock.results.filter(({ value }) =>
- value.upsert.mock.calls.length || value.update.mock.calls.length
- );
- expect(settingsWrites).toHaveLength(0);
- });
-
- it("does not write settings when user is not signed in", async () => {
- setMockAuthUser(null);
- const store = useSettingsStore.getState();
- store.setTheme("dark");
- await new Promise((resolve) => setTimeout(resolve, 0));
- expect(supabaseMock.from).not.toHaveBeenCalled();
- });
-
- it("persists a cleared system prompt as null and carries a set one into config writes", async () => {
- const store = useSettingsStore.getState();
- store.setSystemPrompt("You are a debate coach.");
- store.setFeatureConfig("generate", { temperature: 0.5 });
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- const configWrite = supabaseMock.from.mock.results
- .map(({ value }) => value.update?.mock.calls?.at(-1)?.[0])
- .filter(Boolean)
- .at(-1);
- expect(configWrite.custom_system_prompt).toBe("You are a debate coach.");
-
- store.setSystemPrompt("");
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- const clearWrite = supabaseMock.from.mock.results
- .map(({ value }) => value.update?.mock.calls?.at(-1)?.[0])
- .filter(Boolean)
- .at(-1);
- expect(clearWrite.custom_system_prompt).toBeNull();
- });
-
- it("setLanguage updates state and switches the i18n runtime", () => {
- const spy = vi.spyOn(i18n, "changeLanguage");
- useSettingsStore.getState().setLanguage("hu");
-
- expect(useSettingsStore.getState().language).toBe("hu");
- expect(spy).toHaveBeenCalledWith("hu");
- spy.mockRestore();
- });
-
- it('hydrateFromServer returns early when no server row exists', async () => {
- setMockTableResponse('user_settings', { single: null });
-
- useSettingsStore.setState({ theme: 'dark' });
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().theme).toBe('dark');
- });
-
- it('hydrateFromServer applies fallback values when server fields are empty', async () => {
- setMockTableResponse('user_settings', {
- single: {
- language: undefined,
- theme: undefined,
- custom_system_prompt: '',
- feature_configs: undefined,
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- const state = useSettingsStore.getState();
- expect(state.language).toBe('en');
- expect(state.theme).toBe('system');
- expect(state.customSystemPrompt).toBeUndefined();
- expect(state.featureConfigs.generate.model).toBe('openai/gpt-5.6-luna');
- });
-
- it('normalizes persisted state in onRehydrateStorage callback', () => {
- const onRehydrateStorage = useSettingsStore.persist.getOptions().onRehydrateStorage;
- expect(onRehydrateStorage).toBeTypeOf('function');
-
- const afterRehydrate = onRehydrateStorage?.(useSettingsStore.getState());
- if (!afterRehydrate) throw new Error('Expected a rehydration callback');
-
- const partialState = {
- featureConfigs: undefined,
- language: '',
- theme: '',
- wikiResearchEnabled: undefined,
- } as any;
-
- afterRehydrate(partialState);
-
- expect(partialState.featureConfigs.generate).toBeDefined();
- expect(partialState.language).toBe('en');
- expect(partialState.theme).toBe('system');
- expect(partialState.wikiResearchEnabled).toBe(false);
- });
-
- it('canonicalizes legacy local configs and removes client credentials', () => {
- const onRehydrateStorage = useSettingsStore.persist.getOptions().onRehydrateStorage;
- const afterRehydrate = onRehydrateStorage?.(useSettingsStore.getState());
- if (!afterRehydrate) throw new Error('Expected a rehydration callback');
-
- const partialState = {
- featureConfigs: {
- generate: {
- provider: 'google',
- model: 'gemini-3-flash-preview',
- temperature: 0.3,
- reasoningEffort: 'medium',
- apiKey: 'legacy-secret',
- baseUrl: 'https://legacy.example.test',
- },
- },
- } as any;
-
- afterRehydrate(partialState);
-
- expect(partialState.featureConfigs.generate).toMatchObject({
- provider: 'openrouter',
- model: 'google/gemini-3.8-flash',
- temperature: 0.3,
- reasoningEffort: 'medium',
- });
- expect(partialState.featureConfigs.generate).not.toHaveProperty('apiKey');
- expect(partialState.featureConfigs.generate).not.toHaveProperty('baseUrl');
- });
-
- it('normalizes a stored local mock fixture back to the real provider on rehydration', () => {
- const onRehydrateStorage = useSettingsStore.persist.getOptions().onRehydrateStorage;
- const afterRehydrate = onRehydrateStorage?.(useSettingsStore.getState());
- if (!afterRehydrate) throw new Error('Expected a rehydration callback');
-
- const partialState = {
- featureConfigs: {
- generate: { provider: 'mock', model: 'local', temperature: 0.7 },
- },
- } as any;
-
- afterRehydrate(partialState);
-
- // The in-app mock provider is gone; e2e specs stub the network instead, so
- // a leftover 'mock'/'local' pair must not survive into a live config.
- expect(partialState.featureConfigs.generate).toMatchObject({
- provider: 'openrouter',
- reasoningEffort: 'low',
- });
- expect(partialState.featureConfigs.generate.model).not.toBe('local');
- });
-
- it('durably migrates legacy local credentials to the default OpenRouter config', async () => {
- localStorage.setItem('debate-trainer-settings', JSON.stringify({
- version: 0,
- state: {
- featureConfigs: {
- generate: {
- provider: 'openai',
- model: 'gpt-5.5',
- temperature: 0.4,
- apiKey: 'legacy-secret',
- baseUrl: 'https://legacy.example.test',
- },
- },
- },
- }));
-
- await useSettingsStore.persist.rehydrate();
-
- const persisted = JSON.parse(localStorage.getItem('debate-trainer-settings') || '{}');
- expect(persisted.version).toBe(2);
- expect(persisted.state.featureConfigs.generate).toMatchObject({
- provider: 'openrouter',
- model: 'openai/gpt-5.6-luna',
- temperature: 0.4,
- reasoningEffort: 'low',
- });
- expect(persisted.state.featureConfigs.generate).not.toHaveProperty('apiKey');
- expect(persisted.state.featureConfigs.generate).not.toHaveProperty('baseUrl');
- });
-
- it('uses Luna for a saved GLM default until entitlement hydration completes', async () => {
- localStorage.setItem('debate-trainer-settings', JSON.stringify({
- version: 1,
- state: {
- featureConfigs: {
- generate: {
- provider: 'openrouter',
- model: 'z-ai/glm-5.3-flash',
- temperature: 0.7,
- reasoningEffort: 'low',
- },
- },
- },
- }));
-
- await useSettingsStore.persist.rehydrate();
-
- const persisted = JSON.parse(localStorage.getItem('debate-trainer-settings') || '{}');
- expect(persisted.version).toBe(2);
- expect(persisted.state.featureConfigs.generate.model).toBe('openai/gpt-5.6-luna');
- });
-
- it("keeps Auto Fast for trial users", async () => {
- setMockTableResponse("user_settings", {
- single: {
- subscription_tier: "trial",
- trial_ends_at: "2099-01-01T00:00:00.000Z",
- feature_configs: {
- generate: {
- provider: "openrouter",
- model: "auto-fast",
- temperature: 0.7,
- reasoningEffort: "high",
- },
- },
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().featureConfigs.generate).toMatchObject({
- model: "auto-fast",
- reasoningEffort: "high",
- });
- });
-
- it("moves an inaccessible Auto Pro selection to Auto Fast", async () => {
- setMockTableResponse("user_settings", {
- single: {
- subscription_tier: "trial",
- trial_ends_at: "2099-01-01T00:00:00.000Z",
- feature_configs: {
- generate: {
- provider: "openrouter",
- model: "auto-pro",
- temperature: 0.7,
- reasoningEffort: "high",
- },
- },
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().featureConfigs.generate.model).toBe("auto-fast");
- });
-
- it("keeps Auto Pro for active Pro users", async () => {
- setMockTableResponse("subscriptions", {
- select: [{ status: "active", current_period_end: "2099-01-01T00:00:00.000Z" }],
- });
- setMockTableResponse("user_settings", {
- single: {
- subscription_tier: "pro",
- feature_configs: {
- generate: {
- provider: "openrouter",
- model: "auto-pro",
- temperature: 0.7,
- reasoningEffort: "high",
- },
- },
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().featureConfigs.generate.model).toBe("auto-pro");
- });
-
- it('resetLocalData can preserve only the interface language', () => {
- const store = useSettingsStore.getState();
- store.setLanguage('hu');
- store.setOutputLanguage('hu');
- store.setTheme('dark');
- store.setSystemPrompt('custom');
- store.resetLocalData(true);
-
- const state = useSettingsStore.getState();
- expect(state.language).toBe('hu');
- expect(state.outputLanguage).toBe('en');
- expect(state.theme).toBe('system');
- expect(state.customSystemPrompt).toBeUndefined();
- });
-
- it('hydrateFromServer accepts Hungarian language settings', async () => {
- setMockTableResponse('user_settings', {
- single: {
- language: 'hu',
- output_language: 'hu',
- feature_configs: undefined,
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().language).toBe('hu');
- expect(useSettingsStore.getState().outputLanguage).toBe('hu');
- expect(document.documentElement.lang).toBe('hu');
- });
-
- it('hydrateFromServer rejects unsupported language settings', async () => {
- setMockTableResponse('user_settings', {
- single: {
- language: 'zz',
- output_language: 'zz',
- feature_configs: undefined,
- },
- });
-
- await useSettingsStore.getState().hydrateFromServer();
-
- expect(useSettingsStore.getState().language).toBe('en');
- expect(useSettingsStore.getState().outputLanguage).toBe('en');
- expect(document.documentElement.lang).toBe('en');
- });
-
- it('normalizes unsupported locally persisted languages', () => {
- const onRehydrateStorage = useSettingsStore.persist.getOptions().onRehydrateStorage;
- const afterRehydrate = onRehydrateStorage?.(useSettingsStore.getState());
- if (!afterRehydrate) throw new Error('Expected a rehydration callback');
-
- const partialState = {
- featureConfigs: undefined,
- language: 'zz',
- outputLanguage: 'zz',
- } as any;
-
- afterRehydrate(partialState);
-
- expect(partialState.language).toBe('en');
- expect(partialState.outputLanguage).toBe('en');
- });
-});
diff --git a/src/test/stores/sparring-store.test.ts b/src/test/stores/sparring-store.test.ts
deleted file mode 100644
index 0806a0043..000000000
--- a/src/test/stores/sparring-store.test.ts
+++ /dev/null
@@ -1,532 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { supabaseMock, resetMockState, setMockTableResponse } from '../mocks/supabase';
-import { useSparringStore, type SparringSession } from '@/lib/stores/sparring-store';
-import type { SparringScorecard, SparringSpeech } from '@/lib/ai/schemas/sparring';
-
-const { captureProductEventMock } = vi.hoisted(() => ({
- captureProductEventMock: vi.fn(),
-}));
-
-vi.mock('@/lib/supabase/client', () => ({
- supabase: supabaseMock,
-}));
-
-vi.mock('@/lib/observability', () => ({
- captureProductEvent: captureProductEventMock,
-}));
-
-const SCORECARD: SparringScorecard = {
- userScore: 76,
- aiScore: 72,
- categories: [
- { name: 'Logic', userScore: 20, aiScore: 18, feedback: 'The central causal chain was clear.' },
- { name: 'Evidence', userScore: 18, aiScore: 17, feedback: 'The round needed one stronger factual warrant.' },
- { name: 'Delivery', userScore: 19, aiScore: 18, feedback: 'Typed delivery was not inferred; organization was readable.' },
- { name: 'Strategy', userScore: 19, aiScore: 19, feedback: 'Both sides engaged the main clash.' },
- ],
- overallFeedback: 'A close round with a small advantage to the user.',
- keyMoments: ['The final rebuttal answered the strongest objection.'],
- improvementAreas: ['Add comparative weighing before the conclusion.'],
- winner: 'user',
- verdict: 'The user had a narrow edge.',
-};
-
-function getSession(): SparringSession {
- const session = useSparringStore.getState().currentSession;
- if (!session) throw new Error('Expected an active sparring session.');
- return session;
-}
-
-function speech(overrides: Partial = {}): SparringSpeech {
- return {
- id: 'ai-speech-1',
- turnId: 'standard-2',
- speaker: 'ai',
- side: 'con',
- role: 'Con Introductor',
- content: 'A narrower policy could address the same concern with fewer tradeoffs.',
- createdAt: 2_000,
- timeLimitSeconds: 240,
- timeUsedSeconds: 120,
- ...overrides,
- };
-}
-
-function validSavedRow(id: string) {
- return {
- id,
- motion: 'This House would make public transport free at the point of use.',
- user_side: 'pro',
- format: 'standard',
- difficulty: 'medium',
- turns: [
- {
- id: 'user-speech-1',
- turnId: 'standard-1',
- speaker: 'user',
- side: 'pro',
- role: 'Pro Introductor',
- content: 'Universal access removes a direct barrier to participation.',
- createdAt: 1_000,
- timeLimitSeconds: 240,
- timeUsedSeconds: 80,
- },
- speech(),
- ],
- final_evaluation: SCORECARD,
- status: 'complete',
- created_at: '2026-08-30T00:00:00.000Z',
- updated_at: '2026-08-30T00:02:00.000Z',
- };
-}
-
-describe('sparring-store', () => {
- beforeEach(() => {
- vi.useRealTimers();
- localStorage.clear();
- sessionStorage.clear();
- resetMockState();
- captureProductEventMock.mockClear();
- useSparringStore.setState({ ownerUserId: null, currentSession: null, privateSessionBackup: null, savedSessions: [], hydrated: false });
- });
-
- it('starts a session with a live user timer and preserves the draft without writing on every keystroke', () => {
- vi.useFakeTimers();
- vi.setSystemTime(1_000);
- const session = useSparringStore.getState().startSession({
- motion: ' This House would make public transport free. ',
- userSide: 'pro',
- format: 'standard',
- difficulty: 'medium',
- });
-
- expect(session.motion).toBe('This House would make public transport free.');
- expect(session.currentTurnIndex).toBe(0);
- expect(session.turns[0].speaker).toBe('user');
- expect(session.turns[0]).toMatchObject({ label: 'Pro Introductor', kind: 'introductor', timeLimitSeconds: 240 });
- expect(session.timerRemainingSeconds).toBe(240);
- expect(session.timerRunning).toBe(true);
- expect(session.timerDeadline).toBe(241_000);
-
- useSparringStore.getState().setDraft('My opening claim and warrant.');
- expect(getSession().draft).toBe('My opening claim and warrant.');
- const beforeDebounce = JSON.parse(sessionStorage.getItem('debate-sparring-storage') ?? '{}') as {
- state?: { currentSession?: { draft?: string } };
- };
- expect(beforeDebounce.state?.currentSession?.draft).toBe('');
- vi.advanceTimersByTime(250);
-
- const persisted = JSON.parse(sessionStorage.getItem('debate-sparring-storage') ?? '{}') as {
- state?: { currentSession?: { draft?: string } };
- };
- expect(persisted.state?.currentSession?.draft).toBe('My opening claim and warrant.');
- });
-
- it('keeps a private draft while a workspace session runs', () => {
- const privateSession = useSparringStore.getState().startSession({
- motion: 'Private motion',
- userSide: 'pro',
- format: 'standard',
- difficulty: 'medium',
- });
-
- useSparringStore.getState().beginWorkspaceSession();
- expect(useSparringStore.getState()).toMatchObject({ currentSession: null, privateSessionBackup: { id: privateSession.id } });
- useSparringStore.getState().startSession({
- motion: 'Workspace motion',
- userSide: 'con',
- format: 'ld',
- difficulty: 'hard',
- });
- useSparringStore.getState().endWorkspaceSession();
-
- expect(useSparringStore.getState().currentSession?.id).toBe(privateSession.id);
- expect(useSparringStore.getState().privateSessionBackup).toBeNull();
- });
-
- it('restores and abandons a persisted workspace session safely', () => {
- const session = useSparringStore.getState().startSession({
- motion: 'Workspace motion',
- userSide: 'con',
- format: 'ld',
- difficulty: 'hard',
- });
- useSparringStore.setState({ currentSession: null });
-
- expect(useSparringStore.getState().restoreWorkspaceSession(session)).toBe(true);
- expect(getSession()).toMatchObject({ id: session.id, motion: 'Workspace motion' });
- useSparringStore.getState().abandonSession();
- expect(useSparringStore.getState().currentSession).toBeNull();
- expect(useSparringStore.getState().restoreWorkspaceSession({ id: 'invalid' })).toBe(false);
- });
-
- it('migrates an in-progress legacy standard round to the school roles', async () => {
- const session = useSparringStore.getState().startSession({
- motion: 'This House would make public transport free.',
- userSide: 'pro',
- format: 'standard',
- difficulty: 'medium',
- });
- useSparringStore.setState({ currentSession: null, savedSessions: [] });
- sessionStorage.setItem('debate-sparring-storage', JSON.stringify({
- version: 1,
- state: {
- currentSession: {
- ...session,
- turns: session.turns.map((turn, index) => ({
- ...turn,
- label: index < 2 ? `${turn.side === 'pro' ? 'Pro' : 'Con'} opening` : turn.label,
- kind: index < 2 ? 'opening' : index < 4 ? 'constructive' : index < 6 ? 'rebuttal' : 'final',
- })),
- },
- savedSessions: [],
- },
- }));
- await useSparringStore.persist.rehydrate();
-
- expect(getSession().turns.map(({ label, kind }) => ({ label, kind }))).toEqual([
- { label: 'Pro Introductor', kind: 'introductor' },
- { label: 'Con Introductor', kind: 'introductor' },
- { label: 'Pro R1', kind: 'r1' },
- { label: 'Con R1', kind: 'r1' },
- { label: 'Pro R2', kind: 'r2' },
- { label: 'Con R2', kind: 'r2' },
- { label: 'Pro Conclusor', kind: 'conclusor' },
- { label: 'Con Conclusor', kind: 'conclusor' },
- ]);
- });
-
- it('rejects malformed persisted state even at the current storage version', async () => {
- sessionStorage.setItem('debate-sparring-storage', JSON.stringify({
- version: 3,
- state: {
- currentSession: { id: 'malformed', turns: 'not-an-array' },
- savedSessions: [{ id: 'also-malformed' }],
- },
- }));
-
- await useSparringStore.persist.rehydrate();
-
- expect(useSparringStore.getState()).toMatchObject({ currentSession: null, savedSessions: [] });
- });
-
- it('clears unowned or cross-user state before hydrating a signed-in user', () => {
- const session = useSparringStore.getState().startSession({
- motion: 'This House would make public transport free.',
- userSide: 'pro',
- format: 'standard',
- difficulty: 'medium',
- });
- useSparringStore.setState({ savedSessions: [session] });
-
- useSparringStore.getState().prepareForUser('user-a');
- expect(useSparringStore.getState()).toMatchObject({ ownerUserId: 'user-a', currentSession: null, savedSessions: [], hydrated: false });
-
- const ownedSession = useSparringStore.getState().startSession({
- motion: 'This House supports protected bike lanes.',
- userSide: 'con',
- format: 'ld',
- difficulty: 'hard',
- });
- useSparringStore.getState().prepareForUser('user-a');
- expect(useSparringStore.getState().currentSession?.id).toBe(ownedSession.id);
-
- useSparringStore.getState().prepareForUser('user-b');
- expect(useSparringStore.getState()).toMatchObject({ ownerUserId: 'user-b', currentSession: null, savedSessions: [], hydrated: false });
- });
-
- it('pauses, resumes, and times out against a wall-clock deadline', () => {
- vi.setSystemTime(1_000);
- useSparringStore.getState().startSession({
- motion: 'This House would make public transport free.',
- userSide: 'pro',
- format: 'standard',
- difficulty: 'medium',
- });
-
- useSparringStore.getState().pauseTimer(61_000);
- expect(getSession()).toMatchObject({
- timerRemainingSeconds: 180,
- timerRunning: false,
- timerDeadline: null,
- });
-
- useSparringStore.getState().resumeTimer(70_000);
- expect(getSession()).toMatchObject({
- timerRemainingSeconds: 180,
- timerRunning: true,
- timerDeadline: 250_000,
- });
-
- useSparringStore.getState().tickTimer(250_001);
- expect(getSession()).toMatchObject({
- timerRemainingSeconds: 0,
- timerRunning: false,
- timerDeadline: null,
- });
- });
-
- it('records each turn in order and rejects an out-of-order or duplicate AI speech', () => {
- vi.setSystemTime(1_000);
- const store = useSparringStore.getState();
- store.startSession({
- motion: 'This House would make public transport free.',
- userSide: 'pro',
- format: 'standard',
- difficulty: 'medium',
- });
-
- const afterUser = store.submitUserSpeech('Universal access removes the price barrier.', 61_000);
- expect(afterUser?.speeches).toHaveLength(1);
- expect(afterUser?.speeches[0]).toMatchObject({
- turnId: 'standard-1',
- speaker: 'user',
- side: 'pro',
- content: 'Universal access removes the price barrier.',
- timeUsedSeconds: 60,
- });
-
- expect(store.appendAiSpeech(speech({ turnId: 'standard-8' }), 62_000)).toBeNull();
- expect(getSession().speeches).toHaveLength(1);
-
- const opponentSpeech = speech();
- const afterAi = store.appendAiSpeech(opponentSpeech, 62_000);
- expect(afterAi?.speeches).toHaveLength(2);
- expect(afterAi?.currentTurnIndex).toBe(2);
- expect(afterAi?.turns[2].speaker).toBe('user');
-
- // Simulate a stale in-flight response arriving while the same AI turn is
- // still active. The store must not append it a second time.
- const activeAiSession = {
- ...getSession(),
- currentTurnIndex: 1,
- speeches: [afterAi?.speeches[0] ?? opponentSpeech, opponentSpeech],
- };
- useSparringStore.setState({ currentSession: activeAiSession });
- expect(store.appendAiSpeech(opponentSpeech, 63_000)).toBeNull();
- expect(getSession().speeches.filter((item) => item.id === opponentSpeech.id)).toHaveLength(1);
- });
-
- it('captures the zero-based final turn index when the round enters judging', () => {
- const store = useSparringStore.getState();
- const session = store.startSession({
- motion: 'This House would make public transport free.',
- userSide: 'pro',
- format: 'standard',
- difficulty: 'medium',
- });
- const finalTurnIndex = session.turns.length - 1;
- useSparringStore.setState({
- currentSession: {
- ...session,
- currentTurnIndex: finalTurnIndex,
- timerRemainingSeconds: session.turns[finalTurnIndex].timeLimitSeconds,
- timerRunning: false,
- timerDeadline: null,
- },
- });
-
- const completedRound = store.appendAiSpeech(speech({
- id: 'ai-final-speech',
- turnId: session.turns[finalTurnIndex].id,
- role: session.turns[finalTurnIndex].label,
- timeLimitSeconds: session.turns[finalTurnIndex].timeLimitSeconds,
- }), 10_000);
-
- expect(completedRound?.phase).toBe('judging');
- // The store's currentTurnIndex is zero-based, so the final standard turn is index 7.
- expect(captureProductEventMock).toHaveBeenCalledWith('sparring_round_completed', {
- format: 'standard',
- roundIndex: finalTurnIndex,
- });
-
- expect(store.appendAiSpeech(speech({
- id: 'ai-final-retry',
- turnId: session.turns[finalTurnIndex].id,
- }), 11_000)).toBeNull();
- expect(captureProductEventMock).toHaveBeenCalledTimes(1);
- });
-
- it('rejects a speech that exceeds the server content limit without advancing', () => {
- const store = useSparringStore.getState();
- store.startSession({
- motion: 'This House would make public transport free.',
- userSide: 'pro',
- format: 'standard',
- difficulty: 'medium',
- });
-
- expect(store.submitUserSpeech('x'.repeat(6_001))).toBeNull();
- expect(getSession()).toMatchObject({ currentTurnIndex: 0, speeches: [] });
- });
-
- it('rejects a speech after its saved deadline without advancing', () => {
- const store = useSparringStore.getState();
- store.startSession({
- motion: 'This House would make public transport free.',
- userSide: 'pro',
- format: 'standard',
- difficulty: 'medium',
- });
-
- const deadline = getSession().timerDeadline;
- if (deadline === null) throw new Error('Expected a running timer.');
- expect(store.submitUserSpeech('A late speech.', deadline + 1)).toBeNull();
- expect(getSession()).toMatchObject({ currentTurnIndex: 0, speeches: [] });
- });
-
- it('moves to judging and saves a completed scorecard', () => {
- const store = useSparringStore.getState();
- const session = store.startSession({
- motion: 'This House would make public transport free.',
- userSide: 'pro',
- format: 'ld',
- difficulty: 'hard',
- });
- useSparringStore.setState({
- currentSession: {
- ...session,
- currentTurnIndex: session.turns.length,
- phase: 'judging',
- timerRemainingSeconds: 0,
- timerRunning: false,
- timerDeadline: null,
- },
- });
-
- const completed = store.completeSession(SCORECARD, 10_000);
- expect(completed?.phase).toBe('complete');
- expect(completed?.scorecard).toEqual(SCORECARD);
- expect(useSparringStore.getState().savedSessions).toEqual([completed]);
- expect(captureProductEventMock).toHaveBeenCalledWith('sparring_session_judged', {
- format: 'ld',
- winner: 'user',
- speechCount: 0,
- });
-
- expect(store.completeSession(SCORECARD, 11_000)).toBeNull();
- expect(captureProductEventMock).toHaveBeenCalledTimes(1);
- });
-
- it('hydrates only complete rows that pass the nested speech and scorecard schemas', async () => {
- setMockTableResponse('sparring_sessions', {
- select: [
- validSavedRow('valid-row'),
- { ...validSavedRow('bad-speech'), turns: [{ ...speech(), content: 42 }] },
- { ...validSavedRow('bad-scorecard'), final_evaluation: { ...SCORECARD, categories: [] } },
- { ...validSavedRow('in-progress'), status: 'in_progress' },
- ],
- });
-
- await useSparringStore.getState().hydrateSavedSessions();
-
- expect(useSparringStore.getState().savedSessions).toHaveLength(1);
- expect(useSparringStore.getState().savedSessions[0].id).toBe('valid-row');
- expect(useSparringStore.getState().savedSessions[0].phase).toBe('complete');
- expect(useSparringStore.getState().hydrated).toBe(true);
- });
-
- it('starts a voice round, keeps a spoken speech\'s measured facts, and reloads both', async () => {
- const delivery = { speakingSeconds: 75, wordCount: 160, wordsPerMinute: 128, fillerCount: 2, fillers: [{ word: 'um', count: 2 }], longPauses: 1, longestPauseSeconds: 4 };
- const store = useSparringStore.getState();
- store.startSession({ motion: 'This House would make public transport free.', userSide: 'pro', format: 'standard', difficulty: 'medium', voice: true });
- expect(getSession().voice).toBe(true);
-
- const next = useSparringStore.getState().submitUserSpeech('Universal access removes the price barrier.', undefined, delivery);
- expect(next?.speeches[0].delivery).toEqual(delivery);
-
- // A speech typed in the same voice round carries no measurements.
- const typed = useSparringStore.getState();
- typed.startSession({ motion: 'This House would make public transport free.', userSide: 'pro', format: 'standard', difficulty: 'medium', voice: true });
- expect(useSparringStore.getState().submitUserSpeech('A typed speech.')?.speeches[0]).not.toHaveProperty('delivery');
-
- useSparringStore.setState({ currentSession: next });
- const persisted = JSON.stringify({ version: 4, state: { ownerUserId: null, currentSession: next, privateSessionBackup: null, savedSessions: [] } });
- useSparringStore.setState({ currentSession: null });
- sessionStorage.setItem('debate-sparring-storage', persisted);
- await useSparringStore.persist.rehydrate();
- expect(getSession().voice).toBe(true);
- expect(getSession().speeches[0].delivery).toEqual(delivery);
- });
-
- it('starts typed rounds without a voice marker, and loads sessions saved before voice rounds', async () => {
- const session = useSparringStore.getState().startSession({ motion: 'This House would make public transport free.', userSide: 'pro', format: 'standard', difficulty: 'medium' });
- expect(session).not.toHaveProperty('voice');
-
- // Rows saved before voice rounds have no delivery facts; rows saved after may.
- const spoken = validSavedRow('spoken-row');
- const delivery = { speakingSeconds: 60, wordCount: 120, wordsPerMinute: 120, fillerCount: 0, fillers: [] as Array<{ word: string; count: number }>, longPauses: 0, longestPauseSeconds: 0 };
- spoken.turns[0] = { ...spoken.turns[0], delivery } as typeof spoken.turns[0];
- setMockTableResponse('sparring_sessions', { select: [validSavedRow('typed-row'), spoken] });
- await useSparringStore.getState().hydrateSavedSessions();
-
- const saved = useSparringStore.getState().savedSessions;
- expect(saved.map((item) => item.id).sort()).toEqual(['spoken-row', 'typed-row']);
- expect(saved.find((item) => item.id === 'typed-row')?.speeches[0]).not.toHaveProperty('delivery');
- expect(saved.find((item) => item.id === 'spoken-row')?.speeches[0].delivery).toEqual(delivery);
- });
-
- it('caps hydrated sparring history to fifty sessions', async () => {
- setMockTableResponse('sparring_sessions', {
- select: Array.from({ length: 55 }, (_, index) => ({
- ...validSavedRow(`saved-row-${index}`),
- updated_at: new Date(Date.UTC(2026, 7, 30, 0, index)).toISOString(),
- })),
- });
-
- await useSparringStore.getState().hydrateSavedSessions();
-
- expect(useSparringStore.getState().savedSessions).toHaveLength(50);
- expect(useSparringStore.getState().savedSessions[0].id).toBe('saved-row-54');
- });
-
- it('discards a hydration response after local auth data is reset', async () => {
- let finishQuery!: (value: { data: unknown[]; error: null }) => void;
- supabaseMock.from.mockImplementationOnce(() => ({
- select: () => ({
- order: () => new Promise((resolve) => {
- finishQuery = resolve;
- }),
- }),
- } as ReturnType));
-
- const hydration = useSparringStore.getState().hydrateSavedSessions();
- await vi.waitFor(() => expect(supabaseMock.from).toHaveBeenCalledWith('sparring_sessions'));
- useSparringStore.getState().resetLocalData();
- finishQuery({ data: [validSavedRow('stale-user-row')], error: null });
- await hydration;
-
- expect(useSparringStore.getState()).toMatchObject({ savedSessions: [], hydrated: false });
- });
-
- it('rolls back the local deletion when the server delete fails', async () => {
- const session = useSparringStore.getState().startSession({
- motion: 'This House would make public transport free.',
- userSide: 'pro',
- format: 'standard',
- difficulty: 'easy',
- });
- useSparringStore.setState({
- savedSessions: [{
- ...session,
- phase: 'complete',
- currentTurnIndex: session.turns.length,
- scorecard: SCORECARD,
- timerRemainingSeconds: 0,
- timerRunning: false,
- timerDeadline: null,
- }],
- });
-
- supabaseMock.from.mockImplementationOnce(() => ({
- delete: () => ({
- eq: async () => ({ data: null as unknown, error: { message: 'delete unavailable' } }),
- }),
- } as unknown as ReturnType));
-
- await expect(useSparringStore.getState().deleteSavedSession(session.id))
- .rejects.toThrow('delete unavailable');
- expect(useSparringStore.getState().savedSessions).toHaveLength(1);
- expect(useSparringStore.getState().savedSessions[0].id).toBe(session.id);
- });
-});
diff --git a/src/test/stripe.test.ts b/src/test/stripe.test.ts
deleted file mode 100644
index 38b86ec14..000000000
--- a/src/test/stripe.test.ts
+++ /dev/null
@@ -1,267 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-const { getSessionMock, invokeMock, getWebAppRedirectUrlMock, openExternalUrlMock } = vi.hoisted(() => ({
- getSessionMock: vi.fn(),
- invokeMock: vi.fn(),
- getWebAppRedirectUrlMock: vi.fn(),
- openExternalUrlMock: vi.fn(),
-}));
-
-vi.mock('@/lib/supabase/client', () => ({
- supabase: {
- auth: {
- getSession: getSessionMock,
- },
- functions: {
- invoke: invokeMock,
- },
- },
-}));
-
-vi.mock('@/lib/native', () => ({
- getWebAppRedirectUrl: getWebAppRedirectUrlMock,
- openExternalUrl: openExternalUrlMock,
-}));
-
-const loadStripeModule = async () => {
- vi.resetModules();
- return import('@/lib/stripe');
-};
-
-describe('stripe helpers', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- vi.unstubAllEnvs();
- getSessionMock.mockResolvedValue({
- data: { session: { access_token: 'token-123' } },
- });
- getWebAppRedirectUrlMock.mockImplementation((path: string) => `${window.location.origin}${path}`);
- openExternalUrlMock.mockResolvedValue(undefined);
- });
-
- afterEach(() => {
- vi.unstubAllEnvs();
- });
-
- it('requires auth before checkout redirect', async () => {
- getSessionMock.mockResolvedValueOnce({ data: { session: null } });
- const { redirectToCheckout } = await loadStripeModule();
-
- await expect(redirectToCheckout()).rejects.toThrow('Please sign in to upgrade');
- expect(invokeMock).not.toHaveBeenCalled();
- });
-
- it('redirects to checkout and sends expected payload', async () => {
- invokeMock.mockResolvedValueOnce({ data: { url: `${window.location.origin}/checkout` }, error: null });
- const { redirectToCheckout } = await loadStripeModule();
-
- await redirectToCheckout('PROMO10');
-
- expect(invokeMock).toHaveBeenCalledWith('create-checkout', {
- headers: { Authorization: 'Bearer token-123' },
- body: expect.objectContaining({
- promoCode: 'PROMO10',
- successUrl: `${window.location.origin}/app/settings?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
- cancelUrl: `${window.location.origin}/app/settings?checkout=canceled`,
- }),
- });
- expect(openExternalUrlMock).toHaveBeenCalledWith(`${window.location.origin}/checkout`);
- });
-
- it('uses public HTTPS callback URLs for native checkout', async () => {
- getWebAppRedirectUrlMock.mockImplementation((path: string) => `https://debate.pi-stack.org${path}`);
- invokeMock.mockResolvedValueOnce({ data: { url: 'https://checkout.stripe.com/session' }, error: null });
- const { redirectToCheckout } = await loadStripeModule();
-
- await redirectToCheckout();
-
- expect(invokeMock).toHaveBeenCalledWith('create-checkout', {
- headers: { Authorization: 'Bearer token-123' },
- body: expect.objectContaining({
- successUrl: 'https://debate.pi-stack.org/app/settings?checkout=success&session_id={CHECKOUT_SESSION_ID}',
- cancelUrl: 'https://debate.pi-stack.org/app/settings?checkout=canceled',
- }),
- });
- });
-
- it('does not send a client-configured Stripe price ID', async () => {
- vi.stubEnv('VITE_STRIPE_PRICE_ID', 'price_abc123');
- invokeMock.mockResolvedValueOnce({ data: { url: `${window.location.origin}/checkout-with-price` }, error: null });
- const { redirectToCheckout } = await loadStripeModule();
-
- await redirectToCheckout();
-
- expect(invokeMock).toHaveBeenCalledWith('create-checkout', {
- headers: { Authorization: 'Bearer token-123' },
- body: expect.not.objectContaining({
- priceId: expect.anything(),
- }),
- });
- });
-
- it('parses edge-function error body from Response-like context', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code',
- context: {
- status: 400,
- text: async () => JSON.stringify({ error: 'Invalid checkout request' }),
- },
- },
- });
- const { redirectToCheckout } = await loadStripeModule();
-
- await expect(redirectToCheckout()).rejects.toThrow('Invalid checkout request');
- });
-
- it('falls back to HTTP status when edge-function error body is missing', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code',
- context: {
- status: 503,
- },
- },
- });
- const { redirectToCheckout } = await loadStripeModule();
-
- await expect(redirectToCheckout()).rejects.toThrow('Request failed (HTTP 503)');
- });
-
- it('uses a plain context body string error when present', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code',
- context: {
- body: JSON.stringify({ error: 'Body string error' }),
- },
- },
- });
- const { redirectToCheckout } = await loadStripeModule();
-
- await expect(redirectToCheckout()).rejects.toThrow('Body string error');
- });
-
- it('returns custom non-generic edge-function messages directly', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Billing backend is unavailable',
- },
- });
- const { redirectToCheckout } = await loadStripeModule();
-
- await expect(redirectToCheckout()).rejects.toThrow('Billing backend is unavailable');
- });
-
- it('falls back to a generic message when status and message are missing', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {},
- });
- const { redirectToCheckout } = await loadStripeModule();
-
- await expect(redirectToCheckout()).rejects.toThrow('Request failed');
- });
-
- it('handles invalid JSON in response text and still falls back to status', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code',
- context: {
- status: 502,
- text: async () => '{invalid-json',
- },
- },
- });
- const { redirectToCheckout } = await loadStripeModule();
-
- await expect(redirectToCheckout()).rejects.toThrow('Request failed (HTTP 502)');
- });
-
- it('throws when checkout URL is not returned', async () => {
- invokeMock.mockResolvedValueOnce({ data: {}, error: null });
- const { redirectToCheckout } = await loadStripeModule();
-
- await expect(redirectToCheckout()).rejects.toThrow('No checkout URL returned');
- });
-
- it('redirects to billing portal', async () => {
- invokeMock.mockResolvedValueOnce({ data: { url: `${window.location.origin}/portal` }, error: null });
- const { redirectToPortal } = await loadStripeModule();
-
- await redirectToPortal();
-
- expect(invokeMock).toHaveBeenCalledWith('create-portal', {
- headers: { Authorization: 'Bearer token-123' },
- body: { returnUrl: `${window.location.origin}/app/settings` },
- });
- });
-
- it('requires auth before portal redirect', async () => {
- getSessionMock.mockResolvedValueOnce({ data: { session: null } });
- const { redirectToPortal } = await loadStripeModule();
-
- await expect(redirectToPortal()).rejects.toThrow('Please sign in to manage subscription');
- });
-
- it('returns parsed redeem-promo errors without throwing', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code',
- context: {
- body: { error: 'Promo code already redeemed' },
- },
- },
- });
- const { redeemPromoCode } = await loadStripeModule();
-
- await expect(redeemPromoCode('USED-CODE')).resolves.toEqual({
- success: false,
- message: 'Promo code already redeemed',
- });
- });
-
- it('returns fallback redeem-promo error text when payload has no message', async () => {
- invokeMock.mockResolvedValueOnce({
- data: null,
- error: {
- message: 'Edge Function returned a non-2xx status code',
- context: {
- status: 429,
- },
- },
- });
- const { redeemPromoCode } = await loadStripeModule();
-
- await expect(redeemPromoCode('RATE-LIMIT')).resolves.toEqual({
- success: false,
- message: 'Request failed (HTTP 429)',
- });
- });
-
- it('returns redeem-promo success payload', async () => {
- invokeMock.mockResolvedValueOnce({
- data: { success: true, message: 'Promo applied' },
- error: null,
- });
- const { redeemPromoCode } = await loadStripeModule();
-
- await expect(redeemPromoCode('NEW-CODE')).resolves.toEqual({
- success: true,
- message: 'Promo applied',
- });
- });
-
- it('requires auth before redeeming promo codes', async () => {
- getSessionMock.mockResolvedValueOnce({ data: { session: null } });
- const { redeemPromoCode } = await loadStripeModule();
-
- await expect(redeemPromoCode('ANY')).rejects.toThrow('Please sign in to redeem a code');
- });
-});
diff --git a/src/test/supabase/pinned-env.test.ts b/src/test/supabase/pinned-env.test.ts
new file mode 100644
index 000000000..dc8b9393e
--- /dev/null
+++ b/src/test/supabase/pinned-env.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { resolveSupabaseConfig } from "@/lib/supabase/env";
+
+const noToggle = { getItem: (): string | null => null };
+const stagingToggle = { getItem: () => "staging" };
+
+describe("build-pinned Supabase project", () => {
+ it("uses staging when the build pins staging, with no saved toggle", () => {
+ expect(resolveSupabaseConfig({ VITE_SUPABASE_ENV: "staging" }, noToggle)).toMatchObject({
+ env: "staging",
+ url: "https://brifeyypjauyusmkptkx.supabase.co",
+ });
+ });
+
+ it("ignores a saved staging toggle when the build pins production", () => {
+ expect(resolveSupabaseConfig({ VITE_SUPABASE_ENV: "production" }, stagingToggle)).toMatchObject({
+ env: "production",
+ url: "https://uziwdtkzofptihahfooo.supabase.co",
+ });
+ });
+
+ it("fails closed on an unknown pinned project", () => {
+ expect(() => resolveSupabaseConfig({ VITE_SUPABASE_ENV: "prod" }, noToggle))
+ .toThrow("Unknown VITE_SUPABASE_ENV");
+ });
+
+ it("keeps the saved toggle when nothing is pinned", () => {
+ expect(resolveSupabaseConfig({}, stagingToggle)).toMatchObject({ env: "staging" });
+ expect(resolveSupabaseConfig({ VITE_SUPABASE_ENV: " " }, noToggle)).toMatchObject({ env: "production" });
+ });
+});
diff --git a/src/test/supabase/prompt-builder.test.ts b/src/test/supabase/prompt-builder.test.ts
deleted file mode 100644
index e25291a83..000000000
--- a/src/test/supabase/prompt-builder.test.ts
+++ /dev/null
@@ -1,207 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { buildUserPrompt, getAskModeGuidance } from '../../../supabase/functions/ai/prompt-builder';
-
-describe('supabase prompt-builder', () => {
- it('builds generate prompts with language and defaults', () => {
- const prompt = buildUserPrompt('generate', {
- motion: 'This house supports public transit subsidies',
- side: 'pro',
- language: 'es',
- }, 'SEARCH CONTEXT');
-
- expect(prompt).toContain('Motion: This house supports public transit subsidies');
- expect(prompt).toContain('Side: pro');
- expect(prompt).toContain('Length Instruction: Return exactly 3 contentions');
- expect(prompt).toContain('Audience Instruction: Assume a trained debater');
- expect(prompt).toContain('Tone Instruction: Use polished, judge-facing language');
- expect(prompt).toContain('Language: Spanish (es)');
- expect(prompt).toContain('SEARCH CONTEXT');
- });
-
- it('preserves every generate role and length combination', () => {
- const lengths = [
- ['brief', 2],
- ['standard', 3],
- ['comprehensive', 4],
- ] as const;
- const roles = ['intro', 'r1', 'r2', 'conc'] as const;
-
- for (const role of roles) {
- for (const [length, contentionCount] of lengths) {
- const prompt = buildUserPrompt('generate', {
- motion: 'MATRIX MOTION',
- side: 'con',
- format: 'custom',
- role,
- length,
- language: 'hu',
- }, 'RESEARCH CONTEXT');
-
- expect(prompt).toContain(`Role: ${role}`);
- expect(prompt).toContain(`Length: ${length}`);
- expect(prompt).toContain(`Length Instruction: Return exactly ${contentionCount} contentions`);
- expect(prompt).toContain('Language: Hungarian (hu)');
- }
- }
- });
-
- it('builds attack prompts with toggle states', () => {
- const prompt = buildUserPrompt('attack', {
- caseText: 'Case text',
- detectFallacies: false,
- analyzeWeighing: true,
- }, 'EXTRA');
-
- expect(prompt).toContain('Detect Fallacies: disabled');
- expect(prompt).toContain('Analyze Weighing: enabled');
- expect(prompt).toContain('CASE TEXT:');
- expect(prompt).toContain('EXTRA');
- });
-
- it('preserves attack intensity, paradigms, and toggle combinations', () => {
- for (const aggressiveness of ['moderate', 'ruthless'] as const) {
- for (const judgeParadigm of ['logic', 'values', 'policy'] as const) {
- for (const [detectFallacies, analyzeWeighing] of [[true, true], [true, false], [false, true], [false, false]] as const) {
- const prompt = buildUserPrompt('attack', {
- caseText: 'MATRIX CASE',
- aggressiveness,
- judgeParadigm,
- detectFallacies,
- analyzeWeighing,
- }, 'RESEARCH CONTEXT');
-
- expect(prompt).toContain(`Attack Intensity: ${aggressiveness}`);
- expect(prompt).toContain(`Judge Paradigm: ${judgeParadigm}`);
- expect(prompt).toContain(`Detect Fallacies: ${detectFallacies ? 'enabled' : 'disabled'}`);
- expect(prompt).toContain(`Analyze Weighing: ${analyzeWeighing ? 'enabled' : 'disabled'}`);
- }
- }
- }
- });
-
- it('builds brainstorm prompts with optional context and defaults', () => {
- const prompt = buildUserPrompt('brainstorm', {
- motion: 'Ban private jets',
- }, 'SEARCH');
-
- expect(prompt).toContain('Motion: Ban private jets');
- expect(prompt).toContain('Side: both');
- expect(prompt).toContain('Output Mode: topfive');
- });
-
- it('preserves brainstorm output modes for one side and both sides', () => {
- for (const side of ['pro', 'con', 'both'] as const) {
- for (const outputMode of ['topfive', 'mindmap', 'buckets'] as const) {
- const prompt = buildUserPrompt('brainstorm', {
- motion: 'MATRIX MOTION',
- side,
- outputMode,
- }, 'RESEARCH CONTEXT');
-
- expect(prompt).toContain(`Side: ${side}`);
- expect(prompt).toContain(`Output Mode: ${outputMode}`);
- }
- }
- });
-
- it('builds factoids prompts with language and filters', () => {
- const prompt = buildUserPrompt('factoids', {
- claim: 'Solar is cheaper than coal',
- sourceType: 'government',
- language: 'en',
- }, 'CTX');
-
- expect(prompt).toContain('Find evidence in favor of this statement: "Solar is cheaper than coal"');
- expect(prompt).toContain('Source Type: government');
- expect(prompt).toContain('Language: English (en)');
- });
-
- it('preserves factoid source, timeframe, direction, and count inputs', () => {
- const fixtures = [
- { claim: 'Is this law constitutional?', direction: 'against', sourceType: 'government', timeframe: '2024-2026', sourceCount: 9, language: 'hu' },
- { claim: 'What do the latest statistics show?', direction: 'for', sourceType: 'government', timeframe: '2025-2026', sourceCount: 12, language: 'en' },
- { claim: 'Does the academic literature support this claim?', direction: 'for', sourceType: 'academic', timeframe: 'Recent (last 5 years)', sourceCount: 5, language: 'es' },
- ] as const;
-
- for (const fixture of fixtures) {
- const prompt = buildUserPrompt('factoids', fixture, 'RESEARCH CONTEXT');
- expect(prompt).toContain(`Find evidence ${fixture.direction === 'against' ? 'against' : 'in favor of'} this statement: "${fixture.claim}"`);
- expect(prompt).toContain(`Source Type: ${fixture.sourceType}`);
- expect(prompt).toContain(`Timeframe: ${fixture.timeframe}`);
- expect(prompt).toContain(`Source Count: ${fixture.sourceCount}`);
- expect(prompt).toContain(`Language: ${fixture.language === 'hu' ? 'Hungarian' : fixture.language === 'es' ? 'Spanish' : 'English'} (${fixture.language})`);
- }
- });
-
- it('builds ask prompts with mode guidance fallback', () => {
- const prompt = buildUserPrompt('ask', {
- message: 'Help me prep',
- mode: 'unknown-mode',
- }, '');
-
- expect(prompt).toContain('Mode: unknown-mode');
- expect(prompt).toContain('Mode Guidance: Give a direct coaching answer');
- });
-
- it('preserves general and clarify ask guidance', () => {
- for (const [mode, guidance] of [['general', 'Give a direct coaching answer'], ['clarify', 'Define unclear terms']] as const) {
- const prompt = buildUserPrompt('ask', { message: 'MATRIX QUESTION', mode }, '');
- expect(prompt).toContain(`Mode: ${mode}`);
- expect(prompt).toContain(`Mode Guidance: ${guidance}`);
- expect(prompt).not.toContain('RESEARCH CITATION RULES:');
- }
- });
-
- it('returns ask mode guidance for known and unknown modes', () => {
- expect(getAskModeGuidance('argumentmap')).toContain('include argumentMap');
- expect(getAskModeGuidance('clarify')).toContain('follow-up questions');
- expect(getAskModeGuidance('not-a-mode')).toContain('direct coaching answer');
- });
-
- it('uses safe fallback values for invalid generate fields', () => {
- const prompt = buildUserPrompt('generate', {
- motion: '',
- side: 123,
- format: null,
- role: undefined,
- length: '',
- audience: '',
- tone: '',
- language: 'zz',
- }, '');
-
- expect(prompt).toContain('Motion: ');
- expect(prompt).toContain('Side: pro');
- expect(prompt).toContain('Format: custom');
- expect(prompt).toContain('Role: intro');
- expect(prompt).toContain('Length: standard');
- expect(prompt).toContain('Audience: intermediate');
- expect(prompt).toContain('Tone: formal');
- expect(prompt).toContain('Language: English (en)');
- expect(prompt).not.toContain('Language: zz');
- });
-
- it('builds attack prompts with boolean defaults and explicit disabled weighing', () => {
- const prompt = buildUserPrompt('attack', {
- caseText: 'Case text',
- aggressiveness: 0,
- judgeParadigm: {},
- detectFallacies: true,
- analyzeWeighing: false,
- }, '');
-
- expect(prompt).toContain('Attack Intensity: moderate');
- expect(prompt).toContain('Judge Paradigm: logic');
- expect(prompt).toContain('Detect Fallacies: enabled');
- expect(prompt).toContain('Analyze Weighing: disabled');
- });
-
- it('includes brainstorm context when provided', () => {
- const prompt = buildUserPrompt('brainstorm', {
- motion: 'Expand climate adaptation policy',
- context: 'School board debate round',
- }, '');
-
- expect(prompt).toContain('Context: School board debate round');
- });
-});
diff --git a/src/test/use-route-prefetch.test.tsx b/src/test/use-route-prefetch.test.tsx
deleted file mode 100644
index 0a477fc15..000000000
--- a/src/test/use-route-prefetch.test.tsx
+++ /dev/null
@@ -1,264 +0,0 @@
-import { act, fireEvent, render, screen } from '@testing-library/react';
-import type { ComponentProps } from 'react';
-import { MemoryRouter, Route, Routes } from 'react-router-dom';
-import type { To } from 'react-router-dom';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { NavLink } from '@/components/NavLink';
-import { useRoutePrefetch } from '@/hooks/use-route-prefetch';
-
-const { mockPreloadRoute } = vi.hoisted(() => ({
- mockPreloadRoute: vi.fn(),
-}));
-
-vi.mock('@/lib/route-modules', () => ({
- preloadRoute: mockPreloadRoute,
- routeLoaders: {
- '/app/attack': vi.fn(),
- },
-}));
-
-function IntentLink({ to }: { to: To }) {
- const intentHandlers = useRoutePrefetch(to);
- return (
-
- Destination
-
- );
-}
-
-function TestLink({ to }: { to: To }) {
- return (
-
-
- } />
-
-
- );
-}
-
-type TestNavLinkHandlers = Pick, 'onPointerDown' | 'onPointerLeave' | 'onBlur'>;
-
-function TestNavLink({ onPointerDown, onPointerLeave, onBlur }: TestNavLinkHandlers) {
- return (
-
-
-
- Destination
-
- )}
- />
-
-
- );
-}
-
-function setConnection(connection: { saveData?: boolean; effectiveType?: string } | undefined) {
- if (connection) {
- Object.defineProperty(navigator, 'connection', {
- configurable: true,
- value: connection,
- });
- return;
- }
-
- Reflect.deleteProperty(navigator, 'connection');
-}
-
-function mediaQuery(matches: boolean, media: string): MediaQueryList {
- return {
- matches,
- media,
- onchange: null,
- addListener: () => {},
- removeListener: () => {},
- addEventListener: () => {},
- removeEventListener: () => {},
- dispatchEvent: () => false,
- };
-}
-
-describe('useRoutePrefetch', () => {
- beforeEach(() => {
- vi.useFakeTimers();
- mockPreloadRoute.mockReset();
- setConnection(undefined);
- vi.spyOn(window, 'matchMedia').mockImplementation((query) => mediaQuery(true, query));
- });
-
- afterEach(() => {
- setConnection(undefined);
- vi.restoreAllMocks();
- vi.useRealTimers();
- });
-
- it('waits briefly before prefetching on hover', () => {
- render();
-
- fireEvent.pointerEnter(screen.getByRole('link'));
- expect(mockPreloadRoute).not.toHaveBeenCalled();
-
- act(() => vi.advanceTimersByTime(99));
- expect(mockPreloadRoute).not.toHaveBeenCalled();
-
- act(() => vi.advanceTimersByTime(1));
- expect(mockPreloadRoute).toHaveBeenCalledWith('/app/attack');
- });
-
- it('cancels a hover prefetch when the pointer leaves first', () => {
- render();
- const link = screen.getByRole('link');
-
- fireEvent.pointerEnter(link);
- fireEvent.pointerLeave(link);
- act(() => vi.advanceTimersByTime(100));
-
- expect(mockPreloadRoute).not.toHaveBeenCalled();
- });
-
- it('ignores synthetic pointer hover on coarse touch screens', () => {
- vi.mocked(window.matchMedia).mockImplementation((query) => mediaQuery(false, query));
- render();
-
- fireEvent.pointerEnter(screen.getByRole('link'), { pointerType: 'mouse' });
- act(() => vi.advanceTimersByTime(100));
-
- expect(mockPreloadRoute).not.toHaveBeenCalled();
- });
-
- it('waits briefly before prefetching on keyboard focus', () => {
- render();
-
- fireEvent.focus(screen.getByRole('link'));
- act(() => vi.advanceTimersByTime(100));
-
- expect(mockPreloadRoute).toHaveBeenCalledWith('/app/attack');
- });
-
- it('prefetches immediately on pointer-down intent', () => {
- render();
-
- fireEvent.pointerDown(screen.getByRole('link'), { pointerType: 'touch' });
-
- expect(mockPreloadRoute).toHaveBeenCalledWith('/app/attack');
- });
-
- it('resolves relative string destinations against the current route', () => {
- render();
-
- fireEvent.pointerDown(screen.getByRole('link'));
-
- expect(mockPreloadRoute).toHaveBeenCalledWith('/app/attack');
- });
-
- it('resolves relative object destinations against the current route', () => {
- render();
-
- fireEvent.pointerDown(screen.getByRole('link'));
-
- expect(mockPreloadRoute).toHaveBeenCalledWith('/app/attack');
- });
-
- it('uses the pathname for same-origin absolute destinations', () => {
- render();
-
- fireEvent.pointerDown(screen.getByRole('link'));
-
- expect(mockPreloadRoute).toHaveBeenCalledWith('/app/attack');
- });
-
- it('deduplicates repeated intent signals for one destination', () => {
- render();
- const link = screen.getByRole('link');
-
- fireEvent.pointerEnter(link);
- act(() => vi.advanceTimersByTime(100));
- fireEvent.focus(link);
- fireEvent.pointerDown(link);
-
- expect(mockPreloadRoute).toHaveBeenCalledTimes(1);
- });
-
- it.each([
- ['Save-Data', { saveData: true }],
- ['slow-2g', { effectiveType: 'slow-2g' }],
- ['2g', { effectiveType: '2g' }],
- ])('skips prefetch for %s connections', (_label, connection) => {
- setConnection(connection);
- render();
-
- fireEvent.pointerDown(screen.getByRole('link'));
- act(() => vi.advanceTimersByTime(100));
-
- expect(mockPreloadRoute).not.toHaveBeenCalled();
- });
-
- it.each([
- ['3g', { effectiveType: '3g' }],
- ['missing Network Information', undefined],
- ])('allows prefetch for %s connections', (_label, connection) => {
- setConnection(connection);
- render();
-
- fireEvent.pointerDown(screen.getByRole('link'));
-
- expect(mockPreloadRoute).toHaveBeenCalledWith('/app/attack');
- });
-
- it('ignores unknown and external URLs', () => {
- const { rerender } = render();
-
- fireEvent.pointerDown(screen.getByRole('link'));
- rerender();
- fireEvent.pointerDown(screen.getByRole('link'));
-
- expect(mockPreloadRoute).not.toHaveBeenCalled();
- });
-
- it('cleans up a pending timer on unmount', () => {
- const { unmount } = render();
- fireEvent.pointerEnter(screen.getByRole('link'));
-
- unmount();
- act(() => vi.advanceTimersByTime(100));
-
- expect(mockPreloadRoute).not.toHaveBeenCalled();
- });
-
- it('lets a consumer prevent pointer-down prefetch', () => {
- render( event.preventDefault()} />);
-
- fireEvent.pointerDown(screen.getByRole('link'));
-
- expect(mockPreloadRoute).not.toHaveBeenCalled();
- });
-
- it('always cancels a pointer timer when the consumer prevents default', () => {
- render( event.preventDefault()} />);
- const link = screen.getByRole('link');
-
- fireEvent.pointerEnter(link);
- fireEvent.pointerLeave(link);
- act(() => vi.advanceTimersByTime(100));
-
- expect(mockPreloadRoute).not.toHaveBeenCalled();
- });
-
- it('always cancels a focus timer when the consumer prevents default', () => {
- render( event.preventDefault()} />);
- const link = screen.getByRole('link');
-
- fireEvent.focus(link);
- fireEvent.blur(link);
- act(() => vi.advanceTimersByTime(100));
-
- expect(mockPreloadRoute).not.toHaveBeenCalled();
- });
-});
diff --git a/src/test/use-speech-to-text.test.tsx b/src/test/use-speech-to-text.test.tsx
deleted file mode 100644
index f2c3292ae..000000000
--- a/src/test/use-speech-to-text.test.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import { act, renderHook, waitFor } from '@testing-library/react';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { resetMockState, supabaseMock } from '@/test/mocks/supabase';
-
-vi.mock('@/lib/supabase/client', () => ({ supabase: supabaseMock }));
-
-import { useSpeechToText } from '@/hooks/use-speech-to-text';
-import { useSettingsStore } from '@/lib/stores/settings-store';
-
-class MockSpeechRecognition {
- continuous = false;
- interimResults = false;
- lang = '';
- start = vi.fn();
- stop = vi.fn();
- abort = vi.fn();
- onresult = vi.fn();
- onerror = vi.fn();
- onend = vi.fn();
-}
-
-const instances: MockSpeechRecognition[] = [];
-
-class SpeechRecognitionConstructor extends MockSpeechRecognition {
- constructor() {
- super();
- instances.push(this);
- }
-}
-
-describe('useSpeechToText locale', () => {
- beforeEach(() => {
- resetMockState();
- instances.length = 0;
- useSettingsStore.setState({ language: 'en' });
- Object.defineProperty(window, 'SpeechRecognition', {
- configurable: true,
- writable: true,
- value: SpeechRecognitionConstructor,
- });
- Object.defineProperty(window, 'webkitSpeechRecognition', {
- configurable: true,
- writable: true,
- value: undefined,
- });
- });
-
- it('uses hu-HU when Hungarian is active at mount', async () => {
- useSettingsStore.setState({ language: 'hu' });
- renderHook(() => useSpeechToText());
-
- await waitFor(() => expect(instances).toHaveLength(1));
- expect(instances[0].lang).toBe('hu-HU');
- });
-
- it('recreates recognition with hu-HU after the language changes', async () => {
- const { unmount } = renderHook(() => useSpeechToText());
- await waitFor(() => expect(instances).toHaveLength(1));
- expect(instances[0].lang).toBe('en-GB');
-
- act(() => useSettingsStore.setState({ language: 'hu' }));
-
- await waitFor(() => expect(instances).toHaveLength(2));
- expect(instances[0].abort).toHaveBeenCalledOnce();
- expect(instances[1].lang).toBe('hu-HU');
- unmount();
- expect(instances[1].abort).toHaveBeenCalledOnce();
- });
-
- it('can restart after the language changes during active dictation', async () => {
- const { result } = renderHook(() => useSpeechToText());
- await waitFor(() => expect(instances).toHaveLength(1));
-
- await act(async () => {
- await result.current.startListening();
- });
- expect(result.current.isListening).toBe(true);
-
- act(() => useSettingsStore.setState({ language: 'hu' }));
- await waitFor(() => expect(instances).toHaveLength(2));
- expect(result.current.isListening).toBe(false);
-
- await act(async () => {
- await result.current.startListening();
- });
- expect(instances[1].start).toHaveBeenCalledOnce();
- expect(result.current.isListening).toBe(true);
- });
-
- it('reports unsupported browsers without constructing recognition', () => {
- Object.defineProperty(window, 'SpeechRecognition', { configurable: true, value: undefined });
- const { result } = renderHook(() => useSpeechToText());
-
- expect(result.current.hasSupport).toBe(false);
- expect(instances).toHaveLength(0);
- });
-});
diff --git a/src/test/validation.test.ts b/src/test/validation.test.ts
deleted file mode 100644
index f90b9b687..000000000
--- a/src/test/validation.test.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { validateUrl } from '@/lib/validation/url';
-import { validateFileUpload, validateSingleFile, MAX_FILE_SIZE, MAX_FILE_COUNT } from '@/lib/validation/file';
-
-describe('URL Validation', () => {
- describe('validateUrl', () => {
- it('returns valid http URLs unchanged', () => {
- expect(validateUrl('http://example.com')).toBe('http://example.com/');
- expect(validateUrl('https://example.com/path?query=1')).toBe('https://example.com/path?query=1');
- });
-
- it('adds https:// to URLs without protocol', () => {
- expect(validateUrl('example.com')).toBe('https://example.com/');
- expect(validateUrl('www.example.com')).toBe('https://www.example.com/');
- });
-
- it('blocks javascript: protocol', () => {
- expect(validateUrl('javascript:alert(1)')).toBeUndefined();
- expect(validateUrl('JAVASCRIPT:alert(1)')).toBeUndefined();
- });
-
- it('blocks data: protocol', () => {
- expect(validateUrl('data:text/html,')).toBeUndefined();
- });
-
- it('blocks other dangerous protocols', () => {
- expect(validateUrl('vbscript:alert(1)')).toBeUndefined();
- expect(validateUrl('file:///etc/passwd')).toBeUndefined();
- });
-
- it('returns undefined for invalid URLs', () => {
- expect(validateUrl('')).toBeUndefined();
- expect(validateUrl(undefined)).toBeUndefined();
- expect(validateUrl('not a url')).toBeUndefined();
- });
-
- it('handles whitespace', () => {
- expect(validateUrl(' https://example.com ')).toBe('https://example.com/');
- });
- });
-});
-
-describe('File Validation', () => {
- // Helper to create mock files
- const createMockFile = (name: string, size: number, type: string = 'text/plain'): File => {
- const content = new Array(size).fill('a').join('');
- return new File([content], name, { type });
- };
-
- describe('validateSingleFile', () => {
- it('accepts valid text files', () => {
- const file = createMockFile('test.txt', 1000);
- expect(validateSingleFile(file)).toEqual({ valid: true });
- });
-
- it('accepts valid code files', () => {
- const jsFile = createMockFile('app.js', 1000, 'application/javascript');
- expect(validateSingleFile(jsFile)).toEqual({ valid: true });
-
- const pyFile = createMockFile('script.py', 1000, 'text/x-python');
- expect(validateSingleFile(pyFile)).toEqual({ valid: true });
- });
-
- it('rejects files that are too large', () => {
- const largeFile = createMockFile('large.txt', MAX_FILE_SIZE + 1);
- const result = validateSingleFile(largeFile);
- expect(result.valid).toBe(false);
- expect(result.error).toContain('too large');
- });
-
- it('rejects files with unsupported extensions', () => {
- const exeFile = createMockFile('virus.exe', 1000, 'application/x-executable');
- const result = validateSingleFile(exeFile);
- expect(result.valid).toBe(false);
- expect(result.error).toContain('unsupported file type');
- });
-
- it('rejects an allowed extension with an unsupported declared MIME type', () => {
- const file = createMockFile('notes.txt', 1000, 'application/pdf');
-
- expect(validateSingleFile(file)).toMatchObject({
- valid: false,
- error: expect.stringContaining('unsupported file type'),
- });
- });
-
- it('accepts an allowed extension when the browser omits its MIME type', () => {
- const file = createMockFile('notes.txt', 1000, '');
-
- expect(validateSingleFile(file)).toEqual({ valid: true });
- });
- });
-
- describe('validateFileUpload', () => {
- it('accepts valid file arrays', () => {
- const files = [
- createMockFile('file1.txt', 1000),
- createMockFile('file2.md', 1000),
- ];
- expect(validateFileUpload(files)).toEqual({ valid: true });
- });
-
- it('rejects when too many files', () => {
- const files = Array.from({ length: MAX_FILE_COUNT + 1 }, (_, i) =>
- createMockFile(`file${i}.txt`, 1000)
- );
- const result = validateFileUpload(files);
- expect(result.valid).toBe(false);
- expect(result.error).toContain('Too many files');
- });
-
- it('rejects if any file is invalid', () => {
- const files = [
- createMockFile('valid.txt', 1000),
- createMockFile('invalid.exe', 1000, 'application/x-executable'),
- ];
- const result = validateFileUpload(files);
- expect(result.valid).toBe(false);
- });
-
- it('accepts empty arrays', () => {
- expect(validateFileUpload([])).toEqual({ valid: true });
- });
- });
-});
diff --git a/src/test/webhook-event-lifecycle.test.ts b/src/test/webhook-event-lifecycle.test.ts
deleted file mode 100644
index 962070d62..000000000
--- a/src/test/webhook-event-lifecycle.test.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- duplicateWebhookResponse,
- webhookFailureResponse,
-} from "../../supabase/functions/stripe-webhook/webhook-event-lifecycle";
-
-describe("webhook event lifecycle", () => {
- it("keeps successfully processed events idempotent", () => {
- expect(duplicateWebhookResponse()).toEqual({ status: 200, duplicate: true });
- });
-
- it("returns a retryable failure when event handling fails", () => {
- expect(webhookFailureResponse("temporary database error")).toEqual({
- status: 500,
- received: false,
- error: "temporary database error",
- });
- });
-});
diff --git a/src/test/workspace/collaboration.test.ts b/src/test/workspace/collaboration.test.ts
deleted file mode 100644
index e4b37485f..000000000
--- a/src/test/workspace/collaboration.test.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { HocuspocusProvider } from "@hocuspocus/provider";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import * as Y from "yjs";
-import { createWorkspaceEditor } from "@/lib/workspace/editor-schema";
-import {
- createCollaborationServer,
- type DurableUpdate,
- type DurableUpdateStore,
-} from "../../../services/collaboration/src/server";
-
-class MemoryStore implements DurableUpdateStore {
- readonly updates: DurableUpdate[] = [];
-
- async load(documentId: string, epoch: number) {
- return this.updates.filter(
- (update) =>
- update.documentId === documentId && update.epoch === epoch,
- );
- }
-
- async find(documentId: string, epoch: number, mutationId: string) {
- return this.updates.find(
- (update) =>
- update.documentId === documentId &&
- update.epoch === epoch &&
- update.mutationId === mutationId,
- );
- }
-
- async append(update: Omit) {
- const existing = await this.find(
- update.documentId,
- update.epoch,
- update.mutationId,
- );
- if (existing) return existing;
-
- const durable = { ...update, sequence: this.updates.length + 1 };
- this.updates.push(durable);
- return durable;
- }
-}
-
-describe("workspace editor collaboration", () => {
- const cleanup: Array<() => void | Promise> = [];
-
- afterEach(async () => {
- await Promise.all(cleanup.splice(0).map((dispose) => dispose()));
- });
-
- async function createSession() {
- const server = (() => {
- const deno = Object.getOwnPropertyDescriptor(globalThis, "Deno");
- try {
- Reflect.deleteProperty(globalThis, "Deno");
- return createCollaborationServer({
- authenticate: async ({ documentName, token }) => ({
- authorId: token,
- documentId: documentName.split("@")[0],
- epoch: Number(documentName.split("@")[1]),
- }),
- store: new MemoryStore(),
- });
- } finally {
- if (deno) Object.defineProperty(globalThis, "Deno", deno);
- }
- })();
- await server.listen();
-
- const leftDocument = new Y.Doc();
- const rightDocument = new Y.Doc();
- const left = createWorkspaceEditor(leftDocument);
- const right = createWorkspaceEditor(rightDocument);
- const url = `ws://127.0.0.1:${server.address.port}`;
- const leftProvider = new HocuspocusProvider({
- document: leftDocument,
- name: "structured-editor@1",
- token: "left",
- url,
- });
- const rightProvider = new HocuspocusProvider({
- document: rightDocument,
- name: "structured-editor@1",
- token: "right",
- url,
- });
- cleanup.push(
- () => leftProvider.destroy(),
- () => rightProvider.destroy(),
- () => left.destroy(),
- () => right.destroy(),
- () => leftDocument.destroy(),
- () => rightDocument.destroy(),
- () => server.destroy(),
- );
-
- await vi.waitFor(() => {
- expect(leftProvider.isSynced).toBe(true);
- expect(rightProvider.isSynced).toBe(true);
- });
-
- return { left, leftProvider, right, rightProvider };
- }
-
- it("converges structured paragraph, list, and link edits after reconnect", async () => {
- const { left, leftProvider, right, rightProvider } = await createSession();
-
- left.commands.setContent("Opening claim
Evidence
");
- await vi.waitFor(() => expect(right.getJSON()).toEqual(left.getJSON()));
-
- rightProvider.disconnect();
- await vi.waitFor(() => expect(rightProvider.isSynced).toBe(false));
-
- left.commands.insertContentAt(
- left.state.doc.content.size,
- "Concurrent rebuttal
",
- );
- right.commands.insertContentAt(1, "Offline ");
- right.commands.selectAll();
- expect(right.commands.toggleBulletList()).toBe(true);
- right.commands.selectAll();
- expect(
- right.commands.setLink({ href: "https://example.com/evidence" }),
- ).toBe(true);
-
- rightProvider.connect();
- await vi.waitFor(() => {
- expect(leftProvider.isSynced).toBe(true);
- expect(rightProvider.isSynced).toBe(true);
- expect(right.getJSON()).toEqual(left.getJSON());
- });
-
- const json = JSON.stringify(left.getJSON());
- expect(json).toContain('"type":"paragraph"');
- expect(json).toContain('"type":"bulletList"');
- expect(json).toContain('"type":"link"');
- expect(json).toContain("Offline Opening claim");
- expect(json).toContain("Concurrent rebuttal");
- });
-
- it("undoes only the current editor's last change through the provider", async () => {
- const { left, right } = await createSession();
-
- left.commands.setContent("Base
");
- await vi.waitFor(() => expect(right.getJSON()).toEqual(left.getJSON()));
- left.commands.insertContentAt(5, " Alice");
- await vi.waitFor(() => expect(right.getText()).toContain("Alice"));
- right.commands.insertContentAt(11, " Bob");
- await vi.waitFor(() => expect(left.getText()).toContain("Bob"));
-
- expect(left.commands.undo()).toBe(true);
- await vi.waitFor(() => expect(right.getJSON()).toEqual(left.getJSON()));
-
- expect(left.getText()).toContain("Bob");
- expect(left.getText()).not.toContain("Alice");
- });
-});
diff --git a/src/test/workspace/document-conversion.test.ts b/src/test/workspace/document-conversion.test.ts
deleted file mode 100644
index 47648770a..000000000
--- a/src/test/workspace/document-conversion.test.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import type { JSONContent } from "@tiptap/core";
-import { readFile } from "node:fs/promises";
-import { resolve } from "node:path";
-import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
-import { describe, expect, it, vi } from "vitest";
-import {
- fromWorkspaceDocx,
- fromWorkspaceHtml,
- fromWorkspaceMarkdown,
- toWorkspaceDocx,
- toWorkspaceMarkdown,
- toWorkspacePdf,
-} from "@/lib/workspace/document-conversion";
-import {
- STRUCTURED_SPEECH,
- STRUCTURED_SPEECH_TEXT,
-} from "./fixtures/structured-speech";
-
-const EXPECTED_SEMANTICS = {
- headings: [{ level: 1, text: "A fair transition" }],
- bulletListDepths: [0, 1],
- links: [{ href: "https://example.com/evidence", text: "source" }],
- table: [
- ["Metric", "Value"],
- ["Access", "Equal"],
- ],
- citations: [
- {
- sourceId: "source-1",
- title: "Public evidence",
- locator: "p. 7",
- quote: "Equal access",
- href: "https://example.com/evidence",
- },
- ],
-};
-
-function nodeText(node: JSONContent): string {
- if (node.type === "citation") return "";
- return node.text ?? node.content?.map(nodeText).join("") ?? "";
-}
-
-function documentSemantics(document: JSONContent) {
- const headings: Array<{ level: number; text: string }> = [];
- const bulletListDepths: number[] = [];
- const links: Array<{ href: string; text: string }> = [];
- const table: string[][] = [];
- const citations: Array> = [];
-
- function visit(node: JSONContent, listDepth = 0) {
- if (node.type === "heading") {
- headings.push({ level: Number(node.attrs?.level), text: nodeText(node) });
- }
- if (node.type === "bulletList") {
- bulletListDepths.push(listDepth);
- listDepth += 1;
- }
- if (node.type === "text") {
- const link = node.marks?.find((mark) => mark.type === "link");
- if (link) links.push({ href: String(link.attrs?.href), text: node.text ?? "" });
- }
- if (node.type === "table") {
- for (const row of node.content ?? []) {
- table.push((row.content ?? []).map(nodeText));
- }
- }
- if (node.type === "citation") citations.push(node.attrs ?? {});
- node.content?.forEach((child) => visit(child, listDepth));
- }
-
- visit(document);
- return { headings, bulletListDepths, links, table, citations };
-}
-
-describe("workspace document conversion", () => {
- it("round-trips supported semantics and multilingual text through Markdown", () => {
- expect(documentSemantics(STRUCTURED_SPEECH)).toEqual(EXPECTED_SEMANTICS);
-
- const markdown = toWorkspaceMarkdown(STRUCTURED_SPEECH);
- const roundTrip = fromWorkspaceMarkdown(markdown);
-
- expect(documentSemantics(roundTrip)).toEqual(EXPECTED_SEMANTICS);
- const visibleText = nodeText(roundTrip);
- for (const text of STRUCTURED_SPEECH_TEXT) {
- expect(visibleText).toContain(text);
- }
- });
-
- it("round-trips supported semantics and multilingual text through DOCX", async () => {
- expect(toWorkspaceDocx).toBeTypeOf("function");
- expect(fromWorkspaceDocx).toBeTypeOf("function");
-
- const docx = await toWorkspaceDocx(STRUCTURED_SPEECH);
- expect(Array.from(docx.slice(0, 2))).toEqual([0x50, 0x4b]);
- const roundTrip = await fromWorkspaceDocx(docx);
-
- expect(documentSemantics(roundTrip)).toEqual(EXPECTED_SEMANTICS);
- const visibleText = nodeText(roundTrip);
- for (const text of STRUCTURED_SPEECH_TEXT) {
- expect(visibleText).toContain(text);
- }
- });
-
- it("renders selectable multilingual PDF text with intact glyphs", async () => {
- const fontData = await readFile(resolve(
- process.cwd(),
- "node_modules/pdfjs-dist/standard_fonts/LiberationSans-Regular.ttf",
- ));
- const bytes = await toWorkspacePdf(STRUCTURED_SPEECH, { fontData });
- const loadingTask = getDocument({ data: bytes, useWorkerFetch: false });
- const pdf = await loadingTask.promise;
- const pages: string[] = [];
- for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
- const page = await pdf.getPage(pageNumber);
- const content = await page.getTextContent();
- pages.push(content.items.map((item) => "str" in item ? item.str : "").join(" "));
- page.cleanup();
- }
- await loadingTask.destroy();
-
- const extracted = pages.join(" ").replace(/\s+/g, " ");
- for (const text of STRUCTURED_SPEECH_TEXT) {
- expect(extracted).toContain(text);
- }
- expect(extracted).toContain("[Public evidence, p. 7]");
- });
-
- it("keeps malformed citations, scripts, and unsafe links outside imported schema", () => {
- const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
- const imported = fromWorkspaceHtml(`
- Safe heading
-
- plain unsafe label
-
- broken citation label
-
- `);
- const serialized = JSON.stringify(imported);
-
- expect(nodeText(imported)).toContain("plain unsafe label");
- expect(nodeText(imported)).toContain("broken citation label");
- expect(serialized).not.toContain("hidden payload");
- expect(serialized).not.toContain('"type":"link"');
- expect(serialized).not.toContain('"type":"citation"');
- warning.mockRestore();
- });
-});
diff --git a/src/test/workspace/durable-ack.test.ts b/src/test/workspace/durable-ack.test.ts
deleted file mode 100644
index f51d33933..000000000
--- a/src/test/workspace/durable-ack.test.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { createEncoder, toUint8Array, writeVarString, writeVarUint } from "lib0/encoding";
-import { writeUpdate } from "y-protocols/sync";
-import { describe, expect, it } from "vitest";
-import { durableMutationFromMessage } from "@/lib/workspace/durable-ack";
-
-describe("workspace durable acknowledgements", () => {
- it("hashes the exact Yjs payload sent in a live update message", async () => {
- const encoder = createEncoder();
- writeVarString(encoder, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa@3");
- writeVarUint(encoder, 0);
- writeUpdate(encoder, new Uint8Array([1, 2, 3]));
-
- await expect(durableMutationFromMessage(toUint8Array(encoder))).resolves.toMatchObject({
- mutationId: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/),
- syncType: 2,
- });
- });
-
- it("ignores non-write sync messages", async () => {
- const encoder = createEncoder();
- writeVarString(encoder, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa@3");
- writeVarUint(encoder, 0);
- writeVarUint(encoder, 0);
-
- await expect(durableMutationFromMessage(toUint8Array(encoder))).resolves.toBeNull();
- });
-});
diff --git a/src/test/workspace/fixtures/structured-speech.ts b/src/test/workspace/fixtures/structured-speech.ts
deleted file mode 100644
index cb13205f6..000000000
--- a/src/test/workspace/fixtures/structured-speech.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import type { JSONContent } from "@tiptap/core";
-
-export const STRUCTURED_SPEECH_TEXT = [
- "A fair transition",
- "English opening with café evidence",
- "Español: la niñez y la acción pública",
- "Magyar: árvíztűrő tükörfúrógép",
- "First response",
- "Nested reply",
- "Metric",
- "Value",
- "Access",
- "Equal",
-] as const;
-
-export const STRUCTURED_SPEECH: JSONContent = {
- type: "doc",
- content: [
- {
- type: "heading",
- attrs: { level: 1 },
- content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[0] }],
- },
- {
- type: "paragraph",
- content: [
- { type: "text", text: `${STRUCTURED_SPEECH_TEXT[1]} ` },
- {
- type: "text",
- marks: [
- {
- type: "link",
- attrs: {
- href: "https://example.com/evidence",
- target: "_blank",
- rel: "noopener noreferrer nofollow",
- class: null,
- title: null,
- },
- },
- ],
- text: "source",
- },
- { type: "text", text: ". " },
- {
- type: "citation",
- attrs: {
- sourceId: "source-1",
- title: "Public evidence",
- locator: "p. 7",
- quote: "Equal access",
- href: "https://example.com/evidence",
- },
- },
- ],
- },
- {
- type: "paragraph",
- content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[2] }],
- },
- {
- type: "paragraph",
- content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[3] }],
- },
- {
- type: "bulletList",
- content: [
- {
- type: "listItem",
- content: [
- {
- type: "paragraph",
- content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[4] }],
- },
- {
- type: "bulletList",
- content: [
- {
- type: "listItem",
- content: [
- {
- type: "paragraph",
- content: [
- { type: "text", text: STRUCTURED_SPEECH_TEXT[5] },
- ],
- },
- ],
- },
- ],
- },
- ],
- },
- ],
- },
- {
- type: "table",
- content: [
- {
- type: "tableRow",
- content: [
- {
- type: "tableHeader",
- attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null },
- content: [
- {
- type: "paragraph",
- content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[6] }],
- },
- ],
- },
- {
- type: "tableHeader",
- attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null },
- content: [
- {
- type: "paragraph",
- content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[7] }],
- },
- ],
- },
- ],
- },
- {
- type: "tableRow",
- content: [
- {
- type: "tableCell",
- attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null },
- content: [
- {
- type: "paragraph",
- content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[8] }],
- },
- ],
- },
- {
- type: "tableCell",
- attrs: { colspan: 1, rowspan: 1, colwidth: null, align: null },
- content: [
- {
- type: "paragraph",
- content: [{ type: "text", text: STRUCTURED_SPEECH_TEXT[9] }],
- },
- ],
- },
- ],
- },
- ],
- },
- { type: "paragraph" },
- ],
-};
diff --git a/src/test/workspace/member-removal.test.ts b/src/test/workspace/member-removal.test.ts
deleted file mode 100644
index 7e4bf5d75..000000000
--- a/src/test/workspace/member-removal.test.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
-
-vi.mock("@/lib/workspace/collaboration-config", () => ({
- COLLABORATION_URL: "ws://127.0.0.1:12345",
-}));
-
-import { removeWorkspaceMember } from "@/lib/workspace/client";
-
-describe("workspace member removal", () => {
- afterEach(() => vi.unstubAllGlobals());
-
- it("reuses one operation ID when the committed response is lost", async () => {
- const fetchMock = vi.fn()
- .mockRejectedValueOnce(new TypeError("connection closed"))
- .mockResolvedValueOnce(new Response(JSON.stringify({ revision: 4 }), { status: 200 }));
- vi.stubGlobal("fetch", fetchMock);
-
- await expect(removeWorkspaceMember({
- expectedRevision: 3,
- targetUserId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
- token: "signed-token",
- workspaceId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
- })).resolves.toBe(4);
-
- const bodies = fetchMock.mock.calls.map(([, init]) => JSON.parse(String(init?.body)));
- expect(fetchMock).toHaveBeenCalledTimes(2);
- expect(bodies[0].requestId).toMatch(/^[0-9a-f-]{36}$/);
- expect(bodies[1]).toEqual(bodies[0]);
- });
-});
diff --git a/src/test/workspace/navigation.test.tsx b/src/test/workspace/navigation.test.tsx
deleted file mode 100644
index 8b90d7f96..000000000
--- a/src/test/workspace/navigation.test.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { routeLoaders } from "@/lib/route-modules";
-import WorkspacesPage from "@/pages/WorkspacesPage";
-import WorkspacePage from "@/pages/WorkspacePage";
-
-const { mockFrom, mockRpc } = vi.hoisted(() => ({
- mockFrom: vi.fn(),
- mockRpc: vi.fn(),
-}));
-
-vi.mock("@/lib/supabase/client", () => ({
- supabase: { from: mockFrom, rpc: mockRpc },
-}));
-
-vi.mock("@/lib/supabase/AuthProvider", () => ({
- useAuth: () => ({
- session: { access_token: "signed-token" },
- user: { id: "11111111-1111-4111-8111-111111111111" },
- }),
-}));
-
-vi.mock("@/components/features/workspace/WorkspaceEditor", () => ({
- WorkspaceEditor: ({ editable }: { editable: boolean }) => (
-
- ),
-}));
-
-function LocationProbe() {
- const location = useLocation();
- return ;
-}
-
-function renderWorkspaces(children = ) {
- const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- return render(
-
- {children}
- ,
- );
-}
-
-describe("workspace navigation", () => {
- beforeEach(() => {
- mockRpc.mockReset();
- mockFrom.mockReset();
- });
-
- it("lazy loads the workspace index and editor routes", () => {
- expect(Object.keys(routeLoaders)).toEqual(expect.arrayContaining([
- "/app/workspaces",
- "/app/workspaces/:workspaceId",
- ]));
- });
-
- it("collects the motion once and exposes workspace creation", () => {
- renderWorkspaces();
-
- expect(screen.getByRole("textbox", { name: "Motion" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Create workspace" })).toBeInTheDocument();
- });
-
- it("creates a workspace and opens its Intro immediately", async () => {
- mockRpc.mockResolvedValue({ data: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", error: null });
- const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- render(
-
-
-
- } />
- } />
-
-
- ,
- );
-
- fireEvent.change(screen.getByRole("textbox", { name: "Motion" }), {
- target: { value: "Schools should ban phones" },
- });
- fireEvent.click(screen.getByRole("button", { name: "Create workspace" }));
-
- await waitFor(() => {
- expect(screen.getByRole("status", { name: "Current path" })).toHaveTextContent(
- "/app/workspaces/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
- );
- });
- });
-
- it("lists two memberships and reopens an archived workspace", async () => {
- const order = vi.fn().mockResolvedValue({
- data: [
- {
- id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
- motion: "AI tutors should grade homework",
- status: "active",
- title: null,
- updated_at: "2026-09-21T08:00:00.000Z",
- },
- {
- id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
- motion: "Schools should ban phones",
- status: "archived",
- title: null,
- updated_at: "2026-09-20T08:00:00.000Z",
- },
- ],
- error: null,
- });
- mockFrom.mockReturnValue({ select: () => ({ order }) });
- const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
-
- render(
-
-
-
- } />
- } />
-
-
- ,
- );
-
- expect(await screen.findByRole("link", { name: /AI tutors should grade homework/ })).toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "All" }));
- expect(screen.getByRole("link", { name: /Schools should ban phones/ })).toBeInTheDocument();
- fireEvent.click(screen.getByRole("link", { name: /Schools should ban phones/ }));
-
- expect(screen.getByRole("status", { name: "Current path" })).toHaveTextContent(
- "/app/workspaces/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
- );
- });
-
- it("shows both sides and keeps an assigned file editable by every member", async () => {
- mockFrom.mockImplementation((table: string) => ({
- select: () => ({
- eq: () => table === "workspaces"
- ? { single: async () => ({
- data: {
- access_epoch: 0,
- id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
- motion: "Schools should ban phones",
- owner_id: "11111111-1111-4111-8111-111111111111",
- pending_owner_id: null as string | null,
- revision: 0,
- status: "active",
- title: null as string | null,
- },
- error: null as Error | null,
- }) }
- : Promise.resolve({
- data: table === "workspace_members"
- ? [
- { joined_at: "2026-09-21T08:00:00.000Z", user_id: "11111111-1111-4111-8111-111111111111" },
- { joined_at: "2026-09-21T08:01:00.000Z", user_id: "22222222-2222-4222-8222-222222222222" },
- ]
- : [
- {
- assignee_id: "22222222-2222-4222-8222-222222222222",
- document_epoch: 0,
- durable_sequence: 0,
- id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
- position: 1,
- side: "pro",
- state: "active",
- title: "R1",
- },
- {
- assignee_id: null,
- document_epoch: 0,
- durable_sequence: 0,
- id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
- position: 1,
- side: "con",
- state: "active",
- title: "R1",
- },
- ],
- error: null,
- }),
- }),
- }));
- const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
-
- render(
-
-
-
- } />
-
-
- ,
- );
-
- expect(await screen.findByRole("button", { name: "For" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Against" })).toBeInTheDocument();
- expect(screen.getByText("Assigned to Member 2222")).toBeInTheDocument();
- expect(screen.getByRole("textbox", { name: "Document editor" })).toHaveAttribute("contenteditable", "true");
- });
-});
diff --git a/src/test/workspace/review-panel.test.tsx b/src/test/workspace/review-panel.test.tsx
deleted file mode 100644
index 84724d38c..000000000
--- a/src/test/workspace/review-panel.test.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-const api = vi.hoisted(() => ({
- listComments: vi.fn(),
- listNotifications: vi.fn(),
- listProposals: vi.fn(),
-}));
-vi.mock("@/lib/workspace/client", () => ({
- decideWorkspaceProposal: vi.fn(),
- listWorkspaceComments: api.listComments,
- listWorkspaceNotifications: api.listNotifications,
- listWorkspaceProposals: api.listProposals,
- replyWorkspaceComment: vi.fn(),
- resolveWorkspaceComment: vi.fn(),
-}));
-vi.mock("@/hooks/use-online-status", () => ({ useOnlineStatus: () => true }));
-
-import { WorkspaceReviewPanel } from "@/components/features/workspace/WorkspaceReviewPanel";
-
-describe("workspace review panel", () => {
- beforeEach(() => {
- api.listComments.mockResolvedValue([]);
- api.listNotifications.mockResolvedValue([]);
- api.listProposals.mockResolvedValue([{
- id: "proposal",
- hunks: [{
- id: "hunk",
- kind: "format",
- absoluteFrom: 7,
- absoluteTo: 16,
- contextBefore: [{ type: "text", text: "Before " }],
- contextAfter: [{ type: "text", text: " after" }],
- original: [{ type: "text", text: "Old claim" }],
- replacement: [{ type: "text", text: "New claim", marks: [{ type: "bold" }] }],
- status: "pending",
- }],
- }]);
- });
-
- it("shows a labelled non-color diff and disables decisions when read-only", async () => {
- const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- render(
-
-
- ,
- );
-
- expect(await screen.findByText("Old claim")).toBeInTheDocument();
- expect(screen.getByText("New claim [bold]")).toBeInTheDocument();
- expect(screen.getByText("Original")).toBeInTheDocument();
- expect(screen.getByText("Proposed")).toBeInTheDocument();
- expect(screen.getByText("Characters 7–16")).toBeInTheDocument();
- expect(screen.getByText((_, element) => element?.textContent === "Before Old claim after")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Accept" })).toBeDisabled();
- expect(screen.getByRole("button", { name: "Reject" })).toBeDisabled();
- });
-});
diff --git a/src/test/workspace/review.test.ts b/src/test/workspace/review.test.ts
deleted file mode 100644
index 1fbcf0e02..000000000
--- a/src/test/workspace/review.test.ts
+++ /dev/null
@@ -1,495 +0,0 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { yUndoPluginKey } from "@tiptap/y-tiptap";
-import * as Y from "yjs";
-import { createWorkspaceEditor } from "@/lib/workspace/editor-schema";
-import * as review from "@/lib/workspace/review";
-import {
- STRUCTURED_SPEECH,
- STRUCTURED_SPEECH_TEXT,
-} from "./fixtures/structured-speech";
-
-describe("workspace review model", () => {
- const cleanup: Array<() => void> = [];
-
- afterEach(() => {
- cleanup.splice(0).forEach((dispose) => dispose());
- });
-
- function createEditor(document = new Y.Doc()) {
- const editor = createWorkspaceEditor(document);
- cleanup.push(() => editor.destroy());
- if (arguments.length === 0) cleanup.push(() => document.destroy());
- return editor;
- }
-
- function createSyncedEditors() {
- const leftDocument = new Y.Doc();
- const rightDocument = new Y.Doc();
- const fromLeft = Symbol("from-left");
- const fromRight = Symbol("from-right");
- const syncLeft = (update: Uint8Array, origin: unknown) => {
- if (origin !== fromRight) Y.applyUpdate(rightDocument, update, fromLeft);
- };
- const syncRight = (update: Uint8Array, origin: unknown) => {
- if (origin !== fromLeft) Y.applyUpdate(leftDocument, update, fromRight);
- };
- leftDocument.on("update", syncLeft);
- rightDocument.on("update", syncRight);
- cleanup.push(
- () => leftDocument.off("update", syncLeft),
- () => rightDocument.off("update", syncRight),
- () => leftDocument.destroy(),
- () => rightDocument.destroy(),
- );
- return {
- left: createEditor(leftDocument),
- leftDocument,
- right: createEditor(rightDocument),
- rightDocument,
- };
- }
-
- it("restores binary proposal anchors after a JSON round trip", () => {
- const editor = createEditor();
- editor.commands.setContent("Accepted text
");
- const proposal = review.createProposal({
- authorId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
- documentEpoch: 1,
- editor,
- hunks: [{
- content: [{ type: "paragraph", content: [{ type: "text", text: "Improved text" }] }],
- from: 0,
- id: "format",
- kind: "format",
- to: editor.state.doc.content.size,
- }],
- id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
- });
-
- const restored = review.deserializeProposal(JSON.parse(JSON.stringify(review.serializeProposal(proposal))));
-
- expect(restored.baseStateVector).toBeInstanceOf(Uint8Array);
- expect(restored.hunks[0].from).toBeInstanceOf(Uint8Array);
- expect(restored.hunks[0].to).toBeInstanceOf(Uint8Array);
- });
-
- it("keeps the representative multilingual document structured", () => {
- const editor = createEditor();
-
- expect(editor.commands.setContent(STRUCTURED_SPEECH)).toBe(true);
-
- expect(editor.getJSON()).toEqual(STRUCTURED_SPEECH);
- expect(editor.getJSON()).toMatchObject({
- content: expect.arrayContaining([
- expect.objectContaining({ type: "heading" }),
- expect.objectContaining({ type: "bulletList" }),
- expect.objectContaining({ type: "table" }),
- ]),
- });
- expect(JSON.stringify(editor.getJSON())).toContain('"type":"citation"');
- for (const text of STRUCTURED_SPEECH_TEXT) {
- expect(editor.getText()).toContain(text);
- }
- });
-
- it("keeps suggested insert, delete, and formatting outside accepted content", async () => {
- const editor = createEditor();
- editor.commands.setContent("Hello world
");
- const acceptedBefore = editor.getJSON();
- const composed = " tűrő".normalize("NFC");
-
- const proposal = review.createProposal({
- id: "proposal-1",
- authorId: "member-1",
- documentEpoch: 1,
- editor,
- hunks: [
- { id: "insert", kind: "insert", at: 6, content: [{ type: "text", text: composed }] },
- { id: "delete", kind: "delete", from: 7, to: 12 },
- {
- id: "format",
- kind: "format",
- from: 1,
- to: 6,
- content: [
- { type: "text", text: "Hello", marks: [{ type: "bold" }] },
- ],
- },
- ],
- });
-
- expect(editor.getJSON()).toEqual(acceptedBefore);
- expect(proposal.hunks.map((hunk) => [hunk.kind, hunk.status])).toEqual([
- ["insert", "pending"],
- ["delete", "pending"],
- ["format", "pending"],
- ]);
- expect(proposal.hunks[0].replacement).toEqual([
- { type: "text", text: composed },
- ]);
- expect(proposal.hunks[2].replacement).toEqual([
- { type: "text", text: "Hello", marks: [{ type: "bold" }] },
- ]);
- expect(proposal.hunks[1].original).toEqual([
- { type: "text", text: "world" },
- ]);
- });
-
- it("intercepts paste and emits only settled native composition without changing accepted text", async () => {
- const document = new Y.Doc();
- const onSuggestion = vi.fn();
- let suggesting = false;
- const editor = createWorkspaceEditor(document, {
- isSuggesting: () => suggesting,
- onSuggestion,
- });
- cleanup.push(() => editor.destroy(), () => document.destroy());
- editor.commands.setContent("Alpha
");
- const accepted = editor.getJSON();
- suggesting = true;
-
- editor.view.dispatch(editor.state.tr.insertText(" pasted", 6).setMeta("uiEvent", "paste"));
- await vi.waitFor(() => expect(onSuggestion).toHaveBeenCalledTimes(1));
- editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
- editor.view.dom.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, cancelable: true, data: " t", inputType: "insertCompositionText" }));
- editor.view.dom.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, cancelable: true, data: " tűrő", inputType: "insertCompositionText" }));
- expect(onSuggestion).toHaveBeenCalledTimes(1);
- editor.view.dom.dispatchEvent(new CompositionEvent("compositionend"));
-
- await vi.waitFor(() => expect(onSuggestion).toHaveBeenCalledTimes(2));
- expect(editor.getJSON()).toEqual(accepted);
- expect(onSuggestion.mock.calls.map(([hunk]) => hunk.kind)).toEqual(["insert", "insert"]);
- expect(onSuggestion.mock.calls[1][0].content).toEqual([{ type: "text", text: " tűrő" }]);
-
- editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
- editor.view.dom.dispatchEvent(new CompositionEvent("compositionend", { data: " vég" }));
- await vi.waitFor(() => expect(onSuggestion).toHaveBeenCalledTimes(3));
- expect(onSuggestion.mock.calls[2][0].content).toEqual([{ type: "text", text: " vég" }]);
-
- editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
- editor.view.dom.dispatchEvent(new CompositionEvent("compositionupdate", { data: " ősz" }));
- editor.view.dom.dispatchEvent(new CompositionEvent("compositionend"));
- await vi.waitFor(() => expect(onSuggestion).toHaveBeenCalledTimes(4));
- expect(onSuggestion.mock.calls[3][0].content).toEqual([{ type: "text", text: " ősz" }]);
-
- editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
- editor.view.dom.dispatchEvent(new CompositionEvent("compositionupdate", { data: " cancelled" }));
- editor.view.dom.dispatchEvent(new CompositionEvent("compositionupdate", { data: "" }));
- editor.view.dom.dispatchEvent(new CompositionEvent("compositionend"));
- await new Promise((resolve) => setTimeout(resolve, 0));
- expect(onSuggestion).toHaveBeenCalledTimes(4);
-
- suggesting = false;
- editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart"));
- editor.view.dom.dispatchEvent(new CompositionEvent("compositionupdate", { data: " committed" }));
- editor.view.dom.dispatchEvent(new CompositionEvent("compositionend", { data: " committed" }));
- await new Promise((resolve) => setTimeout(resolve, 0));
- expect(onSuggestion).toHaveBeenCalledTimes(4);
- });
-
- it("accepts one independent hunk and keeps teammate text outside local undo", async () => {
- expect(review.acceptProposalHunks).toBeTypeOf("function");
- expect(review.rejectProposalHunks).toBeTypeOf("function");
-
- const { left, right } = createSyncedEditors();
- left.commands.setContent("Alpha Beta Gamma
");
- expect(right.getJSON()).toEqual(left.getJSON());
-
- const proposal = review.createProposal({
- id: "proposal-2",
- authorId: "member-1",
- documentEpoch: 1,
- editor: left,
- hunks: [
- {
- id: "format-beta",
- kind: "format",
- from: 7,
- to: 11,
- content: [
- { type: "text", text: "Beta", marks: [{ type: "bold" }] },
- ],
- },
- { id: "delete-gamma", kind: "delete", from: 12, to: 17 },
- ],
- });
- proposal.hunks[0].original = [{ text: "Beta", type: "text" }];
-
- yUndoPluginKey.getState(left.state).undoManager.clear();
- yUndoPluginKey.getState(right.state).undoManager.clear();
- right.commands.insertContentAt(1, "Remote ");
- left.commands.insertContentAt(left.state.doc.content.size - 1, " Local");
-
- const accepted = review.acceptProposalHunks({
- editor: left,
- documentEpoch: 1,
- proposal,
- hunkIds: ["format-beta"],
- });
- const reviewed = review.rejectProposalHunks({
- proposal: accepted,
- hunkIds: ["delete-gamma"],
- });
-
- expect(reviewed.hunks.map((hunk) => [hunk.id, hunk.status])).toEqual([
- ["format-beta", "accepted"],
- ["delete-gamma", "rejected"],
- ]);
- expect(left.getText()).toBe("Remote Alpha Beta Gamma Local");
- expect(right.getJSON()).toEqual(left.getJSON());
- expect(left.getJSON()).toEqual(
- expect.objectContaining({
- content: expect.arrayContaining([
- expect.objectContaining({
- content: expect.arrayContaining([
- expect.objectContaining({
- text: "Beta",
- marks: [{ type: "bold" }],
- }),
- ]),
- }),
- ]),
- }),
- );
-
- expect(left.commands.undo()).toBe(true);
- expect(left.getText()).toBe("Remote Alpha Beta Gamma");
- expect(JSON.stringify(left.getJSON())).toContain('"type":"bold"');
- expect(left.commands.redo()).toBe(true);
- expect(left.getText()).toBe("Remote Alpha Beta Gamma Local");
- expect(JSON.stringify(left.getJSON())).toContain('"type":"bold"');
- });
-
- it("accepts independent hunks sequentially after the first shifts the document", () => {
- const document = new Y.Doc();
- const editor = createEditor(document);
- editor.commands.setContent("Alpha Beta
");
- let proposal = review.createProposal({
- id: "proposal-sequential",
- authorId: "member-1",
- documentEpoch: 1,
- editor,
- hunks: [
- { id: "prefix", kind: "insert", at: 1, content: [{ type: "text", text: "Remote " }] },
- { id: "beta", kind: "format", from: 7, to: 11, content: [{ type: "text", text: "Beta", marks: [{ type: "bold" }] }] },
- ],
- });
-
- proposal = review.acceptProposalHunkInDocument({ document, documentEpoch: 1, hunkId: "prefix", proposal });
- proposal = review.acceptProposalHunkInDocument({ document, documentEpoch: 1, hunkId: "beta", proposal });
-
- expect(proposal.hunks.map(({ status }) => status)).toEqual(["accepted", "accepted"]);
- expect(editor.getText()).toBe("Remote Alpha Beta");
- expect(JSON.stringify(editor.getJSON())).toContain('"type":"bold"');
- });
-
- it("stales a remotely deleted and reinserted identical target", () => {
- const { left, right, rightDocument } = createSyncedEditors();
- left.commands.setContent("Alpha Beta Gamma
");
- const proposal = review.createProposal({
- id: "proposal-identity",
- authorId: "member-1",
- documentEpoch: 1,
- editor: left,
- hunks: [
- {
- id: "format-beta",
- kind: "format",
- from: 7,
- to: 11,
- content: [
- { type: "text", text: "Beta", marks: [{ type: "bold" }] },
- ],
- },
- ],
- });
-
- right.commands.insertContentAt(1, "Remote ");
- const paragraph = rightDocument.getXmlFragment("content").get(0);
- if (!(paragraph instanceof Y.XmlElement)) throw new Error("Expected paragraph element");
- const text = paragraph.get(0);
- if (!(text instanceof Y.XmlText)) throw new Error("Expected paragraph text");
- rightDocument.transact(() => {
- text.delete(13, 4);
- text.insert(13, "Beta");
- });
- const beforeAcceptance = left.getJSON();
-
- let stale: unknown;
- try {
- review.acceptProposalHunks({
- editor: left,
- documentEpoch: 1,
- proposal,
- hunkIds: ["format-beta"],
- });
- } catch (error) {
- stale = error;
- }
-
- expect(stale).toBeInstanceOf(review.StaleProposalError);
- if (!(stale instanceof review.StaleProposalError)) throw stale;
- expect(stale.proposal.hunks[0].status).toBe("stale");
- expect(left.getJSON()).toEqual(beforeAcceptance);
- expect(right.getJSON()).toEqual(beforeAcceptance);
- expect(left.getText()).toBe("Remote Alpha Beta Gamma");
- expect(JSON.stringify(left.getJSON())).not.toContain('"type":"bold"');
- });
-
- it("stales a remotely overlapped target and preserves unrelated remote text", () => {
- const { left, right } = createSyncedEditors();
- left.commands.setContent("Alpha Beta Gamma
");
- const proposal = review.createProposal({
- id: "proposal-overlap",
- authorId: "member-1",
- documentEpoch: 1,
- editor: left,
- hunks: [
- {
- id: "format-beta",
- kind: "format",
- from: 7,
- to: 11,
- content: [
- { type: "text", text: "Beta", marks: [{ type: "bold" }] },
- ],
- },
- ],
- });
-
- right.commands.insertContentAt(1, "Remote ");
- right.commands.insertContentAt(16, "X");
- const beforeAcceptance = left.getJSON();
-
- let stale: unknown;
- try {
- review.acceptProposalHunks({
- editor: left,
- documentEpoch: 1,
- proposal,
- hunkIds: ["format-beta"],
- });
- } catch (error) {
- stale = error;
- }
-
- expect(stale).toBeInstanceOf(review.StaleProposalError);
- if (!(stale instanceof review.StaleProposalError)) throw stale;
- expect(stale.proposal.hunks[0].status).toBe("stale");
- expect(left.getJSON()).toEqual(beforeAcceptance);
- expect(right.getJSON()).toEqual(beforeAcceptance);
- expect(left.getText()).toBe("Remote Alpha BeXta Gamma");
- expect(JSON.stringify(left.getJSON())).not.toContain('"type":"bold"');
- });
-
- it("marks changed and overlapping exact targets stale without fuzzy replacement", async () => {
- const changed = createEditor();
- changed.commands.setContent("alpha beta alpha
");
- const changedProposal = review.createProposal({
- id: "proposal-3",
- authorId: "member-1",
- documentEpoch: 1,
- editor: changed,
- hunks: [
- {
- id: "changed-target",
- kind: "format",
- from: 7,
- to: 11,
- content: [
- { type: "text", text: "beta", marks: [{ type: "bold" }] },
- ],
- },
- ],
- });
- changed.commands.insertContentAt({ from: 7, to: 11 }, "alpha");
-
- let changedError: unknown;
- try {
- review.acceptProposalHunks({
- editor: changed,
- documentEpoch: 1,
- proposal: changedProposal,
- hunkIds: ["changed-target"],
- });
- } catch (error) {
- changedError = error;
- }
- expect(changedError).toBeInstanceOf(review.StaleProposalError);
- if (!(changedError instanceof review.StaleProposalError)) throw changedError;
- expect(changedError.proposal.hunks[0].status).toBe("stale");
- expect(changed.getText()).toBe("alpha alpha alpha");
- expect(JSON.stringify(changed.getJSON())).not.toContain('"type":"bold"');
-
- const overlapping = createEditor();
- overlapping.commands.setContent("abcdefghij
");
- const overlappingProposal = review.createProposal({
- id: "proposal-4",
- authorId: "member-1",
- documentEpoch: 1,
- editor: overlapping,
- hunks: [
- {
- id: "left-range",
- kind: "format",
- from: 2,
- to: 6,
- content: [{ type: "text", text: "bcde", marks: [{ type: "bold" }] }],
- },
- { id: "right-range", kind: "delete", from: 4, to: 8 },
- ],
- });
-
- let overlapError: unknown;
- try {
- review.acceptProposalHunks({
- editor: overlapping,
- documentEpoch: 1,
- proposal: overlappingProposal,
- hunkIds: ["left-range", "right-range"],
- });
- } catch (error) {
- overlapError = error;
- }
- expect(overlapError).toBeInstanceOf(review.StaleProposalError);
- if (!(overlapError instanceof review.StaleProposalError)) throw overlapError;
- expect(overlapError.proposal.hunks.map((hunk) => hunk.status)).toEqual([
- "stale",
- "stale",
- ]);
- expect(overlapping.getText()).toBe("abcdefghij");
- });
-
- it("keeps quote anchors through unrelated insertion and detaches on target deletion", async () => {
- expect(review.createQuoteAnchor).toBeTypeOf("function");
- expect(review.resolveQuoteAnchor).toBeTypeOf("function");
-
- const editor = createEditor();
- editor.commands.setContent("Claim target context
");
- const anchor = review.createQuoteAnchor({
- id: "comment-1",
- documentEpoch: 1,
- editor,
- from: 7,
- to: 13,
- quote: "target",
- });
-
- editor.commands.insertContentAt(1, "Unrelated ");
- const moved = review.resolveQuoteAnchor({
- anchor,
- documentEpoch: 1,
- editor,
- });
- expect(moved).toMatchObject({ status: "attached", quote: "target" });
- if (moved.status !== "attached") return;
- expect(editor.state.doc.textBetween(moved.from, moved.to)).toBe("target");
-
- editor.commands.deleteRange({ from: moved.from, to: moved.to });
- expect(review.resolveQuoteAnchor({
- anchor,
- documentEpoch: 1,
- editor,
- })).toEqual({ status: "detached", quote: "target" });
- });
-});
diff --git a/src/test/workspace/tool-context.test.ts b/src/test/workspace/tool-context.test.ts
deleted file mode 100644
index 5469dbd64..000000000
--- a/src/test/workspace/tool-context.test.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-const saveArtifact = vi.hoisted(() => vi.fn().mockResolvedValue("artifact"));
-vi.mock("@/lib/workspace/practice-client", () => ({ saveWorkspaceArtifact: saveArtifact }));
-
-import { attachWorkspaceResult, parseWorkspaceToolContext, type WorkspaceToolContext } from "@/lib/workspace/tool-context";
-
-const context: WorkspaceToolContext = {
- documentEpoch: 3,
- documentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
- documentIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"],
- documentText: "R1 text",
- documentTitle: "R1",
- motion: "Schools should ban phones",
- requestId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
- returnUrl: "/app/workspaces/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
- side: "pro" as const,
- sourceIds: ["cccccccc-cccc-4ccc-8ccc-cccccccccccc"],
- threadRequest: null,
- threadId: null,
- workspaceId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
-};
-
-describe("workspace tool context", () => {
- it("preserves the pinned motion, side, document version, and origin", async () => {
- const parsed = parseWorkspaceToolContext(context);
- expect(parsed).toMatchObject({ documentEpoch: 3, motion: context.motion, side: "pro", workspaceId: context.workspaceId });
-
- await attachWorkspaceResult(parsed, "attack", { caseText: "R1 text" }, { score: 80 });
-
- expect(saveArtifact).toHaveBeenCalledOnce();
- expect(saveArtifact).toHaveBeenCalledWith(expect.objectContaining({
- documentEpoch: 3,
- documentId: context.documentId,
- requestId: expect.stringMatching(/^[0-9a-f-]{36}$/),
- tool: "attack",
- workspaceId: context.workspaceId,
- }));
- expect(saveArtifact.mock.calls[0][0].requestId).not.toBe(context.requestId);
- });
-
- it("rejects a foreign return route instead of attaching late output elsewhere", () => {
- expect(parseWorkspaceToolContext({ ...context, returnUrl: "/app/workspaces/not-a-uuid" })).toBeNull();
- });
-});
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 78b6155f9..dad52b9af 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -6,6 +6,7 @@ interface ImportMetaEnv {
readonly VITE_LOCAL_SUPABASE_URL?: string;
readonly VITE_STAGING_PREVIEW?: string;
readonly VITE_COLLABORATION_URL?: string;
+ readonly VITE_SUPABASE_ENV?: string;
readonly VITE_SENTRY_ENVIRONMENT?: string;
readonly VITE_SENTRY_RELEASE?: string;
}
diff --git a/supabase/functions/_shared/cors.test.ts b/supabase/functions/_shared/cors.test.ts
index 2f110017c..56d7c33cc 100644
--- a/supabase/functions/_shared/cors.test.ts
+++ b/supabase/functions/_shared/cors.test.ts
@@ -1,5 +1,5 @@
import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1.0.19";
-import { ALLOWED_ORIGINS, getCorsHeaders, isDevOrigin } from "./cors.ts";
+import { ALLOWED_ORIGINS, getCorsHeaders, isAllowedOrigin, isDevOrigin } from "./cors.ts";
Deno.test("allows the Capacitor WebView origin", () => {
const headers = getCorsHeaders("capacitor://localhost");
@@ -50,3 +50,27 @@ Deno.test("native share links use the public HTTPS origin", async () => {
const source = await Deno.readTextFile(new URL("../share/index.ts", import.meta.url));
assertStringIncludes(source, "shareUrl: `${PUBLIC_WEB_APP_ORIGIN}/share/${data.share_code}`");
});
+
+Deno.test("allows both Vercel projects' preview and staging hosts", () => {
+ for (const origin of [
+ "https://debate-navigator-git-feat-accou-6d6edb-adam-gaborjanis-projects.vercel.app",
+ "https://debate-navigator-da65qye7v-adam-gaborjanis-projects.vercel.app",
+ "https://debatelab-staging-git-main-adam-gaborjanis-projects.vercel.app",
+ "https://debatelab-staging.vercel.app",
+ ]) {
+ assertEquals(isAllowedOrigin(origin), true, origin);
+ assertEquals(getCorsHeaders(origin)["Access-Control-Allow-Origin"], origin);
+ }
+});
+
+Deno.test("rejects other Vercel projects and lookalike hosts", () => {
+ for (const origin of [
+ "https://chorely-git-main-adam-gaborjanis-projects.vercel.app",
+ "https://debate-navigator-git-x-someone-else.vercel.app",
+ "https://debate-navigator-x-adam-gaborjanis-projects.vercel.app.evil.com",
+ "http://debate-navigator-x-adam-gaborjanis-projects.vercel.app",
+ "https://debate-navigator.vercel.app",
+ ]) {
+ assertEquals(isAllowedOrigin(origin), false, origin);
+ }
+});
diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts
index 5ac9b4caa..32ca37bc7 100644
--- a/supabase/functions/_shared/cors.ts
+++ b/supabase/functions/_shared/cors.ts
@@ -19,6 +19,21 @@ export const ALLOWED_ORIGINS = [
"capacitor://localhost", // iOS Capacitor WebView
] as const;
+/**
+ * Vercel preview and staging hosts for the two DebateLab projects. Preview
+ * hosts end in this team's scope slug. Tokens live in the real origin's
+ * storage, so a lookalike host gains nothing from being allowed here.
+ */
+const VERCEL_PREVIEW_ORIGIN =
+ /^https:\/\/(?:debate-navigator|debatelab-staging)-[a-z0-9-]+-adam-gaborjanis-projects\.vercel\.app$/;
+const STAGING_WEB_APP_ORIGIN = "https://debatelab-staging.vercel.app";
+
+export function isAllowedOrigin(origin: string): boolean {
+ return (ALLOWED_ORIGINS as readonly string[]).includes(origin) ||
+ origin === STAGING_WEB_APP_ORIGIN ||
+ VERCEL_PREVIEW_ORIGIN.test(origin);
+}
+
export type CorsMethods = "POST, OPTIONS" | "GET, POST, OPTIONS" | "POST, GET, OPTIONS";
/**
@@ -52,7 +67,7 @@ export function getCorsHeaders(
{ allowDevOrigins = false, methods = "POST, OPTIONS" }: CorsOptions = {},
): Record {
const permitted = Boolean(origin) && (
- ALLOWED_ORIGINS.includes(origin as typeof ALLOWED_ORIGINS[number]) ||
+ isAllowedOrigin(origin as string) ||
(allowDevOrigins && isDevOrigin(origin as string))
);
return {
diff --git a/supabase/functions/demo-code-login/index.ts b/supabase/functions/demo-code-login/index.ts
index ce07557c3..a012b53fc 100644
--- a/supabase/functions/demo-code-login/index.ts
+++ b/supabase/functions/demo-code-login/index.ts
@@ -1,5 +1,5 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
-import { ALLOWED_ORIGINS, getCorsHeaders } from "../_shared/cors.ts";
+import { getCorsHeaders, isAllowedOrigin } from "../_shared/cors.ts";
import {
getDemoCodeDenialReason,
getDemoLoginRole,
@@ -38,11 +38,7 @@ function sanitizeRedirectTo(value: unknown): string | undefined {
// Web origins only. The shared CORS list also carries the Capacitor
// WebView scheme, which must never be a generateLink redirect target.
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
- return ALLOWED_ORIGINS.includes(
- `${url.protocol}//${url.host}` as typeof ALLOWED_ORIGINS[number],
- )
- ? value
- : undefined;
+ return isAllowedOrigin(`${url.protocol}//${url.host}`) ? value : undefined;
} catch {
return undefined;
}
diff --git a/supabase/functions/demo-code-login/index_test.ts b/supabase/functions/demo-code-login/index_test.ts
index 5c59c4465..ef247642f 100644
--- a/supabase/functions/demo-code-login/index_test.ts
+++ b/supabase/functions/demo-code-login/index_test.ts
@@ -29,6 +29,7 @@ type Scenario = {
attemptLookupFails?: boolean;
currentRole?: string | null;
existingUser?: boolean;
+ redirectTo?: string;
userMetadata?: Record;
};
@@ -37,9 +38,10 @@ async function runScenario({
attemptLookupFails = false,
currentRole = "user",
existingUser = true,
+ redirectTo,
userMetadata = {},
}: Scenario) {
- const calls: Array<{ body: Record | null; method: string; pathname: string }> = [];
+ const calls: Array<{ body: Record | null; method: string; pathname: string; search: string }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => {
@@ -47,7 +49,7 @@ async function runScenario({
const url = new URL(request.url);
const bodyText = request.method === "GET" ? "" : await request.clone().text();
const body = bodyText ? JSON.parse(bodyText) as Record : null;
- calls.push({ body, method: request.method, pathname: url.pathname });
+ calls.push({ body, method: request.method, pathname: url.pathname, search: url.search });
if (url.pathname === "/rest/v1/demo_access_attempts" && request.method !== "POST") {
return attemptLookupFails ? jsonResponse({ message: "attempt lookup failed" }, 500) : jsonResponse([]);
@@ -102,6 +104,7 @@ async function runScenario({
body: JSON.stringify({
code: "DEMO-AB12-CD34",
...(attemptLookupFails ? { browserFingerprint: "test-browser" } : {}),
+ ...(redirectTo ? { redirectTo } : {}),
}),
}));
return { calls, response };
@@ -189,3 +192,17 @@ Deno.test("attempt-store errors fail closed before code lookup", async () => {
throw new Error("attempt-store failure continued to code lookup");
}
});
+
+Deno.test("sends sign-in links back to a DebateLab preview but not to a foreign host", async () => {
+ const linkFor = async (redirectTo: string) => {
+ const { calls } = await runScenario({ existingUser: false, redirectTo });
+ const call = calls.find((entry) => entry.pathname === "/auth/v1/admin/generate_link");
+ return JSON.stringify(call?.body ?? null) + decodeURIComponent(call?.search ?? "");
+ };
+ const preview = "https://debatelab-staging-git-main-adam-gaborjanis-projects.vercel.app/app";
+ const sent = await linkFor(preview);
+ if (!sent.includes(preview)) throw new Error(`preview redirect was dropped: ${sent}`);
+ if ((await linkFor("https://evil.example.com/app")).includes("evil.example.com")) {
+ throw new Error("foreign redirect was passed to generate_link");
+ }
+});
diff --git a/vite.config.ts b/vite.config.ts
index 96d11d80c..3715316bb 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -73,11 +73,18 @@ function stagingPreviewProxy(stagingPreview: boolean) {
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
+ // src/lib/supabase/env.ts rejects any other value at startup; fail the build
+ // instead, so a typo in a Vercel variable never deploys a blank app.
+ const viteEnv = loadEnv(mode, process.cwd(), "VITE_");
+ const pinnedEnv = viteEnv.VITE_SUPABASE_ENV?.trim();
+ if (pinnedEnv && pinnedEnv !== "production" && pinnedEnv !== "staging") {
+ throw new Error(`VITE_SUPABASE_ENV must be "production" or "staging", not "${pinnedEnv}"`);
+ }
const release = buildRelease();
const uploadSourceMaps = sentrySourceMaps(release);
// Read the flag the way the client sees it (process env plus .env files), so
// the app never targets the proxy path while the server lacks the proxy.
- const stagingPreview = loadEnv(mode, process.cwd(), "VITE_").VITE_STAGING_PREVIEW === "true";
+ const stagingPreview = viteEnv.VITE_STAGING_PREVIEW === "true";
const proxy = stagingPreviewProxy(stagingPreview);
// Previews are reached through `tailscale serve`, which forwards the tailnet
// hostname. Vite rejects unknown hosts, so allow tailnet names in this mode only.
diff --git a/vitest.config.ts b/vitest.config.ts
index 440ad688f..5a51ceeef 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -11,36 +11,6 @@ export default defineConfig({
// Edge function tests are Deno tests. `npm run test:edge` runs them under
// the runtime they ship on, so Vitest does not re-run them through a shim.
include: ["src/**/*.{test,spec}.{ts,tsx}"],
- coverage: {
- provider: "v8",
- // Omit `all` intentionally: this is a selected-scope gate for the audited
- // runtime modules below, not a misleading sitewide coverage claim.
- include: [
- "src/lib/ai/client.ts",
- "src/lib/ai/schemas/sparring.ts",
- "src/lib/sparring/formats.ts",
- "src/lib/stores/sparring-store.ts",
- "src/lib/crossfire/prefill.ts",
- "src/lib/crossfire/reducer.ts",
- "src/lib/crossfire/schemas.ts",
- "src/lib/crossfire/storage.ts",
- "src/lib/stores/session-store.ts",
- "src/lib/stores/settings-store.ts",
- "src/lib/stripe.ts",
- "src/lib/supabase/AuthProvider.tsx",
- "src/hooks/use-subscription.ts",
- "src/hooks/use-subscription-error.ts",
- ],
- thresholds: {
- statements: 90,
- // thresholds.branches is 81.56% in the current selected-scope baseline.
- // TODO: ratchet branches/functions back to 90 after covering conditional
- // gaps in stripe.ts, AuthProvider.tsx, session-store.ts, and ai/client.ts.
- branches: 80,
- functions: 89,
- lines: 90,
- },
- },
},
resolve: {
alias: {