import { randomUUID } from 'node:crypto' import { env } from 'node:process' import { createClient } from '@supabase/supabase-js' import { describe, expect, it } from 'vitest' import { APP_ONBOARDING_OTA_V1_STEP_IDS, APP_ONBOARDING_V3_STEP_IDS, parseAppOnboarding } from '../supabase/functions/_backend/utils/appOnboarding.ts' const admin = createClient(env.SUPABASE_URL!, env.SUPABASE_SERVICE_KEY!, { auth: { persistSession: false, autoRefreshToken: false } }) describe('persisted app checklists', () => { it.concurrent.each([ ['ota', 'ota', null, true, 4], ['ota', 'builder', 'B', false, 4], // Every non-Builder app gets the seven-step OTA checklist, whatever the org intent. ['both', 'ota', 'A', true, 4], ['builder', 'ota', 'A', true, 4], ['unknown', 'ota', 'A', true, 4], ] as const)('assigns org intent %s, creator intent %s, saved branch %s, creator-owned org %s to version %s', async (orgIntent, creatorIntent, branch, ownOrg, expectedVersion) => { const email = `onboarding-v3-${randomUUID()}@example.com` const created = await admin.auth.admin.createUser({ email, password: 'v3test-password', email_confirm: true }) expect(created.error).toBeNull() const userId = created.data.user!.id let otherUserId: string | undefined const appIds: string[] = [] const orgIds: string[] = [] try { expect((await admin.from('users').upsert({ id: userId, email, onboarding: { intent: creatorIntent, abtests: branch ? { ota_todo_list_v3: { branch, assigned_at: new Date().toISOString() } } : {}, } })).error).toBeNull() if (!ownOrg) { const other = await admin.auth.admin.createUser({ email: `onboarding-v3-other-${randomUUID()}@example.com`, password: 'v3test-password', email_confirm: true }) expect(other.error).toBeNull() otherUserId = other.data.user!.id expect((await admin.from('users').upsert({ id: otherUserId, email: other.data.user!.email })).error).toBeNull() } // Both apps keep their insert-time versions when intent and assignments change. for (let n = 0; n < 2; n++) { const org = await admin.from('orgs').insert({ created_by: otherUserId ?? userId, name: `Checklist v3 ${randomUUID()}`, management_email: email, onboarding: { intent: orgIntent } }).select('id').single() expect(org.error).toBeNull() orgIds.push(org.data!.id) const appId = `com.onboarding.v3.${randomUUID()}` appIds.push(appId) const app = await admin.from('apps').insert({ app_id: appId, owner_org: org.data!.id, name: 'Checklist v3', icon_url: '', onboarding: { created_by_user_id: userId, setup: { todo_list_version: 2, steps: {} } } }).select('onboarding').single() expect(app.error).toBeNull() expect(parseAppOnboarding(app.data!.onboarding).todo_list_version).toBe(expectedVersion) if (expectedVersion === 4) { const setup = (app.data!.onboarding as any).setup expect(setup.ota_todo_list_version).toBe('1') expect(setup.paths).toEqual(['ota']) expect(setup.selected_path).toBe('ota') expect(Object.keys(setup.steps)).toEqual(['ota']) expect(Object.keys(setup.steps.ota).sort()).toEqual([...APP_ONBOARDING_OTA_V1_STEP_IDS].sort()) expect(Object.values(setup.steps.ota)).toEqual(Array.from({ length: 7 }, () => ({ status: 'pending' }))) } } expect((await admin.from('users').update({ onboarding: { intent: 'builder', abtests: {} } }).eq('id', userId)).error).toBeNull() expect((await admin.from('orgs').update({ onboarding: { intent: orgIntent === 'ota' ? 'builder' : 'ota' } }).in('id', orgIds)).error).toBeNull() expect((await admin.from('apps').update({ name: 'Renamed' }).in('app_id', appIds)).error).toBeNull() const apps = await admin.from('apps').select('onboarding').in('app_id', appIds) expect(apps.error).toBeNull() expect(apps.data!.map(app => parseAppOnboarding(app.onboarding).todo_list_version)).toEqual([expectedVersion, expectedVersion]) } finally { await admin.from('apps').delete().in('app_id', appIds) await admin.from('orgs').delete().in('id', orgIds) await admin.from('users').delete().eq('id', userId) await admin.auth.admin.deleteUser(userId) if (otherUserId) { await admin.from('users').delete().eq('id', otherUserId) await admin.auth.admin.deleteUser(otherUserId) } } }) it.concurrent('assigns OTA v4 from the organization even without a creator ledger', async () => { const email = `onboarding-org-${randomUUID()}@example.com` const orgId = randomUUID() const appId = `com.onboarding.org.${randomUUID()}` let userId: string | undefined try { const created = await admin.auth.admin.createUser({ email, password: 'v3test-password', email_confirm: true }) expect(created.error).toBeNull() userId = created.data.user!.id expect((await admin.from('users').upsert({ id: userId, email, onboarding: { intent: 'builder' } })).error).toBeNull() expect((await admin.from('orgs').insert({ id: orgId, created_by: userId, name: 'OTA organization checklist test', management_email: email, onboarding: { intent: 'ota' } })).error).toBeNull() const app = await admin.from('apps').insert({ app_id: appId, owner_org: orgId, name: 'OTA organization checklist test', icon_url: '' }).select('onboarding').single() expect(app.error).toBeNull() expect((app.data!.onboarding as any).setup.todo_list_version).toBe(4) expect((app.data!.onboarding as any).created_by_user_id).toBeUndefined() } finally { await admin.from('apps').delete().eq('app_id', appId) await admin.from('orgs').delete().eq('id', orgId) if (userId) { await admin.from('users').delete().eq('id', userId) await admin.auth.admin.deleteUser(userId) } } }) it.concurrent('maps legacy v1/v2 step statuses onto the seven OTA steps', async () => { const v2 = await admin.rpc('new_ota_onboarding_steps_v1', { p_legacy_steps: { login_cli_mcp: { status: 'done', at: '2026-09-01T00:00:00.000Z' }, add_channel: { status: 'skipped' }, add_encryption: { status: 'done' }, upload_bundle: { status: 'unknown' }, } }) expect(v2.error).toBeNull() expect(Object.keys(v2.data).sort()).toEqual([...APP_ONBOARDING_OTA_V1_STEP_IDS].sort()) expect(v2.data.login_cli_mcp).toEqual({ status: 'done', at: '2026-09-01T00:00:00.000Z' }) expect(v2.data.add_channel).toEqual({ status: 'skipped' }) expect(v2.data.upload_bundle).toEqual({ status: 'pending' }) expect(v2.data.add_encryption).toBeUndefined() const v1 = await admin.rpc('new_ota_onboarding_steps_v1', { p_legacy_steps: { add_app: { status: 'done' } } }) expect(v1.error).toBeNull() expect(v1.data.login_cli_mcp).toEqual({ status: 'done' }) const empty = await admin.rpc('new_ota_onboarding_steps_v1', {}) expect(empty.error).toBeNull() expect(Object.values(empty.data)).toEqual(Array.from({ length: 7 }, () => ({ status: 'pending' }))) }) it.concurrent('sql merge requires all seven v3 milestones and preserves legacy v2 completion', async () => { const current = { setup: { todo_list_version: 3, steps: {} }, features: { ota: { stage: 'local_only' } } } const premature = await admin.rpc('merge_app_onboarding_setup', { p_existing: current, p_patch: { outcome: 'completed', steps: { completion: { status: 'done' } } } }) expect(premature.error).toBeNull() expect(parseAppOnboarding(premature.data)).toMatchObject({ todo_list_version: 3, outcome: 'in_progress', steps: {} }) const completed = await admin.rpc('merge_app_onboarding_setup', { p_existing: current, p_patch: { steps: Object.fromEntries(APP_ONBOARDING_V3_STEP_IDS.map(id => [id, { status: 'done' }])) } }) expect(completed.error).toBeNull() expect(parseAppOnboarding(completed.data).outcome).toBe('completed') expect(completed.data.features).toEqual(current.features) const control = await admin.rpc('merge_app_onboarding_setup', { p_existing: { setup: { todo_list_version: 2 } }, p_patch: { outcome: 'completed' } }) expect(control.error).toBeNull() expect(parseAppOnboarding(control.data).outcome).toBe('completed') }) it.concurrent('sql merge keeps all v4 OTA steps and preserves future path data', async () => { const current = { setup: { todo_list_version: 4, ota_todo_list_version: '1', paths: ['ota'], selected_path: 'ota', steps: { ota: Object.fromEntries(APP_ONBOARDING_OTA_V1_STEP_IDS.map(id => [id, { status: 'pending' }])), builder: { placeholder: { status: 'pending' } } } }, features: { ota: { stage: 'local_only' } } } const premature = await admin.rpc('merge_app_onboarding_setup', { p_existing: current, p_patch: { outcome: 'completed', steps: { ota: { add_code: { status: 'done' } } } } }) expect(premature.error).toBeNull() expect(premature.data.setup.outcome).toBe('in_progress') expect(premature.data.setup.ota_todo_list_version).toBe('1') expect(premature.data.setup.steps.ota.add_code.status).toBe('done') expect(premature.data.setup.steps.ota.add_channel).toEqual({ status: 'pending' }) expect(premature.data.setup.steps.builder).toEqual(current.setup.steps.builder) expect(premature.data.features).toEqual(current.features) const completed = await admin.rpc('merge_app_onboarding_setup', { p_existing: current, p_patch: { steps: Object.fromEntries(APP_ONBOARDING_OTA_V1_STEP_IDS.map(id => [id, { status: 'done' }])) } }) expect(completed.error).toBeNull() expect(parseAppOnboarding(completed.data).outcome).toBe('completed') }) it.concurrent('sql merge leaves unknown and numeric v4 OTA versions untouched', async () => { for (const version of ['2', 1]) { const current = { setup: { todo_list_version: 4, ota_todo_list_version: version, steps: { ota: { add_code: { status: 'pending' } } } } } const result = await admin.rpc('merge_app_onboarding_setup', { p_existing: current, p_patch: { steps: { add_code: { status: 'done' } }, outcome: 'completed' } }) expect(result.error).toBeNull() expect(result.data.setup.ota_todo_list_version).toBe(version) expect(result.data.setup.steps.ota.add_code).toEqual({ status: 'pending' }) expect(result.data.setup.paths).toBeUndefined() expect(result.data.setup.outcome).toBe('in_progress') } const builderOnly = { setup: { todo_list_version: 4, outcome: 'completed', paths: ['builder'], selected_path: 'builder', steps: { builder: { configure_signing: { status: 'done' } } } } } const result = await admin.rpc('merge_app_onboarding_setup', { p_existing: builderOnly, p_patch: { steps: { add_code: { status: 'done' } } } }) expect(result.error).toBeNull() expect(result.data.setup.outcome).toBe('completed') expect(result.data.setup.paths).toEqual(['builder']) expect(result.data.setup.steps).toEqual(builderOnly.setup.steps) }) })