tests/app-onboarding-double-intent.test.ts
Rama main · réplica de lectura del repositorio original.
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<void>) {
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<ReturnType<typeof fixture>> | 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<Record<string, unknown>>((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()
}
})
})