import type { PoolClient } from 'pg' import { randomUUID } from 'node:crypto' import { Pool } from 'pg' import { afterAll, describe, expect, it } from 'vitest' import { getOrCreateUserABTests } from '../supabase/functions/_backend/utils/ab_tests.ts' import { APP_ONBOARDING_OTA_V1_STEP_IDS, parseAppOnboarding } from '../supabase/functions/_backend/utils/appOnboarding.ts' import { POSTGRES_URL, requestDirectDatabaseRoute } from './test-utils.ts' const pool = new Pool({ connectionString: POSTGRES_URL, max: 4 }) const experiment = 'todo_list_v4_double_intent' const at = '2026-10-18T00:00:00.000Z' const assignment = (branch = 'A') => ({ branch, assigned_at: at }) afterAll(async () => pool.end()) interface Scenario { intent?: string | null orgIntent?: string branch?: string | null invited?: boolean ownOrg?: boolean creator?: 'self' | 'other' | 'missing' } async function fixture(client: PoolClient, scenario: Scenario = {}) { const userId = randomUUID() const otherId = randomUUID() const orgId = randomUUID() const appId = `com.test.doubleintent.${randomUUID()}` for (const id of [userId, otherId]) { const email = `double-intent-${id}@example.com` await client.query('INSERT INTO auth.users(id, email) VALUES ($1, $2)', [id, email]) await client.query('INSERT INTO public.users(id, email) VALUES ($1, $2)', [id, email]) } const intent = scenario.intent === undefined ? 'both' : scenario.intent const branch = scenario.branch === undefined ? 'A' : scenario.branch const onboarding = { ...(intent === null ? {} : { intent }), abtests: branch ? { [experiment]: assignment(branch) } : {} } await client.query('UPDATE public.users SET created_via_invite = $2, onboarding = $3 WHERE id = $1', [userId, scenario.invited ?? false, onboarding]) await client.query(`INSERT INTO public.orgs(id, created_by, name, management_email, onboarding) VALUES ($1, $2, 'Double intent test', 'double-intent@example.com', $3)`, [orgId, scenario.ownOrg === false ? otherId : userId, { intent: scenario.orgIntent ?? intent }]) const creator = scenario.creator === 'missing' ? {} : { created_by_user_id: scenario.creator === 'other' ? otherId : userId } await client.query(`INSERT INTO public.apps(app_id, owner_org, name, icon_url, need_onboarding, onboarding) VALUES ($1, $2, 'Double intent test', '', true, $3)`, [appId, orgId, creator]) const read = async () => (await client.query('SELECT onboarding, need_onboarding FROM public.apps WHERE app_id = $1', [appId])).rows[0] const reconcile = async () => (await client.query('SELECT public.reconcile_double_intent_app_onboarding($1) AS changed', [userId])).rows[0].changed return { userId, otherId, orgId, appId, onboarding, read, reconcile } } async function inTransaction(run: (client: PoolClient) => Promise) { const client = await pool.connect() try { await client.query('BEGIN') await run(client) } finally { await client.query('ROLLBACK') client.release() } } function expectBoth(onboarding: any) { expect(onboarding.setup).toMatchObject({ todo_list_version: 4, ota_todo_list_version: '1', builder_todo_list_version: '1' }) expect([...onboarding.setup.paths].sort()).toEqual(['builder', 'ota']) expect(Object.keys(onboarding.setup.steps.ota).sort()).toEqual([...APP_ONBOARDING_OTA_V1_STEP_IDS].sort()) expect(Object.keys(onboarding.setup.steps.builder.ios)).toHaveLength(6) expect(Object.keys(onboarding.setup.steps.builder.android)).toHaveLength(4) } describe('persisted double intent checklist lifecycle', () => { it.concurrent.each(['both', 'exploring'])('initializes both paths for eligible A / %s, with the compatible OTA default', async intent => inTransaction(async (client) => { const f = await fixture(client, { intent }) const { onboarding } = await f.read() expectBoth(onboarding) expect(onboarding.setup.selected_path).toBe('ota') expect(parseAppOnboarding(onboarding).ota_todo_list_version).toBe('1') expect(onboarding.setup.selected_builder_platform).toBeUndefined() expect(await f.reconcile()).toBe(0) expect((await f.read()).onboarding).toEqual(onboarding) })) it.concurrent.each([ { branch: 'B' }, { branch: null }, { branch: 'C' }, { invited: true }, { intent: 'ota' }, { intent: 'builder' }, { intent: null }, { ownOrg: false }, { creator: 'other' }, { creator: 'missing' }, { orgIntent: 'ota' }, { orgIntent: 'builder' }, ] satisfies Scenario[])('keeps the normal OTA checklist for ineligible fixture %j', async scenario => inTransaction(async (client) => { const f = await fixture(client, scenario) const before = await f.read() expect(before.onboarding.setup.paths).toEqual(['ota']) expect(await f.reconcile()).toBe(0) expect(await f.read()).toEqual(before) })) it.concurrent.each(['ota', 'builder'])('keeps existing pure %s behavior', async intent => inTransaction(async (client) => { const f = await fixture(client, { intent, branch: null }) await client.query('UPDATE public.users SET onboarding = $2 WHERE id = $1', [f.userId, { intent, abtests: { builder_todo_list_v4: assignment() } }]) const appId = `${f.appId}.pure` await client.query(`INSERT INTO public.apps(app_id, owner_org, name, icon_url, onboarding) VALUES ($1, $2, 'Pure checklist', '', $3)`, [appId, f.orgId, { created_by_user_id: f.userId }]) const { onboarding } = (await client.query('SELECT onboarding FROM public.apps WHERE app_id = $1', [appId])).rows[0] expect(onboarding.setup.paths).toEqual([intent]) expect(onboarding.setup.selected_path).toBe(intent) })) it.concurrent.each(['ota', 'builder'])('additively upgrades existing %s progress and remains byte-for-byte idempotent', async path => inTransaction(async (client) => { const f = await fixture(client, { branch: null }) const steps = path === 'ota' ? { ota: { add_code: { status: 'done', at, source: 'cli' }, upload_bundle: { status: 'skipped', at } } } : { builder: { ios: { prepare_certificate: { status: 'warning', at, annotation: 'keep' } }, android: { prepare_keystore: { status: 'done', at } } } } const setup = { todo_list_version: 4, [`${path}_todo_list_version`]: '1', paths: [path], selected_path: path, selected_builder_platform: 'android', source: 'mcp', outcome: 'in_progress', started_at: at, updated_at: at, steps } const original = { created_by_user_id: f.userId, setup, features: { ota: { stage: 'local_only', at } }, custom: 'keep' } await client.query('UPDATE public.apps SET onboarding = $2 WHERE app_id = $1', [f.appId, original]) await client.query('UPDATE public.users SET onboarding = $2 WHERE id = $1', [f.userId, { ...f.onboarding, abtests: { [experiment]: assignment() } }]) expect(await f.reconcile()).toBe(1) const upgraded = (await f.read()).onboarding expectBoth(upgraded) expect(upgraded).toMatchObject({ ...original, setup: { ...setup, paths: upgraded.setup.paths } }) expect(upgraded.setup.selected_path).toBe(path) expect(await f.reconcile()).toBe(0) expect((await f.read()).onboarding).toEqual(upgraded) })) it.concurrent.each(['dismissed', 'completed', 'skipped', 'inactive'])('does not resurrect %s onboarding', async state => inTransaction(async (client) => { const f = await fixture(client, { branch: null }) const original = (await f.read()).onboarding if (state === 'dismissed') original.getting_started_dismissed_at = at if (state === 'completed' || state === 'skipped') original.setup = { ...original.setup, outcome: state, completed_at: at } await client.query('UPDATE public.apps SET onboarding = $2, need_onboarding = $3 WHERE app_id = $1', [f.appId, original, state !== 'inactive']) const before = await f.read() await client.query('UPDATE public.users SET onboarding = $2 WHERE id = $1', [f.userId, { ...f.onboarding, abtests: { [experiment]: assignment() } }]) expect(await f.reconcile()).toBe(0) expect(await f.read()).toEqual(before) })) it.concurrent('does not accept a client-forged treatment or invite provenance', async () => inTransaction(async (client) => { const f = await fixture(client, { branch: 'B', invited: true }) await client.query('SELECT pg_catalog.set_config(\'request.jwt.claims\', $1, true)', [JSON.stringify({ sub: f.userId, role: 'authenticated' })]) await client.query('SET LOCAL ROLE authenticated') await client.query('UPDATE public.users SET onboarding = $2, created_via_invite = false WHERE id = $1', [f.userId, { intent: 'both', abtests: { [experiment]: assignment() } }]) await client.query('RESET ROLE') const saved = (await client.query('SELECT onboarding, created_via_invite FROM public.users WHERE id = $1', [f.userId])).rows[0] expect(saved.created_via_invite).toBe(true) expect(saved.onboarding.abtests[experiment].branch).toBe('B') expect(await f.reconcile()).toBe(0) expect((await f.read()).onboarding.setup.paths).toEqual(['ota']) })) it.concurrent('preserves legacy steps and timestamps when adding both paths', async () => inTransaction(async (client) => { const f = await fixture(client) const original = { created_by_user_id: f.userId, setup: { todo_list_version: 2, source: 'cli', updated_at: at, steps: { add_app: { status: 'done', at }, add_channel: { status: 'skipped', at }, legacy_custom: { status: 'done', at } } } } await client.query('UPDATE public.apps SET onboarding = $2 WHERE app_id = $1', [f.appId, original]) expect(await f.reconcile()).toBe(1) const { onboarding } = await f.read() expectBoth(onboarding) expect(onboarding.setup.steps.ota.login_cli_mcp).toEqual({ status: 'done', at }) expect(onboarding.setup.steps.ota.add_channel).toEqual({ status: 'skipped', at }) expect(onboarding.setup.steps.legacy_custom).toEqual({ status: 'done', at }) expect(onboarding.setup.updated_at).toBe(at) expect(await f.reconcile()).toBe(0) })) it.concurrent.each(['completed', 'skipped'])('does not resurrect root-level legacy outcome %s even with an empty setup', async outcome => inTransaction(async (client) => { const f = await fixture(client) for (const setup of [undefined, {}]) { const original = { created_by_user_id: f.userId, outcome, completed_at: at, ...(setup ? { setup } : {}) } await client.query('UPDATE public.apps SET onboarding = $2 WHERE app_id = $1', [f.appId, original]) expect(await f.reconcile()).toBe(0) expect((await f.read()).onboarding).toEqual(original) } })) it.concurrent('carries root-level legacy progress and source metadata into nested setup', async () => inTransaction(async (client) => { const f = await fixture(client) const original = { created_by_user_id: f.userId, todo_list_version: 2, source: 'mcp', outcome: 'in_progress', started_at: at, updated_at: at, steps: { add_app: { status: 'done', at }, add_channel: { status: 'skipped', at } } } await client.query('UPDATE public.apps SET onboarding = $2 WHERE app_id = $1', [f.appId, original]) expect(await f.reconcile()).toBe(1) const { onboarding } = await f.read() expectBoth(onboarding) expect(onboarding).toMatchObject(original) expect(onboarding.setup).toMatchObject({ source: 'mcp', outcome: 'in_progress', started_at: at, updated_at: at }) expect(onboarding.setup.steps.ota.login_cli_mcp).toEqual({ status: 'done', at }) expect(onboarding.setup.steps.ota.add_channel).toEqual({ status: 'skipped', at }) })) it.concurrent('keeps reconciliation and its merge helper service-only with empty search paths', async () => inTransaction(async (client) => { for (const role of ['anon', 'authenticated']) { for (const signature of ['public.reconcile_double_intent_app_onboarding(uuid)', 'public.add_double_intent_onboarding_paths(jsonb)']) { expect((await client.query('SELECT pg_catalog.has_function_privilege($1, $2, \'EXECUTE\') AS allowed', [role, signature])).rows[0].allowed).toBe(false) } } const rows = (await client.query(`SELECT p.proname, p.proconfig, r.rolname FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_roles r ON r.oid = p.proowner WHERE p.oid IN ('public.reconcile_double_intent_app_onboarding(uuid)'::regprocedure, 'public.add_double_intent_onboarding_paths(jsonb)'::regprocedure)`)).rows expect(rows).toHaveLength(2) for (const row of rows) { expect(row.rolname).toBe('postgres') expect(row.proconfig).toContain('search_path=""') } })) it('upgrades a pre-existing app through the real assignment endpoint service even when A was already persisted', async () => { const client = await pool.connect() let f: Awaited> | undefined try { f = await fixture(client, { branch: null }) const progress = (await f.read()).onboarding progress.setup.steps.ota.add_code = { status: 'done', at } await client.query('UPDATE public.apps SET onboarding = $2 WHERE app_id = $1', [f.appId, progress]) await client.query('UPDATE public.users SET onboarding = $2 WHERE id = $1', [f.userId, { ...f.onboarding, abtests: { [experiment]: assignment() } }]) const userId = f.userId const assignments = await requestDirectDatabaseRoute>((app) => { app.get('/', async c => c.json(await getOrCreateUserABTests(c as any, userId))) }) expect(assignments[experiment]).toEqual(assignment()) const { onboarding } = await f.read() expectBoth(onboarding) expect(onboarding.setup.steps.ota.add_code).toEqual({ status: 'done', at }) // Exercise eight concurrent callers through a bounded pool, leaving room // for the unrelated read and other parallel test files. const parallel = new Pool({ connectionString: POSTGRES_URL, max: 3 }) try { const results = await Promise.all([ ...Array.from({ length: 8 }, () => parallel.query('SELECT public.reconcile_double_intent_app_onboarding($1) AS changed', [userId])), parallel.query('SELECT id FROM public.orgs LIMIT 1'), ]) expect(results.slice(0, 8).map(result => result.rows[0].changed)).toEqual(Array.from({ length: 8 }).fill(0)) expect(results[8].rows).toHaveLength(1) expect((await f.read()).onboarding).toEqual(onboarding) } finally { await parallel.end() } } finally { if (f) { await client.query('DELETE FROM public.apps WHERE owner_org = $1', [f.orgId]) await client.query('DELETE FROM public.orgs WHERE id = $1', [f.orgId]) await client.query('DELETE FROM auth.users WHERE id = ANY($1::uuid[])', [[f.userId, f.otherId]]) } client.release() } }) })