tests/app-onboarding-v3.db.test.ts
Rama main · réplica de lectura del repositorio original.
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)
})
})