mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
test(e2e): the AI offer follows the shared provider's health
Two runs on one account, and the only difference between them is a row in provider_health: a success inside the freshness window brings the offer and the tap leaves an active ai_full receipt; a dead credential inside its cooldown brings 'not answering' and no button at all. The seed is instance-wide because the projection is - a fresh account's own ledger is empty by construction - so the spec runs serially and puts the operator key and the ledger rows back afterwards. The offer path asserts that no provider was called: the flow reads a projection and never probes. Verified as a real check, not a tautology: moving the seeded success from 30 minutes to 25 hours ago makes the offer case fail.
This commit is contained in:
1 parent
74ae672373
commit
e5cfdc9fdf
3 files changed
+323
No files matched your search
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* v1.38.19 (wave D) — the AI offer at the end of the setup flow.
|
||||
*
|
||||
* The screen used to promise that insights "work for you right now" the
|
||||
* moment the operator had configured a key. It never knew whether the key
|
||||
* answered, and it never mentioned the consent receipt the egress actually
|
||||
* needs — so the person who believed it met a `consent.ai.required` refusal
|
||||
* on their first briefing. On 2026-09-11 the operator's own instance was
|
||||
* answering HTTP 500 from its OAuth proxy while the screen kept promising.
|
||||
*
|
||||
* Two runs, one account, and the only difference between them is a row in
|
||||
* `provider_health`:
|
||||
*
|
||||
* a success inside the freshness window → the offer, and one tap grants
|
||||
* a dead credential inside its cooldown → "not answering", and no button
|
||||
*
|
||||
* What is deliberately absent: any call to a provider. The flow's rule holds
|
||||
* — nothing in it talks to one — and a probe here would bill the operator for
|
||||
* every registration on the instance.
|
||||
*
|
||||
* Serial, and it restores the instance state it seeded: the tri-state folds
|
||||
* across accounts by design, so its inputs are instance-wide.
|
||||
*/
|
||||
import {
|
||||
E2E_SETUP_AI_OFFER,
|
||||
SETUP_AI_OFFER_STORAGE_STATE_PATH,
|
||||
} from "./setup/global-setup";
|
||||
import {
|
||||
clearAiConsent,
|
||||
clearOperatorProvider,
|
||||
seedOperatorProvider,
|
||||
seedSharedProviderResult,
|
||||
} from "./setup/ai-offer-fixture";
|
||||
import { resetSetupFlow } from "./setup/setup-flow-fixture";
|
||||
import { expect, test } from "./setup/test";
|
||||
import {
|
||||
answer,
|
||||
answerEverything,
|
||||
complete,
|
||||
expectScreen,
|
||||
} from "./setup-flow-helpers";
|
||||
|
||||
const OFFER = '[data-slot="onboarding-ai-offer"]';
|
||||
const GRANT = '[data-slot="onboarding-ai-offer-grant"]';
|
||||
const UNAVAILABLE = '[data-slot="onboarding-ai-unavailable"]';
|
||||
const SHARED_KEY = '[data-slot="onboarding-ai-shared-key"]';
|
||||
|
||||
/** Walk the account to the done screen through the API, then open it. */
|
||||
async function openDoneScreen(page: import("@playwright/test").Page) {
|
||||
await page.goto("/onboarding");
|
||||
await answerEverything(page);
|
||||
// Q6 applies because the answers name glucose and weight; the flow does not
|
||||
// complete while a screen it owes is unanswered.
|
||||
await answer(page, {
|
||||
step: "units",
|
||||
units: { glucoseUnit: "mmol/L", unitPreference: "metric" },
|
||||
});
|
||||
await complete(page);
|
||||
await answer(page, { step: "first-result", status: "skipped" });
|
||||
await page.goto("/onboarding/done");
|
||||
await expectScreen(page, "done");
|
||||
// The panel itself is unconditional; the variant inside it is not.
|
||||
await expect(page.locator('[data-slot="onboarding-ai-panel"]')).toBeVisible();
|
||||
}
|
||||
|
||||
// One account, one instance-wide ledger. Two workers driving this at once
|
||||
// would each read the other's seed.
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("the setup flow's AI offer follows the shared provider's health", () => {
|
||||
test.use({ storageState: SETUP_AI_OFFER_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetSetupFlow(E2E_SETUP_AI_OFFER.username);
|
||||
await clearAiConsent(E2E_SETUP_AI_OFFER.username);
|
||||
await seedOperatorProvider();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await clearOperatorProvider();
|
||||
await clearAiConsent(E2E_SETUP_AI_OFFER.username);
|
||||
});
|
||||
|
||||
test("offers one tap when the shared provider worked recently, and the tap leaves a receipt", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, {
|
||||
result: "ok",
|
||||
okMinutesAgo: 30,
|
||||
});
|
||||
|
||||
// No receipt before the tap — otherwise the assertion after it proves
|
||||
// nothing about the button.
|
||||
const before = await page.request.get(
|
||||
"/api/consent/ai/latest?kind=ai_full",
|
||||
);
|
||||
expect(before.status()).toBe(200);
|
||||
expect(
|
||||
((await before.json()) as { data: { receipt: unknown } }).data.receipt,
|
||||
).toBeNull();
|
||||
|
||||
await openDoneScreen(page);
|
||||
|
||||
await expect(page.locator(OFFER)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator(UNAVAILABLE)).toHaveCount(0);
|
||||
|
||||
// No provider was called to decide this. The flow reads a projection.
|
||||
const probes: string[] = [];
|
||||
page.on("request", (req) => {
|
||||
const path = new URL(req.url()).pathname;
|
||||
if (path === "/api/ai/test" || path.startsWith("/api/insights/"))
|
||||
probes.push(path);
|
||||
});
|
||||
|
||||
await page.locator(GRANT).click();
|
||||
|
||||
// The line the person reads swaps to the granted one.
|
||||
await expect(page.locator(SHARED_KEY)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator(GRANT)).toHaveCount(0);
|
||||
expect(probes, "the offer must not trigger a provider call").toEqual([]);
|
||||
|
||||
const after = await page.request.get("/api/consent/ai/latest?kind=ai_full");
|
||||
const receipt = (
|
||||
(await after.json()) as {
|
||||
data: { receipt: { kind: string; revokedAt: string | null } | null };
|
||||
}
|
||||
).data.receipt;
|
||||
expect(receipt).not.toBeNull();
|
||||
expect(receipt!.kind).toBe("ai_full");
|
||||
expect(receipt!.revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
test("says the provider is not answering while its credential is benched, and offers no button", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await seedSharedProviderResult(E2E_SETUP_AI_OFFER.username, {
|
||||
result: "auth_failed",
|
||||
});
|
||||
|
||||
await openDoneScreen(page);
|
||||
|
||||
await expect(page.locator(UNAVAILABLE)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator(GRANT)).toHaveCount(0);
|
||||
await expect(page.locator(OFFER)).toHaveCount(0);
|
||||
// The unproven promise is not shown to somebody who has not consented.
|
||||
await expect(page.locator(SHARED_KEY)).toHaveCount(0);
|
||||
|
||||
// The rest of the panel is untouched: the flow stays complete without it.
|
||||
await expect(
|
||||
page.locator('[data-slot="onboarding-ai-keyless"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="onboarding-open-dashboard"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// The grant route itself stays open — it is the user's own consent act
|
||||
// and is reachable from Settings at any time. What this case pins is the
|
||||
// decision the screen was handed, so read it back from the source.
|
||||
const status = await page.request.get("/api/user/ai-provider");
|
||||
const data = (
|
||||
(await status.json()) as {
|
||||
data: { serverProviderHealth: string; serverProviderOffer: boolean };
|
||||
}
|
||||
).data;
|
||||
expect(data.serverProviderHealth).toBe("unhealthy");
|
||||
expect(data.serverProviderOffer).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* v1.38.19 (wave D) — the instance state the shared-provider offer reads.
|
||||
*
|
||||
* Two rows decide what the setup flow's last screen may say, and neither has
|
||||
* a product endpoint that writes them the way this journey needs:
|
||||
*
|
||||
* `app_settings.admin_ai_key_encrypted` — presence alone makes the operator
|
||||
* the origin that would serve a user with no provider of their own
|
||||
* (`managedBy: "server"`). Nothing decrypts it on this path, so the value
|
||||
* below is a placeholder ciphertext and not a credential of any kind.
|
||||
*
|
||||
* `provider_health` — the ledger the tri-state folds. It is written only as
|
||||
* a side effect of a real completion, which this suite must never make, so
|
||||
* the journey states the outcome it wants to reason about instead.
|
||||
*
|
||||
* Both are INSTANCE-wide by nature: the projection deliberately folds across
|
||||
* accounts, because a fresh account's own ledger is empty by construction.
|
||||
* That is why the spec runs serially and puts both back afterwards.
|
||||
*
|
||||
* Seeded through `pg`, the same transport `global-setup.ts` and the other
|
||||
* fixtures use.
|
||||
*/
|
||||
import pg from "pg";
|
||||
|
||||
/** Not a key. Presence is the only thing read on this path. */
|
||||
const PLACEHOLDER_ADMIN_KEY = "e2e-admin-key-presence-only";
|
||||
|
||||
function connect(): pg.Pool {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error("[ai-offer-fixture] DATABASE_URL is not set");
|
||||
}
|
||||
return new pg.Pool({ connectionString: url });
|
||||
}
|
||||
|
||||
async function userId(pool: pg.Pool, username: string): Promise<string> {
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
`SELECT id FROM users WHERE username = $1`,
|
||||
[username],
|
||||
);
|
||||
const id = rows[0]?.id;
|
||||
if (!id) throw new Error(`[ai-offer-fixture] no account named ${username}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The operator has configured a shared provider on this instance. */
|
||||
export async function seedOperatorProvider(): Promise<void> {
|
||||
const pool = connect();
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO app_settings (id, admin_ai_key_encrypted)
|
||||
VALUES ('singleton', $1)
|
||||
ON CONFLICT (id) DO UPDATE SET admin_ai_key_encrypted = EXCLUDED.admin_ai_key_encrypted`,
|
||||
[PLACEHOLDER_ADMIN_KEY],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Put the instance back: no operator key, no ledger rows for either tag. */
|
||||
export async function clearOperatorProvider(): Promise<void> {
|
||||
const pool = connect();
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE app_settings SET admin_ai_key_encrypted = NULL WHERE id = 'singleton'`,
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM provider_health
|
||||
WHERE provider_type IN ('admin-openai', 'admin-codex')`,
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State one outcome for the operator's OpenAI provider.
|
||||
*
|
||||
* `ok` — it served somebody `okMinutesAgo` minutes ago.
|
||||
* `auth_failed` — its credential is dead and benched for another hour,
|
||||
* which is the shape the operator's own instance was in on
|
||||
* 2026-09-11 (a 500 from the OAuth proxy, then a 401).
|
||||
*/
|
||||
export async function seedSharedProviderResult(
|
||||
username: string,
|
||||
outcome: { result: "ok"; okMinutesAgo: number } | { result: "auth_failed" },
|
||||
): Promise<void> {
|
||||
const pool = connect();
|
||||
try {
|
||||
const id = await userId(pool, username);
|
||||
await pool.query(
|
||||
`DELETE FROM provider_health
|
||||
WHERE provider_type IN ('admin-openai', 'admin-codex')`,
|
||||
);
|
||||
if (outcome.result === "ok") {
|
||||
await pool.query(
|
||||
`INSERT INTO provider_health
|
||||
(id, user_id, provider_type, last_result, consecutive_failures,
|
||||
last_ok_at, updated_at)
|
||||
VALUES ($1, $2, 'admin-openai', 'ok', 0,
|
||||
NOW() - ($3 || ' minutes')::interval, NOW())`,
|
||||
[`e2e-ph-${id}`, id, String(outcome.okMinutesAgo)],
|
||||
);
|
||||
} else {
|
||||
await pool.query(
|
||||
`INSERT INTO provider_health
|
||||
(id, user_id, provider_type, last_result, last_status,
|
||||
consecutive_failures, last_failure_at, next_retry_at, updated_at)
|
||||
VALUES ($1, $2, 'admin-openai', 'auth_failed', 401, 1,
|
||||
NOW(), NOW() + interval '1 hour', NOW())`,
|
||||
[`e2e-ph-${id}`, id],
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** No receipt on file — the state a fresh account reaches the screen in. */
|
||||
export async function clearAiConsent(username: string): Promise<void> {
|
||||
const pool = connect();
|
||||
try {
|
||||
const id = await userId(pool, username);
|
||||
await pool.query(`DELETE FROM consent_receipts WHERE user_id = $1`, [id]);
|
||||
await pool.query(`DELETE FROM rate_limits WHERE key = 'consent:' || $1`, [
|
||||
id,
|
||||
]);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
@@ -487,6 +487,21 @@ export const E2E_SETUP_LOCALE = {
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.38.19 (wave D) — the shared-provider offer's own account.
|
||||
*
|
||||
* Its journey seeds INSTANCE-wide state (the operator key's presence and the
|
||||
* provider-health ledger the tri-state folds) and mints a consent receipt on
|
||||
* the account, so it cannot share one with a journey that asserts a module
|
||||
* map or a tile order.
|
||||
*/
|
||||
export const E2E_SETUP_AI_OFFER = {
|
||||
email: "e2e-setup-ai-offer@healthlog.test",
|
||||
username: "e2e-setup-ai-offer",
|
||||
password: "Ao6!Zd3fB9qMv2Rk",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
export const SETUP_BP_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateSetupBp.json",
|
||||
@@ -511,6 +526,10 @@ export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateSetupLocale.json",
|
||||
);
|
||||
export const SETUP_AI_OFFER_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateSetupAiOffer.json",
|
||||
);
|
||||
|
||||
const SETUP_FLOW_ACCOUNTS = [
|
||||
E2E_SETUP_BP,
|
||||
@@ -519,6 +538,7 @@ const SETUP_FLOW_ACCOUNTS = [
|
||||
E2E_SETUP_CHILD,
|
||||
E2E_SETUP_A11Y,
|
||||
E2E_SETUP_LOCALE,
|
||||
E2E_SETUP_AI_OFFER,
|
||||
] as const;
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
@@ -1261,6 +1281,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
await capture(E2E_SETUP_VISIT, SETUP_VISIT_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SETUP_AI_OFFER, SETUP_AI_OFFER_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH);
|
||||
await pool.query(
|
||||
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
|
||||
|
||||
Reference in new issue
Block a user