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(Tesztpárbeszéd); - 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(