import type { Database } from '~/types/database.types' import { randomUUID } from 'node:crypto' import { createClient } from '@supabase/supabase-js' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { parseAppOnboardingLedger, shouldShowGettingStartedNav, shouldSkipOnboardingResume } from '../src/utils/appOnboardingProgress.ts' import { BASE_URL, executeSQL, fetchTestRequest, getAuthHeaders, getSupabaseClient, ORG_ID, SUPABASE_ANON_KEY, SUPABASE_BASE_URL, USER_EMAIL, USER_EMAIL_NONMEMBER, USER_ID, USER_ID_NONMEMBER, USER_PASSWORD, USER_PASSWORD_NONMEMBER, } from './test-utils.ts' const APP_RPC = `ob.rpc.${randomUUID().slice(0, 8)}` const APP_INSERT = `ob.ins.${randomUUID().slice(0, 8)}` const APP_TESTFLIGHT = `ob.tf.${randomUUID().slice(0, 8)}` const APP_STORE = `ob.st.${randomUUID().slice(0, 8)}` const APP_VERIFY = `ob.vf.${randomUUID().slice(0, 8)}` const APP_SETUP = `ob.su.${randomUUID().slice(0, 8)}` const APP_HISTORY = `ob.hi.${randomUUID().slice(0, 8)}` const DEVICE_TF = randomUUID().toLowerCase() const DEVICE_STORE = randomUUID().toLowerCase() const serviceRoleSupabase = getSupabaseClient() function createAuthClient() { return createClient(SUPABASE_BASE_URL, SUPABASE_ANON_KEY, { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false, }, }) } async function bindAppReader(appId: string, userId: string) { await executeSQL(` INSERT INTO public.role_bindings ( principal_type, principal_id, role_id, scope_type, org_id, app_id, granted_by, reason, is_direct ) SELECT public.rbac_principal_user(), $1::uuid, roles.id, public.rbac_scope_app(), $2::uuid, apps.id, $3::uuid, 'Getting started reader regression', true FROM public.roles roles INNER JOIN public.apps apps ON apps.app_id = $4 WHERE roles.name = public.rbac_role_app_reader() AND roles.scope_type = public.rbac_scope_app() ON CONFLICT DO NOTHING `, [userId, ORG_ID, USER_ID, appId]) } async function unbindAppReader(appId: string, userId: string) { await executeSQL(` DELETE FROM public.role_bindings WHERE principal_type = public.rbac_principal_user() AND principal_id = $1::uuid AND app_id = (SELECT id FROM public.apps WHERE app_id = $2) `, [userId, appId]) } async function createApp(appId: string, needOnboarding = false) { const { error } = await serviceRoleSupabase.from('apps').insert({ app_id: appId, owner_org: ORG_ID, name: 'Onboarding progress test app', icon_url: 'https://example.com/icon.png', need_onboarding: needOnboarding, }) if (error) throw error } async function insertDevice(appId: string, deviceId: string, installSource: string) { const { error } = await serviceRoleSupabase.from('devices').insert({ app_id: appId, device_id: deviceId, platform: 'ios', plugin_version: '7.0.0', updated_at: new Date().toISOString(), version_name: '1.0.0', install_source: installSource, is_prod: true, is_emulator: false, }) if (error) throw error } async function refreshOne(appId: string) { const rows = await executeSQL<{ onboarding: unknown }>( 'SELECT public.refresh_one_app_onboarding_progress($1) AS onboarding', [appId], ) return parseAppOnboardingLedger(rows[0]?.onboarding) } beforeAll(async () => { await createApp(APP_RPC) await createApp(APP_TESTFLIGHT) await createApp(APP_STORE) await createApp(APP_VERIFY, true) await createApp(APP_SETUP, true) await createApp(APP_HISTORY, true) await insertDevice(APP_TESTFLIGHT, DEVICE_TF, 'testflight') await insertDevice(APP_STORE, DEVICE_STORE, 'app_store') }) afterAll(async () => { await serviceRoleSupabase.from('devices').delete().eq('app_id', APP_TESTFLIGHT) await serviceRoleSupabase.from('devices').delete().eq('app_id', APP_STORE) await serviceRoleSupabase.from('app_versions').delete().eq('app_id', APP_VERIFY) await serviceRoleSupabase.from('apps').delete().in('app_id', [APP_RPC, APP_INSERT, APP_TESTFLIGHT, APP_STORE, APP_VERIFY, APP_SETUP, APP_HISTORY]) }) describe('app onboarding progress', () => { it.concurrent('defaults and preserves the server-owned todo list version', async () => { const rows = await executeSQL<{ default_version: number, preserved_version: number }>(` SELECT (public.merge_app_onboarding_setup('{}'::jsonb, '{}'::jsonb) -> 'setup' ->> 'todo_list_version')::integer AS default_version, (public.merge_app_onboarding_setup( '{"setup":{"todo_list_version":1}}'::jsonb, '{"todo_list_version":99,"source":"cli"}'::jsonb ) -> 'setup' ->> 'todo_list_version')::integer AS preserved_version `) expect(rows[0]).toEqual({ default_version: 2, preserved_version: 1 }) }) it.concurrent('merges only steps belonging to the stored todo list version', async () => { const rows = await executeSQL>(` SELECT public.merge_app_onboarding_setup( '{"setup":{"todo_list_version":1}}'::jsonb, '{"steps":{"add_app":{"status":"done"},"login_cli_mcp":{"status":"done"}}}'::jsonb ) -> 'setup' -> 'steps' ? 'add_app' AS v1_add_app, public.merge_app_onboarding_setup( '{"setup":{"todo_list_version":1}}'::jsonb, '{"steps":{"add_app":{"status":"done"},"login_cli_mcp":{"status":"done"}}}'::jsonb ) -> 'setup' -> 'steps' ? 'login_cli_mcp' AS v1_login, public.merge_app_onboarding_setup( '{"setup":{"todo_list_version":2}}'::jsonb, '{"steps":{"add_app":{"status":"done"},"login_cli_mcp":{"status":"done"}}}'::jsonb ) -> 'setup' -> 'steps' ? 'add_app' AS v2_add_app, public.merge_app_onboarding_setup( '{"setup":{"todo_list_version":2}}'::jsonb, '{"steps":{"add_app":{"status":"done"},"login_cli_mcp":{"status":"done"}}}'::jsonb ) -> 'setup' -> 'steps' ? 'login_cli_mcp' AS v2_login `) expect(rows[0]).toEqual({ v1_add_app: true, v1_login: false, v2_add_app: false, v2_login: true }) }) it('must reject unauthenticated mark_onboarding_feature_started', async () => { const anon = createAuthClient() const { error } = await anon.rpc('mark_onboarding_feature_started', { p_app_id: APP_RPC, p_feature_key: 'cli_install', }) expect(error).toBeTruthy() }) it('sets started_at and persists it', async () => { const authClient = createAuthClient() const { error: signInError } = await authClient.auth.signInWithPassword({ email: USER_EMAIL, password: USER_PASSWORD, }) if (signInError) throw signInError const { data, error } = await authClient.rpc('mark_onboarding_feature_started', { p_app_id: APP_RPC, p_feature_key: 'cli_install', }) expect(error).toBeNull() const ledger = parseAppOnboardingLedger(data) expect(ledger.features?.cli_install?.started_at).toBeTruthy() expect(ledger.features?.cli_install?.succeeded_at).toBeFalsy() expect(ledger.features?.cli_install?.stage).toBeFalsy() const { data: persisted, error: readError } = await serviceRoleSupabase .from('apps') .select('onboarding') .eq('app_id', APP_RPC) .single() expect(readError).toBeNull() const persistedLedger = parseAppOnboardingLedger(persisted?.onboarding) expect(persistedLedger.features?.cli_install?.started_at).toBeTruthy() expect(persistedLedger.features?.cli_install?.succeeded_at).toBeFalsy() }) it('rejects direct authenticated writes to apps.onboarding', async () => { const authClient = createAuthClient() const { error: signInError } = await authClient.auth.signInWithPassword({ email: USER_EMAIL, password: USER_PASSWORD, }) if (signInError) throw signInError const { error: _updateError } = await authClient .from('apps') .update({ onboarding: { features: { ota: { succeeded_at: '2026-01-01T00:00:00.000Z', stage: 'store_live', }, }, }, }) .eq('app_id', APP_RPC) const { data, error: readError } = await serviceRoleSupabase .from('apps') .select('onboarding') .eq('app_id', APP_RPC) .single() expect(readError).toBeNull() const ledger = parseAppOnboardingLedger(data?.onboarding) expect(ledger.features?.ota?.succeeded_at).toBeFalsy() expect(ledger.features?.ota?.stage).not.toBe('store_live') expect(ledger.features?.cli_install?.started_at).toBeTruthy() }) it('clears forged onboarding on authenticated insert', async () => { const authClient = createAuthClient() const { error: signInError } = await authClient.auth.signInWithPassword({ email: USER_EMAIL, password: USER_PASSWORD, }) if (signInError) throw signInError const { error: insertError } = await authClient.from('apps').insert({ app_id: APP_INSERT, owner_org: ORG_ID, user_id: USER_ID, name: 'Onboarding insert protection test app', icon_url: 'https://example.com/icon.png', onboarding: { features: { ota: { succeeded_at: '2026-01-01T00:00:00.000Z', stage: 'store_live', }, }, }, }) expect(insertError, JSON.stringify(insertError)).toBeNull() const { data, error: readError } = await serviceRoleSupabase .from('apps') .select('onboarding') .eq('app_id', APP_INSERT) .single() expect(readError).toBeNull() expect(data).toBeTruthy() const ledger = parseAppOnboardingLedger(data?.onboarding) expect(ledger.features?.ota?.succeeded_at).toBeFalsy() expect(ledger.features?.ota?.stage).not.toBe('store_live') }) it('keeps TestFlight-only apps off store_live', async () => { const testflight = await refreshOne(APP_TESTFLIGHT) const store = await refreshOne(APP_STORE) expect(testflight.features?.ota?.stage).toBe('testflight') expect(testflight.features?.ota?.stage).not.toBe('store_live') expect(store.features?.ota?.stage).toBe('store_live') const merged = await executeSQL<{ stage: string | null }>( `SELECT public.merge_app_onboarding_feature( '{"stage":"store_live"}'::jsonb, NULL, NULL, NULL, 'no_device' )->>'stage' AS stage`, ) expect(merged[0]?.stage).toBe('store_live') }) it('must reject unauthenticated dismiss_getting_started', async () => { const anon = createAuthClient() const { error } = await anon.rpc('dismiss_getting_started', { p_app_id: APP_RPC, }) expect(error).toBeTruthy() }) it('persists getting started dismiss on the app and survives refresh', async () => { const authClient = createAuthClient() const { error: signInError } = await authClient.auth.signInWithPassword({ email: USER_EMAIL, password: USER_PASSWORD, }) if (signInError) throw signInError const { error: markError } = await authClient.rpc('mark_onboarding_feature_started', { p_app_id: APP_RPC, p_feature_key: 'cli_install', }) expect(markError).toBeNull() const { data, error } = await authClient.rpc('dismiss_getting_started', { p_app_id: APP_RPC, }) expect(error).toBeNull() const ledger = parseAppOnboardingLedger(data) expect(ledger.getting_started_dismissed_at).toBeTruthy() expect(shouldShowGettingStartedNav(ledger)).toBe(false) expect(ledger.features?.cli_install?.started_at).toBeTruthy() const firstDismissedAt = ledger.getting_started_dismissed_at const { data: again, error: againError } = await authClient.rpc('dismiss_getting_started', { p_app_id: APP_RPC, }) expect(againError).toBeNull() expect(parseAppOnboardingLedger(again).getting_started_dismissed_at).toBe(firstDismissedAt) const refreshed = await refreshOne(APP_RPC) expect(refreshed.getting_started_dismissed_at).toBe(firstDismissedAt) expect(refreshed.features?.cli_install?.started_at).toBeTruthy() }) it('must reject unauthenticated verify_getting_started', async () => { const anon = createAuthClient() const { error } = await anon.rpc('verify_getting_started', { p_app_id: APP_VERIFY, }) expect(error).toBeTruthy() }) it('verifies live bundles onto the getting started ledger and completes pending onboarding', async () => { const { error: versionError } = await serviceRoleSupabase.from('app_versions').insert({ app_id: APP_VERIFY, name: '1.0.1', owner_org: ORG_ID, user_id: USER_ID, storage_provider: 'r2', r2_path: `orgs/${ORG_ID}/apps/${APP_VERIFY}/1.0.1.zip`, }) if (versionError) throw versionError const authClient = createAuthClient() const { error: signInError } = await authClient.auth.signInWithPassword({ email: USER_EMAIL, password: USER_PASSWORD, }) if (signInError) throw signInError const { data, error } = await authClient.rpc('verify_getting_started', { p_app_id: APP_VERIFY, }) expect(error).toBeNull() const ledger = parseAppOnboardingLedger(data) expect(ledger.refreshed_at).toBeTruthy() expect(ledger.features?.ota?.started_at).toBeTruthy() // A stored bundle is first-bundle time, not an install. Skip-resume still // needs succeeded_at, dismiss, or CLI/AI setup. Completing need_onboarding // is what stops the login splash after Verify. expect(ledger.features?.ota?.succeeded_at).toBeFalsy() expect(shouldSkipOnboardingResume(data)).toBe(false) const { data: app, error: readError } = await serviceRoleSupabase .from('apps') .select('need_onboarding') .eq('app_id', APP_VERIFY) .single() expect(readError).toBeNull() expect(app?.need_onboarding).toBe(false) }) it('completes pending onboarding when CLI/AI setup reports completed', async () => { // Legacy v2 checklists complete on the reported outcome alone; new apps // start on OTA v4, so pin this app to the legacy list first. const { error: seedError } = await serviceRoleSupabase .from('apps') .update({ onboarding: { setup: { todo_list_version: 2, source: 'manual', outcome: 'in_progress', steps: {} } } }) .eq('app_id', APP_SETUP) expect(seedError).toBeNull() const response = await fetchTestRequest(`${BASE_URL}/app/${APP_SETUP}`, { method: 'PUT', headers: await getAuthHeaders(), body: JSON.stringify({ onboarding: { outcome: 'completed' }, }), }) const updated = await response.json() as { onboarding?: unknown } expect(response.status, JSON.stringify(updated)).toBe(200) expect(shouldSkipOnboardingResume(updated.onboarding)).toBe(true) const { data: app, error: readError } = await serviceRoleSupabase .from('apps') .select('need_onboarding') .eq('app_id', APP_SETUP) .single() expect(readError).toBeNull() expect(app?.need_onboarding).toBe(false) }) it('atomically records concurrent step history and rejects forged history', async () => { const { error: seedError } = await serviceRoleSupabase .from('apps') .update({ onboarding: { setup: { todo_list_version: Number.MAX_SAFE_INTEGER } } }) .eq('app_id', APP_HISTORY) expect(seedError).toBeNull() const headers = await getAuthHeaders() const responses = await Promise.all([ fetchTestRequest(`${BASE_URL}/app/${APP_HISTORY}`, { method: 'PUT', headers, body: JSON.stringify({ onboarding: { steps: { login_cli_mcp: { status: 'done', update_history: [{ forged: true }] } } }, }), }), fetchTestRequest(`${BASE_URL}/app/${APP_HISTORY}`, { method: 'PUT', headers, body: JSON.stringify({ onboarding: { steps: { add_channel: { status: 'done' } } }, }), }), ]) expect(responses.map(response => response.status)).toEqual([200, 200]) const { data, error } = await serviceRoleSupabase .from('apps') .select('onboarding') .eq('app_id', APP_HISTORY) .single() expect(error).toBeNull() const onboarding = data?.onboarding as { setup?: { todo_list_version?: number steps?: Record> }> } } expect(onboarding.setup?.todo_list_version).toBe(Number.MAX_SAFE_INTEGER) for (const stepId of ['login_cli_mcp', 'add_channel']) { const step = onboarding.setup?.steps?.[stepId] expect(step?.status).toBe('done') expect(step?.update_history).toHaveLength(1) expect(step?.update_history?.[0]?.status).toBe('done') expect(step?.update_history?.[0]?.at).toEqual(expect.any(String)) } expect(JSON.stringify(onboarding)).not.toContain('forged') }) it('completes pending onboarding when getting started is hidden', async () => { const appId = `ob.hide.${randomUUID().slice(0, 8)}` await createApp(appId, true) try { const authClient = createAuthClient() const { error: signInError } = await authClient.auth.signInWithPassword({ email: USER_EMAIL, password: USER_PASSWORD, }) if (signInError) throw signInError const { error } = await authClient.rpc('dismiss_getting_started', { p_app_id: appId, }) expect(error).toBeNull() const { data: app, error: readError } = await serviceRoleSupabase .from('apps') .select('need_onboarding, onboarding') .eq('app_id', appId) .single() expect(readError).toBeNull() expect(app?.need_onboarding).toBe(false) expect(parseAppOnboardingLedger(app?.onboarding).getting_started_dismissed_at).toBeTruthy() } finally { await serviceRoleSupabase.from('apps').delete().eq('app_id', appId) } }) it('lets an app reader hide getting started without completing pending onboarding', async () => { const appId = `ob.read.${randomUUID().slice(0, 8)}` await createApp(appId, true) await bindAppReader(appId, USER_ID_NONMEMBER) try { const authClient = createAuthClient() const { error: signInError } = await authClient.auth.signInWithPassword({ email: USER_EMAIL_NONMEMBER, password: USER_PASSWORD_NONMEMBER, }) if (signInError) throw signInError const { data, error } = await authClient.rpc('dismiss_getting_started', { p_app_id: appId, }) expect(error).toBeNull() expect(parseAppOnboardingLedger(data).getting_started_dismissed_at).toBeTruthy() const { data: app, error: readError } = await serviceRoleSupabase .from('apps') .select('need_onboarding') .eq('app_id', appId) .single() expect(readError).toBeNull() expect(app?.need_onboarding).toBe(true) } finally { await unbindAppReader(appId, USER_ID_NONMEMBER) await serviceRoleSupabase.from('apps').delete().eq('app_id', appId) } }) })