mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
3999ea7b3c
commit
2b6f959434
2 files changed
+231
No files matched your search
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user