test(e2e): mocked Codex device-flow + insights regenerate specs

codex-flow.spec.ts intercepts /api/auth/codex/device-start +
device-poll + /api/insights/settings to drive the UI through the
three-state machine (CTA → device-code panel → "ChatGPT connected"
badge) without touching auth.openai.com / chatgpt.com.

insights-generate.spec.ts mocks /api/insights/generate with the
canonical InsightResult shape (changed/stable/drivers/nextSteps/
confidence/limitations) and confirms the regenerate button surfaces
the inline success message — the AI provider stays uninvolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 16:49:00 +02:00
1 parent 3999ea7b3c
commit 2b6f959434
2 files changed
+231

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Settings → KI Codex device-flow spec. The real OAuth handshake bounces
* through chatgpt.com, which we deliberately do NOT hit from CI; instead
* we mock the three relevant endpoints (`device-start`, `device-poll`,
* `insights/settings`) so the assertion focuses on the UI state machine:
*
* 1. "Connect with ChatGPT" CTA visible
* 2. After click → device-code panel showing userCode + verificationUrl
* 3. After mocked poll flips to `connected` → "ChatGPT connected" badge
*/
test.describe("settings AI codex device flow (mocked)", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("button → device-code panel → connected state", async ({ page }) => {
let connected = false;
// Status endpoint — flips to "connected" once `connected` is set.
await page.route("**/api/insights/settings", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
codexStatus: connected ? "connected" : "disconnected",
codexConnectedAt: connected ? new Date().toISOString() : null,
hasAdminKey: false,
codexOauthConfigured: true,
privacyMode: "aggregated",
lastInsightAt: null,
},
error: null,
}),
}),
);
// device-start — returns the user-facing code + verification URL.
await page.route("**/api/auth/codex/device-start", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
userCode: "WXYZ-7890",
verificationUrl: "https://chatgpt.com/codex/device",
intervalSeconds: 1,
},
error: null,
}),
}),
);
// device-poll — first call returns `pending`; flips to `connected`
// after a short delay so the test can assert the transition.
let pollCount = 0;
await page.route("**/api/auth/codex/device-poll", (route) => {
pollCount += 1;
const status = pollCount >= 2 ? "connected" : "pending";
if (status === "connected") connected = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { status },
error: null,
}),
});
});
// Mock the user AI provider endpoint — the AI section renders a
// sub-panel that fetches this; we want it to resolve quickly so the
// page is interactive.
await page.route("**/api/user/ai-provider", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
provider: null,
model: null,
baseUrl: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
},
error: null,
}),
}),
);
await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
// Step 1 — CTA visible
const cta = page.getByRole("button", {
name: /connect with chatgpt|mit chatgpt verbinden/i,
});
await expect(cta).toBeVisible({ timeout: 10_000 });
await cta.click();
// Step 2 — device-code panel shows the userCode and the verificationUrl
await expect(page.getByText("WXYZ-7890")).toBeVisible({ timeout: 5_000 });
await expect(
page.getByRole("link", { name: /chatgpt\.com\/codex\/device/i }),
).toBeVisible();
// Step 3 — once the polling loop hits `connected`, the badge appears.
// The poll runs at the device-flow `intervalSeconds` (1 second).
await expect(page.getByText(/chatgpt connected/i).first()).toBeVisible({
timeout: 15_000,
});
// The device-code panel should disappear once connected (the
// settings card swaps to the connected layout).
await expect(page.getByText("WXYZ-7890")).toBeHidden({ timeout: 5_000 });
});
});
+110
View File
@@ -0,0 +1,110 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Insights generation flow — `/settings/ai` exposes a "Regenerate
* insights" button that POSTs `/api/insights/generate` and re-renders
* the AI-generated text in the surrounding cards.
*
* The real endpoint hits an AI provider (OpenAI / Codex) which we can't
* call from CI. We mock it with the schema documented in
* `src/app/api/insights/generate/route.ts`:
*
* { data: { insights: { changed, stable, drivers, nextSteps,
* confidence, limitations }, cached: false }, error: null }
*
* We then confirm the success message lands AND a query invalidation
* fires on the insight-fetching endpoints (we re-mock those to assert
* they're touched after the regenerate call).
*/
test.describe("insights generation flow (mocked)", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("regenerate button updates insight cards", async ({ page }) => {
// Settings endpoint — return "connected" so the regenerate button
// is rendered (the section gates it on hasProvider).
await page.route("**/api/insights/settings", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
codexStatus: "connected",
codexConnectedAt: new Date().toISOString(),
hasAdminKey: false,
codexOauthConfigured: true,
privacyMode: "aggregated",
lastInsightAt: new Date().toISOString(),
},
error: null,
}),
}),
);
await page.route("**/api/user/ai-provider", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
provider: null,
model: null,
baseUrl: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
},
error: null,
}),
}),
);
let generateCalls = 0;
await page.route("**/api/insights/generate", (route) => {
generateCalls += 1;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
insights: {
changed:
"Blood-pressure averages dropped by 4/2 mmHg over the last 30 days.",
stable: "Pulse and weight unchanged within natural variance.",
drivers:
"Likely driven by improved medication compliance (88% vs 72% prior).",
nextSteps:
"Continue current routine; revisit BMI target in 30 days.",
confidence: "mittel",
limitations:
"Sample size for systolic readings remains below 60.",
},
cached: false,
},
error: null,
}),
});
});
await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
const regenerateBtn = page.getByRole("button", {
name: /regenerate (insights|reports)|insights aktualisieren|neu erstellen|berichte/i,
});
await expect(regenerateBtn).toBeVisible({ timeout: 10_000 });
await regenerateBtn.click();
// The settings page surfaces an inline alert after the mutation
// completes. The exact copy comes from i18n, so match permissively.
await expect(
page.getByText(
/reports? regenerated|insight.*regenerated|berichte.*aktualisiert|generated successfully|erfolgreich/i,
),
).toBeVisible({ timeout: 10_000 });
expect(generateCalls).toBeGreaterThanOrEqual(1);
});
});