From 2e6d99aea41b1760e9c40011185042736e20b9f9 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 13:20:41 +0000 Subject: [PATCH 1/2] Add the admin tables' missing columns and fix QA round 3 UI nits (T19: DB-01, bug 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production's rate_limits has no updated_at, so the admin function's Set limit returned 500. A new additive migration adds it (backfilled from created_at only when missing), the same for global_prompts, and the unique keys both admin upserts infer, skipping a key with a warning when duplicates would block it. No Edge Function changes. Sparring Start shows a spinner and what it is waiting for, sends one quota check however often it is pressed, and never stays spinning. The setup says the whole round costs N AI uses. The radar chart translates its axis. Ask's user bubble takes the bubble's color instead of prose gray (5.1:1 light, 6.7:1 dark) and its header wraps at 375 px in Hungarian. The model picker field shows only the model name; an Auto description sits below. Promo Pro in Settings says where Pro came from and that billing is optional until it ends. Sign out on Settings no longer makes Settings the next sign-in's landing page. Hungarian History says Beszélgetések, not Chatek. Co-Authored-By: Claude Opus 5.5 --- .../verify-debatelab/features/README.md | 10 +- .../verify-debatelab/features/ai-tools.md | 1 + .../features/auth-demo-login.md | 1 + .../features/model-defaults.md | 1 + .../verify-debatelab/features/practice.md | 4 +- .../verify-debatelab/recipes/ai-tools.spec.ts | 70 ++++++++++ .../verify-debatelab/recipes/auth.spec.ts | 23 ++++ .../recipes/billing-states.spec.ts | 12 ++ .../recipes/model-defaults.spec.ts | 26 ++++ .../verify-debatelab/recipes/practice.spec.ts | 58 +++++++++ e2e/full-stack/admin.spec.ts | 36 ++++++ package.json | 2 +- scripts/test-admin-table-drift-migration.sh | 121 ++++++++++++++++++ src/components/ModelSelector.tsx | 22 ++-- src/components/auth/RequireAuth.tsx | 10 +- src/components/features/ask/AskChat.tsx | 19 ++- .../features/sparring/SparringScorecard.tsx | 3 +- .../features/sparring/SparringSetup.tsx | 27 +++- .../subscription/SubscriptionStatus.tsx | 8 +- src/lib/supabase/AuthProvider.tsx | 11 +- src/lib/supabase/sign-out-intent.ts | 17 +++ src/locales/en.json | 14 +- src/locales/es.json | 14 +- src/locales/hu.json | 36 +++--- src/pages/LoginPage.tsx | 4 + src/pages/SparringPage.tsx | 42 +++++- src/test/auth/account-switch-route.test.tsx | 43 +++++-- .../components/SubscriptionStatus.test.tsx | 11 +- .../sparring/sparring-quota-start.test.tsx | 37 +++++- supabase/README.md | 2 + ...1023120000_reconcile_admin_table_drift.sql | 80 ++++++++++++ 31 files changed, 692 insertions(+), 73 deletions(-) create mode 100644 scripts/test-admin-table-drift-migration.sh create mode 100644 src/lib/supabase/sign-out-intent.ts create mode 100644 supabase/migrations/20261023120000_reconcile_admin_table_drift.sql diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 6270cb895..5ae0cecd8 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -39,20 +39,20 @@ Each feature file starts with an H1 title and one paragraph describing the user- ## Features -- [Demo-code sign-in](./auth-demo-login.md): sign-in, reload, rejected code, sign-out (`e2e/full-stack/auth.spec.ts`); email one-time-code sign-in through Mailpit, the emailed sign-in link through `/auth/callback` (including after a failed resend, and a used link), a signed-out deep link through a wrong code, the resend countdown and back, and a second account on the same browser (`recipes/auth.spec.ts`). +- [Demo-code sign-in](./auth-demo-login.md): sign-in, reload, rejected code, sign-out (`e2e/full-stack/auth.spec.ts`); email one-time-code sign-in through Mailpit, the emailed sign-in link through `/auth/callback` (including after a failed resend, and a used link), a signed-out deep link through a wrong code, the resend countdown and back, a second account on the same browser, and the same account after Sign out on Settings landing on `/app` (`recipes/auth.spec.ts`). - [Case generation](./case-generation.md): generate, web research, save and reopen from History on a fresh device, PDF and DOCX export, single-section regeneration, building a flowchart from the case, provider failure and retry, a reply cut off at its length cap, quota block (`recipes/case-generation.spec.ts`). - [Library sources](./library-sources.md): add, pause, delete, grounding, and URL import (`recipes/library.spec.ts`). -- [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cut off at its length cap, cited, and with web search (`recipes/ai-tools.spec.ts`). +- [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cut off at its length cap, cited, with web search, and the student's bubble contrast with the Hungarian layout at 375 px (`recipes/ai-tools.spec.ts`). - [Live generation](./live-generation.md): every AI tool shows its answer while the model writes it, Cancel mid-stream, and the event stream's shape (`recipes/live-generation.spec.ts`); Crossfire and Sparring requests stream too (`recipes/practice.spec.ts`). - [Current Debate](./current-debate.md): start, the first-debate offer and the different-motion choice, sources and results scoped to a debate, switching, and the first onboarding step (`recipes/current-debate.spec.ts`); debate context review and pins (`e2e/full-stack/debate-context.spec.ts`). -- [Model defaults](./model-defaults.md): Pro accounts start on Claude Sonnet 5.5 with Opus 5.5 as an opt-in, Basic accounts on Claude Haiku 5.5 with Pro models locked, and retired model IDs move to their successor (`recipes/model-defaults.spec.ts`). -- [Practice](./practice.md): a full Lincoln-Douglas sparring round with verdict, and Crossfire in both directions with debrief, each filed in its debate (`recipes/practice.spec.ts`). +- [Model defaults](./model-defaults.md): Pro accounts start on Claude Sonnet 5.5 with Opus 5.5 as an opt-in, Basic accounts on Claude Haiku 5.5 with Pro models locked, retired model IDs move to their successor, and an Auto choice fits the picker at 375 px (`recipes/model-defaults.spec.ts`). +- [Practice](./practice.md): the Sparring setup's whole-round cost and Start's pending state, a full Lincoln-Douglas sparring round with verdict (and its Hungarian chart labels), and Crossfire in both directions with debrief, each filed in its debate (`recipes/practice.spec.ts`). - [Voice rounds](./voice-rounds.md): spoken Sparring and Crossfire on the browser voice, with no ElevenLabs key (`recipes/voice-rounds.spec.ts`). - [Workspace autosave](./workspace-autosave.md): a slow Crossfire or Sparring save from a Workspace never overwrites newer text (`recipes/workspace-autosave.spec.ts`). Needs `up --collab`; skips without it. - [Account data](./account-data.md): Download my data and Delete account, each proven against a bystander (`recipes/account-data.spec.ts`); Delete All My Data scoped against a bystander (`e2e/full-stack/account-data.spec.ts`). - Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`). - [Usage limits](./usage-limits.md): Opus refused when its weight does not fit today while Haiku still runs, and the weekly AI budget dialog (`recipes/usage-limits.spec.ts`). -- Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`). +- Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, promo Pro without Stripe, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`). - [Localization](./localization.md): tab titles per route and language, translated form validation and server refusals, the language switcher on sign-in and 404, and `?lang=` with the `/es` and `/hu` home pages (`recipes/localization.spec.ts`). - Admin announcements and the non-admin block (`e2e/full-stack/admin.spec.ts`), public share links (`e2e/full-stack/public.spec.ts`), and Workspaces with live collaboration and invites (`e2e/workspace-collaboration.spec.ts`). diff --git a/.claude/skills/verify-debatelab/features/ai-tools.md b/.claude/skills/verify-debatelab/features/ai-tools.md index f30340f73..95c37da3e 100644 --- a/.claude/skills/verify-debatelab/features/ai-tools.md +++ b/.claude/skills/verify-debatelab/features/ai-tools.md @@ -8,6 +8,7 @@ The single-shot coaching tools: Attack finds weaknesses in a pasted argument, Br - `brainstorm-run` returns ranked ideas for a motion. Provider feature: `brainstorm`. - `factoids-run` finishes research and shows search suggestions. It drops unverified sources because there is no web key locally. Provider feature: `factoids`. - `ask-stream` streams a coaching answer through `ai-stream`. Provider feature: `ask-stream`, recorded as an `ask` usage row. +- `ask-bubble` measures the student's own message (body, bold, and its time line, test id `message-meta`) against the blue bubble in light and dark: each is at least 4.5:1 (about 5.1 light, 6.7 dark). At 375 px in Hungarian the placeholder `Mit kérdeznél?` fits the field and the chat header wraps instead of overflowing (`recipes/ai-tools.spec.ts`). - `ask-cited` uses the structured endpoint when citation mode is on. Provider feature: `ask`. ## How to get to it (user POV) diff --git a/.claude/skills/verify-debatelab/features/auth-demo-login.md b/.claude/skills/verify-debatelab/features/auth-demo-login.md index c9ae6bf3f..437abcdd0 100644 --- a/.claude/skills/verify-debatelab/features/auth-demo-login.md +++ b/.claude/skills/verify-debatelab/features/auth-demo-login.md @@ -12,6 +12,7 @@ A visitor signs in with a demo access code on the Login page. The `demo-code-log - `auth-session-expired` signs out a session the server revoked: the stored tokens are refused, so the app goes to `/login` and shows `Your session expired. Sign in again.` once (`recipes/auth.spec.ts`). - `auth-email-link` opens the sign-in link from the email after a resend failed with 429. It lands on `/app` through `/auth/callback` without showing `/login` on the way, and the same link opened again shows `This sign-in link has expired or was already used. Send a new code.` on `/login` (`recipes/auth.spec.ts`). - `auth-deep-link-email-code` opens `/app/generate` signed out, lands on `/login` (one `main`, an `h1`), sends a code, and sees `Resend in 0:NN` disabled. A wrong code shows `That code is wrong or has expired. Check it or send a new one.`. After the countdown it resends (`New code sent`, the old error gone), and the new code returns to `/app/generate` (`recipes/auth.spec.ts`). +- `auth-same-account-after-sign-out` signs out from Settings and signs the same account back in by email code: it lands on `/app`, not Settings. A session that ends by itself still returns the account to the page it left (`recipes/auth.spec.ts`). - `auth-switch-account` signs out from Settings (the logout request has `scope=local`) and signs a second account in on the same `/login` page. It lands on `/app`, not on Settings, and gets its own onboarding tour. On phones the menu still opens and navigates while the tour shows (`recipes/auth.spec.ts`). ## How to get to it (user POV) diff --git a/.claude/skills/verify-debatelab/features/model-defaults.md b/.claude/skills/verify-debatelab/features/model-defaults.md index f685f775d..6636f518e 100644 --- a/.claude/skills/verify-debatelab/features/model-defaults.md +++ b/.claude/skills/verify-debatelab/features/model-defaults.md @@ -6,6 +6,7 @@ Every AI tool has a model selector. Basic accounts can pick Claude Haiku 5.5, GP - `model-pro-default`: a new Pro account's `user_settings.feature_configs` hold `anthropic/claude-sonnet-5.5` for every tool, the selector shows `Claude Sonnet 5.5` with `Claude Opus 5.5` still enabled, and a Generate run records `model: anthropic/claude-sonnet-5.5` in `ai_requests`. - `model-basic-default`: a trial account with no saved settings gets `anthropic/claude-haiku-5.5` everywhere. `Claude Opus 5.5` and `Claude Sonnet 5.5` are disabled options, and a Generate run records Haiku 5.5. +- `model-picker-narrow`: with `Auto Fast` picked, the field shows only the name and test id `model-auto-description` shows its description below, in English and Hungarian at 375 px, with neither cut off. - `model-retired`: saved `anthropic/claude-sonnet-5` configs become `anthropic/claude-sonnet-5.5` on screen and in `user_settings`. ## How to get to it (user POV) diff --git a/.claude/skills/verify-debatelab/features/practice.md b/.claude/skills/verify-debatelab/features/practice.md index e628c9de0..ed565170e 100644 --- a/.claude/skills/verify-debatelab/features/practice.md +++ b/.claude/skills/verify-debatelab/features/practice.md @@ -5,6 +5,7 @@ Live practice modes. Sparring runs a multi-speech round against an AI opponent a ## Sub-features - `spar-round` starts a round with a chosen format, submits the user's speeches, and receives opponent speeches. +- `spar-start` the setup prices the whole round (test id `sparring-round-cost`, `N AI uses`, and `For the whole round: 4 replies + the judge` for the school format), in English and Hungarian at 375 px and 1280 px. While the pre-round quota check runs, Start reads `Opening room...`, is disabled with `aria-busy`, and test id `sparring-starting` says `Checking your AI uses for today…`; a second press sends no second check, and the round opens without a reload once the check answers. - `spar-verdict` judges the finished round. A `sparring_sessions` row reaches `status: complete`, with `side: pro` and the debate for its motion. - `cross-turn` answers a user question with an AI response and attaches a Jev `Direct answer` signal. - `cross-debrief` ends the session and shows the debrief. A `crossfires` row is filed in the debate for its resolution. @@ -27,7 +28,8 @@ Preconditions: - **Start a sparring round.** On `/app/sparring`, fill the field labelled `Motion`. Open the combobox named `Round format` and choose the option matching `Lincoln-Douglas · 5 speeches`. Click `Start round`. A new account has no Current Debate, so `Start a debate with this motion?` opens: `chooseForMotion(page, "create")`. - **Speeches.** For each user turn, fill the textbox named `Draft for ` (`Affirmative constructive`, `First affirmative rebuttal`, `Second affirmative rebuttal`) and click `Submit speech`. Each turn opens with a stopped clock (test id `sparring-ready`, button `Start speaking`); filling the draft starts it. After each of the first two, the last `FAKE-PROVIDER opponent speech` is visible. -- **Verdict.** The heading `Here is what happened in the room.` and the text `FAKE-PROVIDER verdict: the user had the edge.` appear. `providerCall("sparring").user` contains `SPARRING ACTION:`. `sparring_sessions` statuses for the user equal `["complete"]`, and `select("side,debates(motion)")` gives `{side: "pro", debates: {motion}}`. +- **Verdict.** The heading `Here is what happened in the room.` and the text `FAKE-PROVIDER verdict: the user had the edge.` appear. `providerCall("sparring").user` contains `SPARRING ACTION:`. `sparring_sessions` statuses for the user equal `["complete"]`, and `select("side,debates(motion)")` gives `{side: "pro", debates: {motion}}`. In Hungarian the radar chart axis (`.recharts-polar-angle-axis-tick text`) reads `Logika`, `Bizonyíték`, `Előadás`, `Stratégia`. +- **Hold the quota check.** `page.route("**/functions/v1/ai")` and wait before `route.fallback()` when the body has `quotaCheck: true`; register it after the page loads, since setup asks once on load. - **Start Crossfire.** On `/app/crossfire`, fill test id `crossfire-resolution` and `crossfire-argument`. Click `mode-question-ai` (or `mode-ai-questions-user`), then `start-crossfire`, then `chooseForMotion(page, "create")`. - **Crossfire turn.** Fill `crossfire-input` and click `submit-turn`. The text `FAKE-PROVIDER crossfire answer` and `Direct answer` appear. `providerCall("crossfire")` and `providerCall("jev")` both fired. - **Debrief.** Click `end-crossfire`. Test id `crossfire-debrief` is visible. `crossfires` for the user equals `[{title: , debates: {motion: }}]`. diff --git a/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts b/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts index 9822c67e2..8c98c2b07 100644 --- a/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts @@ -91,6 +91,76 @@ test("ask-stream: Ask streams a coaching answer and records usage", async ({ sig }, { timeout: AI_TIMEOUT }).toContainEqual(expect.objectContaining({ feature: "ask", status: "success" })); }); +/** WCAG contrast of an element's text against the first painted background behind it. */ +async function textContrast(page: import("@playwright/test").Page, text: string) { + return page.evaluate((needle) => { + // "meta" is the first message's time line, the student's own bubble. + const target = (needle === "meta" + ? document.querySelector("[data-testid=message-meta]") + : [...document.querySelectorAll("p, li, strong")].find((element) => element.textContent?.trim() === needle)) as HTMLElement | null | undefined; + if (!target) throw new Error(`no element with text ${needle}`); + const rgb = (value: string) => { + const ctx = document.createElement("canvas").getContext("2d")!; + ctx.fillStyle = value; + ctx.fillRect(0, 0, 1, 1); + return [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 4); + }; + let node: HTMLElement | null = target; + let background = "rgba(0, 0, 0, 0)"; + while (node) { + const color = getComputedStyle(node).backgroundColor; + if (rgb(color)[3] > 0) { background = color; break; } + node = node.parentElement; + } + const luminance = ([r, g, b]: number[]) => [r, g, b] + .map((channel) => { const c = channel / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }) + .reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0); + const [fg, bg] = [luminance(rgb(getComputedStyle(target).color)), luminance(rgb(background))]; + return { color: getComputedStyle(target).color, background, ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05) }; + }, text); +} + +// QA round 3: the student's own message was gray on blue (about 2:1), and the +// Hungarian placeholder was cut off at 375 px. +test("ask-bubble: the student's message meets WCAG AA in light and dark, and the placeholder fits at 375 px", async ({ signedIn: page, evidence }, testInfo) => { + if (testInfo.project.name === "mobile-chromium") await page.setViewportSize({ width: 375, height: 812 }); + const question = "How should I weigh **access** against cost?"; + await page.goto("/app/ask"); + await page.getByPlaceholder("Ask your debate coach...").fill(question); + const stream = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: AI_TIMEOUT }); + await page.getByRole("button", { name: "Send message" }).click(); + await stream; + const ratios: Record = {}; + for (const colorScheme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme }); + await expect(page.locator("html")).toHaveClass(colorScheme === "dark" ? /\bdark\b/ : /^(?!.*\bdark\b)/); + for (const [part, text] of [["body", "How should I weigh access against cost?"], ["bold", "access"], ["time", "meta"]] as const) { + const measured = await textContrast(page, text); + ratios[`${colorScheme}-${part}`] = measured; + expect(measured.ratio, `${colorScheme} ${part} contrast`).toBeGreaterThanOrEqual(4.5); + } + await evidence.shot(`01-user-bubble-${colorScheme}`); + } + await evidence.json("02-contrast", ratios); + await page.emulateMedia({ colorScheme: "light" }); + + await page.getByRole("button", { name: "Language", exact: true }).click(); + await page.getByRole("radio", { name: "Magyar" }).first().click(); + const box = page.getByPlaceholder("Mit kérdeznél?"); + await expect(box).toBeVisible(); + // The whole placeholder fits the field's text area. + const fits = await box.evaluate((input: HTMLTextAreaElement | HTMLInputElement) => { + const style = getComputedStyle(input); + const ctx = document.createElement("canvas").getContext("2d")!; + ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + const room = input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); + return { text: ctx.measureText(input.placeholder).width, room }; + }); + await evidence.json("03-hu-placeholder", fits); + expect(fits.text).toBeLessThanOrEqual(fits.room); + await evidence.shot("03-hu-placeholder"); +}); + test("ask-cutoff: a streamed answer cut off at its length cap says so under the answer", async ({ signedIn: page, evidence }) => { test.skip(LIVE, "only the fake provider can force a cut-off reply"); await fake.truncate("ask-stream"); diff --git a/.claude/skills/verify-debatelab/recipes/auth.spec.ts b/.claude/skills/verify-debatelab/recipes/auth.spec.ts index 2db719125..709775760 100644 --- a/.claude/skills/verify-debatelab/recipes/auth.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/auth.spec.ts @@ -253,3 +253,26 @@ test("auth-switch-account: the next account on this browser starts on /app, gets await deleteDemoUser(second); } }); + +// QA round 3: Sign out lives on Settings, and the same account signing back in by +// email code landed on /app/settings ("Opening Settings") instead of /app. +test("auth-same-account-after-sign-out: signing back in by email code after Sign out on Settings opens /app", async ({ signedIn: page, user, request, evidence }) => { + test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive"); + await page.goto("/app/settings"); + await page.getByRole("button", { name: "Sign out" }).click(); + await expect(page).toHaveURL(/\/login$/); + await evidence.step("Signed out from /app/settings"); + + const sentAfter = Date.now(); + await page.getByRole("tab", { name: "Email" }).click(); + await page.getByLabel("Email address").fill(user.email); + await page.getByRole("button", { name: "Send code" }).click(); + await expect(page.getByText("Code sent").first()).toBeVisible(); + const otp = await readOtp(request, user.email, sentAfter); + await page.getByRole("textbox", { name: "Verification code" }).fill(otp); + await page.getByRole("button", { name: "Verify code" }).click(); + await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 }); + await expect(page.getByText("Opening Settings")).toHaveCount(0); + await evidence.shot("01-landed-on-app"); + await evidence.step("The same account signed back in by email code and landed on /app"); +}); diff --git a/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts b/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts index bd07c0026..7658febde 100644 --- a/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/billing-states.spec.ts @@ -78,6 +78,18 @@ const STATES: State[] = [ dailyLimit: 50, refusal: { title: PAUSED, button: OK }, }, + { + // QA round 3: Pro from a promo code, no Stripe customer. Settings says where + // Pro came from and that billing can wait until it ends. + name: "promo-pro", + settings: { subscription_tier: "pro", grace_period_ends_at: at(365) }, + badge: { en: "Pro", hu: "Pro" }, + text: { en: /comes from a promo code and runs until .+\. Billing is optional until then/, hu: /promóciós kódból származik \(lejárat: .+\)\. Addig a számlázás beállítása nem kötelező/ }, + button: { en: "Set Up Billing", hu: "Számlázás beállítása" }, + plan: "plan-pro", + dailyLimit: 50, + refusal: { title: PAUSED, button: OK }, + }, { // QB-13: the tier the old webhook wrote on the first failed invoice. name: "past-due-grace", diff --git a/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts b/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts index bd90b9168..4f0063c41 100644 --- a/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts @@ -97,3 +97,29 @@ test("model-retired: a saved Claude Sonnet 5 setting moves to Claude Sonnet 5.5" await evidence.shot("01-retired-migrated"); await evidence.step("Saved Sonnet 5 configs were rewritten to Sonnet 5.5 in user_settings"); }); + +// QA round 3: at 375 px the Auto Fast description was squeezed into the field and clipped. +test("model-picker-narrow: an Auto choice shows its name in the field and its description below, at 375 px in Hungarian", async ({ signedIn: page, evidence }, testInfo) => { + if (testInfo.project.name === "mobile-chromium") await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/app/generate"); + const picker = page.getByRole("combobox", { name: "Select AI model for generate" }); + await picker.click(); + await page.getByRole("option", { name: /^Auto Fast/ }).click(); + await expect(picker).toHaveText("Auto Fast"); + await expect(page.getByTestId("model-auto-description")).toHaveText("Fast routing, usually Claude Haiku"); + await page.getByRole("button", { name: "Language", exact: true }).click(); + await page.getByRole("radio", { name: "Magyar" }).first().click(); + const description = page.getByTestId("model-auto-description"); + const huPicker = page.getByRole("combobox", { name: /^MI-modell kiválasztása/ }); + await expect(description).toHaveText("Gyors útválasztás, általában Claude Haikuval"); + // Nothing in the field or its description is cut off. + await expect(huPicker).toHaveText("Auto Fast"); + for (const element of [huPicker, description]) { + const clipped = await element.evaluate((node) => node.scrollWidth > node.clientWidth + 1); + expect(clipped).toBe(false); + } + await evidence.shot("01-auto-fast-hu"); + await huPicker.click(); + await evidence.shot("02-auto-fast-hu-open"); + await page.keyboard.press("Escape"); +}); diff --git a/.claude/skills/verify-debatelab/recipes/practice.spec.ts b/.claude/skills/verify-debatelab/recipes/practice.spec.ts index 10d98f992..5a62753a9 100644 --- a/.claude/skills/verify-debatelab/recipes/practice.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/practice.spec.ts @@ -45,6 +45,64 @@ test("spar-round and spar-verdict: a Lincoln-Douglas round is argued, judged, an expect(calls.some((call) => call.feature === "sparring" && /SPARRING ACTION: score/m.test(call.user ?? ""))).toBe(true); // Opponent speeches and the scorecard were shown live (features/live-generation.md). await expectStreamed(["sparring"]); + + // QA round 3: the radar chart's axis labels follow the interface language. + await switchLanguage(page, "Language", "Magyar"); + const axis = page.locator(".recharts-polar-angle-axis-tick text"); + await expect.poll(async () => (await axis.allTextContents()).sort()).toEqual(["Bizonyíték", "Előadás", "Logika", "Stratégia"]); + await evidence.shot("04-verdict-hu"); + await switchLanguage(page, "Nyelv", "English"); +}); + +/** The header's language popover: interface language first. */ +async function switchLanguage(page: import("@playwright/test").Page, buttonName: string, language: string) { + await page.getByRole("button", { name: buttonName, exact: true }).click(); + await page.getByRole("radio", { name: language }).first().click(); +} + +test("spar-start: the setup prices the whole round, and Start shows the quota check until the round opens", async ({ signedIn: page, evidence }, testInfo) => { + // The QA pass measured 375 px; the mobile project's Pixel 5 is wider. + if (testInfo.project.name === "mobile-chromium") await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/app/sparring"); + await page.getByLabel("Motion").fill(sparMotion); + // The school format has 4 opponent replies; the round is one charge, not one per reply. + await expect(page.getByTestId("sparring-round-cost")).toHaveText(/^\d+ AI uses?$/); + await expect(page.getByText("For the whole round: 4 replies + the judge")).toBeVisible(); + await evidence.shot("01-setup-cost-en"); + + await switchLanguage(page, "Language", "Magyar"); + await expect(page.getByText("A forduló ára", { exact: true })).toBeVisible(); + await expect(page.getByText("Az egész fordulóra: 4 válasz + a bíró")).toBeVisible(); + await expect(page.getByText("Az iskolai formátum: bevezető, R1, R2 és záró beszéd mindkét oldalon.")).toBeVisible(); + await evidence.shot("02-setup-cost-hu"); + await switchLanguage(page, "Nyelv", "English"); + + // Hold the pre-round quota check, as a slow server would. + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let checks = 0; + await page.route("**/functions/v1/ai", async (route) => { + const body = route.request().postDataJSON() as { quotaCheck?: boolean } | null; + if (body?.quotaCheck) { + checks += 1; + await held; + } + await route.fallback(); + }); + await page.getByRole("button", { name: "Start round" }).click(); + await chooseForMotion(page, "create"); + const pending = page.getByRole("button", { name: /Opening room/ }); + await expect(pending).toBeDisabled(); + await expect(pending).toHaveAttribute("aria-busy", "true"); + await expect(page.getByTestId("sparring-starting")).toHaveText("Checking your AI uses for today…"); + await pending.click({ force: true }); + await evidence.shot("03-start-pending"); + await evidence.step("Start pressed twice while the quota check was held"); + + release(); + await expect(page.getByText("Round in progress")).toBeVisible({ timeout: AI_TIMEOUT }); + expect(checks).toBe(1); + await evidence.shot("04-round-open"); }); async function startCrossfire(page: import("@playwright/test").Page, mode: "mode-question-ai" | "mode-ai-questions-user") { diff --git a/e2e/full-stack/admin.spec.ts b/e2e/full-stack/admin.spec.ts index e7bc856ab..94a5cdd27 100644 --- a/e2e/full-stack/admin.spec.ts +++ b/e2e/full-stack/admin.spec.ts @@ -37,3 +37,39 @@ test("a regular user cannot open the admin dashboard", async ({ signedIn: page } await expect(page.getByText("Access Denied")).toBeVisible(); await expect(page.getByRole("heading", { name: "Admin Dashboard" })).toHaveCount(0); }); + +/** The signed-in browser session's access token, for calling the function directly. */ +async function accessToken(page: import("@playwright/test").Page) { + return page.evaluate(() => { + const key = Object.keys(localStorage).find((candidate) => candidate.startsWith("debate-auth-")); + return key ? (JSON.parse(localStorage.getItem(key)!) as { access_token: string }).access_token : ""; + }); +} + +// Production's rate_limits had no updated_at, so Set limit failed there (DB-01); +// scripts/test-admin-table-drift-migration.sh replays that shape. This drives the real function. +test("an admin sets a per-user and a global rate limit through the admin function", async ({ signedIn: page, user }) => { + const limitType = `fullstack_daily_${Date.now()}`; + const userId = await user.userId(); + await admin.from("user_roles").update({ role: "admin" }).eq("user_id", userId); + await page.goto("/app/admin"); + await expect(page.getByRole("heading", { name: "Admin Dashboard" })).toBeVisible(); + const token = await accessToken(page); + const setLimit = (body: Record) => fetch(`${process.env.LOCAL_SUPABASE_URL}/functions/v1/admin?action=set-limit`, { + method: "POST", + headers: { "content-type": "application/json", apikey: process.env.SUPABASE_ANON_KEY ?? "", authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + }); + try { + for (const value of [5, 9]) { + expect((await setLimit({ userId, limitType, limitValue: value })).status).toBe(200); + expect((await setLimit({ limitType, limitValue: value * 10 })).status).toBe(200); + } + // Each second call updated the row the first one wrote; none was added. + const { data: rows } = await admin.from("rate_limits").select("user_id, limit_value, created_at, updated_at").eq("limit_type", limitType); + expect(rows?.map((row) => [row.user_id, row.limit_value]).sort()).toEqual([[userId, 9], [null, 90]].sort()); + expect(rows?.every((row) => row.updated_at >= row.created_at)).toBe(true); + } finally { + await admin.from("rate_limits").delete().eq("limit_type", limitType); + } +}); diff --git a/package.json b/package.json index b2c63c9ca..33190f590 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "start:collaboration": "tsx services/collaboration/src/index.ts", "test:watch": "vitest", "test:edge": "deno test --config supabase/functions/deno.json --node-modules-dir=auto --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-opus-default-migration.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh", + "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-opus-default-migration.sh && bash scripts/test-admin-table-drift-migration.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", diff --git a/scripts/test-admin-table-drift-migration.sh b/scripts/test-admin-table-drift-migration.sh new file mode 100644 index 000000000..4ad8df3fe --- /dev/null +++ b/scripts/test-admin-table-drift-migration.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Rebuilds production's legacy rate_limits and global_prompts (no updated_at, +# no unique keys) inside one transaction that is rolled back at the end, runs +# the drift migration, then issues the writes the admin function's Set limit +# and Set prompt send. Runs the migration again to show the rerun changes nothing, +# and once with duplicate rows to show it warns instead of failing or deleting. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +database_url="${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}" +database_password="${SUPABASE_DB_PASSWORD:-postgres}" + +cd "$repo_root" +PGPASSWORD="$database_password" psql "$database_url" -qX --set ON_ERROR_STOP=1 <<'SQL' +BEGIN; +SET LOCAL client_min_messages = error; + +CREATE FUNCTION pg_temp.check(passed boolean, message text) RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + IF passed IS NOT TRUE THEN RAISE EXCEPTION 'FAIL: %', message; END IF; +END; +$$; + +CREATE FUNCTION pg_temp.has_column(p_table text, p_column text) RETURNS boolean +LANGUAGE sql AS $$ + SELECT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = p_table AND column_name = p_column) +$$; + +CREATE FUNCTION pg_temp.unique_keys(p_table text) RETURNS bigint +LANGUAGE sql AS $$ + SELECT count(*) FROM pg_index WHERE indrelid = format('public.%I', p_table)::regclass AND indisunique AND NOT indisprimary +$$; + +-- Production's shape: drop what 20260204000000 would have created. +DO $$ +DECLARE legacy record; +BEGIN + FOR legacy IN + SELECT conrelid::regclass AS table_name, conname FROM pg_constraint + WHERE conrelid IN ('public.rate_limits'::regclass, 'public.global_prompts'::regclass) AND contype = 'u' + LOOP + EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', legacy.table_name, legacy.conname); + END LOOP; +END $$; +DROP TRIGGER rate_limits_set_updated_at ON public.rate_limits; +ALTER TABLE public.rate_limits DROP COLUMN updated_at; +ALTER TABLE public.global_prompts DROP COLUMN updated_at; +SELECT pg_temp.check(NOT pg_temp.has_column('rate_limits', 'updated_at') AND pg_temp.unique_keys('rate_limits') = 0, 'legacy rate_limits shape'); + +INSERT INTO auth.users (id) VALUES + ('d1000000-0000-4000-8000-00000000000a'), + ('d1000000-0000-4000-8000-00000000000b'); +INSERT INTO public.rate_limits (user_id, limit_type, limit_value, created_at) VALUES + (NULL, 'drift_daily', 50, '2026-01-01T00:00:00Z'), + ('d1000000-0000-4000-8000-00000000000a', 'drift_daily', 5, '2026-02-01T00:00:00Z'), + -- A duplicate per-user pair blocks the unique key. + ('d1000000-0000-4000-8000-00000000000b', 'drift_daily', 7, '2026-03-01T00:00:00Z'), + ('d1000000-0000-4000-8000-00000000000b', 'drift_daily', 8, '2026-03-02T00:00:00Z'); + +-- First run, with the duplicate: columns land, the key does not, nothing is deleted. +\i supabase/migrations/20261023120000_reconcile_admin_table_drift.sql + +SELECT pg_temp.check(pg_temp.has_column('rate_limits', 'updated_at') AND pg_temp.has_column('global_prompts', 'updated_at'), 'updated_at added to both tables'); +SELECT pg_temp.check( + (SELECT bool_and(updated_at = created_at) FROM public.rate_limits WHERE limit_type = 'drift_daily'), + 'updated_at backfilled from created_at' +); +SELECT pg_temp.check(pg_temp.unique_keys('rate_limits') = 0, 'duplicate rows keep the rate_limits key from being created'); +SELECT pg_temp.check((SELECT count(*) FROM public.rate_limits WHERE limit_type = 'drift_daily') = 4, 'no rate_limits row deleted'); +SELECT pg_temp.check(pg_temp.unique_keys('global_prompts') = 1, 'global_prompts gets its feature key'); + +-- The admin resolves the duplicate; a rerun now creates the key. +DELETE FROM public.rate_limits WHERE user_id = 'd1000000-0000-4000-8000-00000000000b' AND limit_value = 7; +\i supabase/migrations/20261023120000_reconcile_admin_table_drift.sql +SELECT pg_temp.check(pg_temp.unique_keys('rate_limits') = 1, 'rerun creates the rate_limits key once duplicates are gone'); + +-- Set limit for a user: the upsert supabase-js sends with onConflict "user_id,limit_type". +INSERT INTO public.rate_limits (user_id, limit_type, limit_value, updated_at) +VALUES ('d1000000-0000-4000-8000-00000000000a', 'drift_daily', 9, '2026-10-23T00:00:00Z') +ON CONFLICT (user_id, limit_type) DO UPDATE + SET limit_value = EXCLUDED.limit_value, updated_at = EXCLUDED.updated_at; +SELECT pg_temp.check( + (SELECT limit_value = 9 FROM public.rate_limits WHERE user_id = 'd1000000-0000-4000-8000-00000000000a' AND limit_type = 'drift_daily'), + 'per-user Set limit upserts in place' +); + +-- Set limit for everyone: find the newest global row by updated_at, then update it. +UPDATE public.rate_limits SET limit_value = 60, updated_at = '2026-10-23T00:00:00Z' +WHERE id = (SELECT id FROM public.rate_limits WHERE user_id IS NULL AND limit_type = 'drift_daily' + ORDER BY updated_at DESC LIMIT 1); +SELECT pg_temp.check( + (SELECT limit_value = 60 AND updated_at > created_at FROM public.rate_limits WHERE user_id IS NULL AND limit_type = 'drift_daily'), + 'global Set limit updates the newest row and its updated_at' +); + +-- Set prompt: the upsert with onConflict "feature". +INSERT INTO public.global_prompts (feature, prompt_text, is_active, created_by, updated_at) +VALUES ('generate', 'drift test prompt', true, NULL, now()) +ON CONFLICT (feature) DO UPDATE + SET prompt_text = EXCLUDED.prompt_text, is_active = EXCLUDED.is_active, updated_at = EXCLUDED.updated_at; +SELECT pg_temp.check( + (SELECT prompt_text = 'drift test prompt' FROM public.global_prompts WHERE feature = 'generate'), + 'Set prompt upserts' +); + +-- A third run on the reconciled tables changes nothing. +CREATE TEMP TABLE before_rerun AS SELECT id, ctid AS row_version, updated_at FROM public.rate_limits; +\i supabase/migrations/20261023120000_reconcile_admin_table_drift.sql +SELECT pg_temp.check( + NOT EXISTS (SELECT 1 FROM before_rerun AS b JOIN public.rate_limits AS r USING (id) + WHERE b.row_version IS DISTINCT FROM r.ctid OR b.updated_at IS DISTINCT FROM r.updated_at), + 'rerun rewrites no rate_limits row' +); +SELECT pg_temp.check(pg_temp.unique_keys('rate_limits') = 1 AND pg_temp.unique_keys('global_prompts') = 1, 'rerun adds no second key'); + +ROLLBACK; +SQL + +echo "Admin table drift migration: legacy shape, duplicates, Set limit, Set prompt and rerun passed" diff --git a/src/components/ModelSelector.tsx b/src/components/ModelSelector.tsx index 2b17a095a..a76c46a8e 100644 --- a/src/components/ModelSelector.tsx +++ b/src/components/ModelSelector.tsx @@ -43,6 +43,10 @@ export function ModelSelector({ feature, className, showLabel = false }: ModelSe const selectedModel = allModels.find((model) => model.id === config?.model); const currentModel = selectedModel?.id ?? DEFAULT_MODEL; const automatic = isAutoModelSelection(currentModel); + const shownModel = allModels.find((model) => model.id === currentModel) ?? allModels[0]; + const autoDescription = (id: string) => id === 'auto-fast' + ? t('modelSelector.autoFastDescription', 'Fast routing, usually GLM') + : t('modelSelector.autoProDescription', 'Routes among the strongest models'); const currentReasoning = !automatic && (isPro || subscriptionLoading) && config?.reasoningEffort && isReasoningEffort(config.reasoningEffort) ? config.reasoningEffort : 'low'; @@ -66,28 +70,25 @@ export function ModelSelector({ feature, className, showLabel = false }: ModelSe }} > - + {/* Only the name fits the field on a phone; an Auto choice's description goes below it. */} + {shownModel.name} {allModels.map((m) => ( - - + + {m.name} {m.isProOnly && Pro} {m.isAuto && ( - - {m.id === 'auto-fast' - ? t('modelSelector.autoFastDescription', 'Fast routing, usually GLM') - : t('modelSelector.autoProDescription', 'Routes among the strongest models')} - + {autoDescription(m.id)} )} @@ -95,6 +96,9 @@ export function ModelSelector({ feature, className, showLabel = false }: ModelSe ))} + {shownModel.isAuto && ( +

{autoDescription(shownModel.id)}

+ )}