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:
Marc-André Bombeck committed 2026-09-11 09:36:07 +02:00
1 parent 74ae672373
commit e5cfdc9fdf
3 files changed
+323

No files matched your search

+132
View File
@@ -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();
}
}
+21
View File
@@ -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`,