import type { Context } from 'hono' import type { MiddlewareKeyVariables } from '../supabase/functions/_backend/utils/hono.ts' import { beforeEach, describe, expect, it, vi } from 'vitest' import { appendAppOnboardingStepHistory, applyAppOnboardingPatch } from '../supabase/functions/_backend/utils/appOnboarding.ts' import { applyBuilderChecklistUpdate, buildBuilderBuildOutcomePatch, getBuilderBuildOutcomeUpdate, getBuilderChecklistUpdateFromAnalytics, markBuilderChecklistFromAnalytics, persistBuilderBuildOutcome, } from '../supabase/functions/_backend/utils/builder_onboarding_checklist.ts' const mocks = vi.hoisted(() => ({ close: vi.fn(), execute: vi.fn(), log: vi.fn(), permission: vi.fn(), track: vi.fn(), transaction: vi.fn(), })) vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ closeClient: mocks.close, getDrizzleClient: () => ({ transaction: mocks.transaction }), getPgClient: () => ({}), })) vi.mock('../supabase/functions/_backend/utils/rbac.ts', () => ({ checkPermissionPg: mocks.permission })) vi.mock('../supabase/functions/_backend/utils/posthog.ts', () => ({ trackPosthogEvent: mocks.track })) vi.mock('../supabase/functions/_backend/utils/logging.ts', () => ({ cloudlogErr: mocks.log, serializeError: (error: unknown) => error })) vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ backgroundTask: async (_c: unknown, task: Promise) => await task })) const FIXED_NOW = '2026-09-22T12:00:00.000Z' function event(tags: Record, overrides: Record = {}) { return { channel: 'builder-onboarding', event: 'Builder Onboarding Action', tags: { platform: 'ios', ...tags }, ...overrides, } as any } function startSetupEvent(platform: 'ios' | 'android') { return event({ action: 'start_setup', app_id: 'com.test.builder', journey_id: 'bj_start-setup', platform, source: 'cli', step: 'welcome', }) } function profilePreparedEvent(source: 'created' | 'imported', step = source === 'created' ? 'creating-profile' : 'import-exporting') { return event({ action: 'profile_prepared', app_id: 'com.test.builder', attempt_id: 'bj_profile-prepared', journey_id: 'bj_profile-prepared', source, step, }) } function androidPreparationEvent(action: 'keystore_prepared' | 'google_play_connected', tags: Record) { return event({ action, app_id: 'com.test.builder', attempt_id: 'bj_android-preparation', journey_id: 'bj_android-preparation', platform: 'android', ...tags, }) } function preparedKeystoreEvent(source: 'generated' | 'imported' = 'generated', keyPassword = source === 'generated' ? 'generated_with_keystore' : 'verified') { return androidPreparationEvent('keystore_prepared', { source, step: source === 'generated' ? 'keystore-generating' : 'keystore-existing-key-password', key_password: keyPassword, }) } function connectedGooglePlayEvent(source: 'imported_service_account' | 'generated_service_account' = 'imported_service_account') { return androidPreparationEvent('google_play_connected', { source, step: source === 'imported_service_account' ? 'sa-json-validating' : 'gcp-setup-running', }) } function onboarding(step = 'choose_destination', state: Record = { status: 'pending' }) { return { feature_flag: { keep: true }, setup: { todo_list_version: 4, builder_todo_list_version: '1', paths: ['builder'], selected_path: 'builder', outcome: 'in_progress', steps: { ota: { login_cli_mcp: { status: 'pending' } }, builder: { ios: { start_setup: { status: 'pending' }, choose_destination: { status: 'pending' }, connect_app_store: { status: 'pending' }, prepare_certificate: { status: 'pending' }, prepare_profile: { status: 'pending' }, successful_cloud_build: { status: 'pending' }, [step]: state, }, android: { start_setup: { status: 'pending' }, prepare_keystore: { status: 'pending' }, connect_google_play: { status: 'pending' }, successful_cloud_build: { status: 'pending' }, }, }, }, }, } } function context() { return ({ env: {}, get: (key: string) => { if (key === 'auth') return { authType: 'apikey', userId: '11111111-1111-4111-8111-111111111111', apikey: { key: 'fixture-key' } } if (key === 'capgkey') return 'header-key' if (key === 'requestId') return 'request-id' return undefined }, }) as Context } function lockedRow(value: unknown) { mocks.execute .mockResolvedValueOnce({ rows: [{ owner_org: '22222222-2222-4222-8222-222222222222' }] }) .mockResolvedValueOnce({ rows: [] }) .mockResolvedValueOnce({ rows: [{ onboarding: value, owner_org: '22222222-2222-4222-8222-222222222222', need_onboarding: true }] }) .mockResolvedValue({ rows: [] }) } describe('builder checklist analytics mapping', () => { it.each(['ios', 'android'] as const)('maps the validated %s setup start', (platform) => { expect(getBuilderChecklistUpdateFromAnalytics(startSetupEvent(platform))).toEqual({ platform, step: 'start_setup', status: 'done' }) }) it.each([ [{ action: 'question_answered', question_id: 'ios_setup_method', choice: 'create-new' }, { step: 'choose_destination', status: 'done', annotation: 'asc_new', annotationType: 'note' }], [{ action: 'question_answered', question_id: 'ios_setup_method', choice: 'import-existing' }, { step: 'choose_destination', status: 'pending' }], [{ action: 'question_skipped', question_id: 'ios_setup_method', choice: 'create-new', reason: 'non_macos_auto_create_new' }, { step: 'choose_destination', status: 'skipped', annotation: 'asc_new_auto', annotationType: 'note' }], [{ action: 'question_answered', question_id: 'ios_import_distribution', choice: 'app_store' }, { step: 'choose_destination', status: 'done', annotation: 'import_app_store', annotationType: 'note' }], [{ action: 'question_answered', question_id: 'ios_import_distribution', choice: 'ad_hoc' }, { step: 'choose_destination', status: 'done', annotation: 'import_ad_hoc', annotationType: 'note' }], [{ action: 'question_answered', question_id: 'ios_import_distribution', choice: 'switch_to_create_new' }, { step: 'choose_destination', status: 'done', annotation: 'asc_new', annotationType: 'note' }], [{ action: 'credential_verified', credential: 'ios_app_store_connect_api_key' }, { step: 'connect_app_store', status: 'done' }], [{ action: 'credential_verification_failed', credential: 'ios_app_store_connect_api_key', source: 'guided_helper' }, { step: 'connect_app_store', status: 'warning', annotation: 'asc_key_verification_failed', annotationType: 'warning' }], [{ action: 'certificate_prepared', source: 'created' }, { step: 'prepare_certificate', status: 'done' }], [{ action: 'certificate_preparation_failed', source: 'created', reason: 'certificate_limit' }, { step: 'prepare_certificate', status: 'warning', annotation: 'ios_certificate_limit_reached', annotationType: 'warning' }], [{ action: 'certificate_preparation_failed', source: 'created', reason: 'create_failed' }, { step: 'prepare_certificate', status: 'warning', annotation: 'ios_certificate_creation_failed', annotationType: 'warning' }], [{ action: 'certificate_preparation_failed', source: 'keychain_import', reason: 'export_failed' }, { step: 'prepare_certificate', status: 'warning', annotation: 'ios_certificate_export_failed', annotationType: 'warning' }], [profilePreparedEvent('created').tags, { step: 'prepare_profile', status: 'done' }], [profilePreparedEvent('created', 'import-create-profile-only').tags, { step: 'prepare_profile', status: 'done' }], [profilePreparedEvent('imported').tags, { step: 'prepare_profile', status: 'done' }], ])('maps %j', (tags, expected) => { expect(getBuilderChecklistUpdateFromAnalytics(event(tags))).toEqual({ platform: 'ios', ...expected }) }) it.each([ [preparedKeystoreEvent('generated'), 'prepare_keystore'], [preparedKeystoreEvent('imported', 'verified'), 'prepare_keystore'], [preparedKeystoreEvent('imported', 'not_checked'), 'prepare_keystore'], [connectedGooglePlayEvent('imported_service_account'), 'connect_google_play'], [connectedGooglePlayEvent('generated_service_account'), 'connect_google_play'], ])('maps validated Android preparation analytics %#', (input, step) => { expect(getBuilderChecklistUpdateFromAnalytics(input)).toEqual({ platform: 'android', step, status: 'done' }) }) it.each([ androidPreparationEvent('keystore_prepared', { source: 'generated', step: 'keystore-existing-key-password', key_password: 'generated_with_keystore' }), androidPreparationEvent('keystore_prepared', { source: 'generated', step: 'keystore-generating', key_password: 'verified' }), androidPreparationEvent('keystore_prepared', { source: 'imported', step: 'keystore-generating', key_password: 'not_checked' }), androidPreparationEvent('keystore_prepared', { source: 'imported', step: 'keystore-existing-key-password', key_password: 'generated_with_keystore' }), androidPreparationEvent('google_play_connected', { source: 'generated_service_account', step: 'sa-json-validating' }), androidPreparationEvent('google_play_connected', { source: 'imported_service_account', step: 'gcp-setup-running' }), androidPreparationEvent('google_play_connected', { app_id: '', source: 'imported_service_account', step: 'sa-json-validating' }), androidPreparationEvent('google_play_connected', { attempt_id: '', source: 'imported_service_account', step: 'sa-json-validating' }), ])('rejects invalid Android preparation analytics %#', (input) => { expect(getBuilderChecklistUpdateFromAnalytics(input)).toBeNull() }) it.each([ event({ action: 'question_shown', question_id: 'ios_setup_method' }), event({ action: 'question_answered', question_id: 'ios_import_distribution', choice: '__cancel__' }), event({ action: 'credential_verified', credential: 'different_key' }), event({ action: 'certificate_preparation_failed', source: 'created', reason: 'unknown' }), event({ action: 'certificate_prepared', source: 'manual' }), event({ action: 'certificate_prepared', source: 'created' }, { channel: 'other' }), event({ action: 'certificate_prepared', source: 'created' }, { event: 'Builder Onboarding Step' }), event({ action: 'certificate_prepared', source: 'created' }, { tags: { platform: 'android', action: 'certificate_prepared', source: 'created' } }), event({ action: 'profile_prepared', source: 'manual', app_id: 'com.test.builder', attempt_id: 'attempt', journey_id: 'journey', step: 'import-exporting' }), event({ action: 'profile_prepared', source: 'created', app_id: 'com.test.builder', attempt_id: 'attempt', journey_id: 'journey', step: 'import-exporting' }), event({ action: 'profile_prepared', source: 'imported', app_id: 'com.test.builder', attempt_id: 'attempt', journey_id: 'journey', step: 'creating-profile' }), event({ action: 'profile_prepared', source: 'created', attempt_id: 'attempt', journey_id: 'journey', step: 'creating-profile' }), event({ action: 'profile_prepared', source: 'created', app_id: 'com.test.builder', journey_id: 'journey', step: 'creating-profile' }), event({ action: 'profile_prepared', source: 'created', app_id: 'com.test.builder', attempt_id: 'attempt', step: 'creating-profile' }), event({ action: 'profile_prepared', source: 'created', app_id: 'com.test.builder', attempt_id: 'attempt', journey_id: 'journey' }), event({ action: 'profile_prepared', source: 'created', app_id: 'com.test.builder', attempt_id: 'attempt', journey_id: 'journey', step: 'creating-profile' }, { tags: { platform: 'android', action: 'profile_prepared', source: 'created', app_id: 'com.test.builder', attempt_id: 'attempt', journey_id: 'journey', step: 'creating-profile' } }), event({ action: 'start_setup', app_id: 'com.test.builder', journey_id: 'bj_start-setup', source: 'dashboard', step: 'welcome' }), event({ action: 'start_setup', journey_id: 'bj_start-setup', source: 'cli', step: 'welcome' }), event({ action: 'start_setup', app_id: 'com.test.builder', source: 'cli', step: 'welcome' }), event({ action: 'start_setup', app_id: 'com.test.builder', journey_id: 'bj_start-setup', source: 'cli' }), event({ action: 'start_setup', app_id: 'com.test.builder', journey_id: 'bj_start-setup', platform: 'web', source: 'cli', step: 'welcome' }), event({}, { tags: { action: 'start_setup', app_id: 'com.test.builder', journey_id: 'bj_start-setup', source: 'cli', step: 'welcome' } }), ])('ignores unrelated or incomplete analytics %#', (input) => { expect(getBuilderChecklistUpdateFromAnalytics(input)).toBeNull() }) }) describe('builder cloud build outcome mapping', () => { it.each(['ios', 'android'] as const)('maps terminal %s outcomes', (platform) => { expect(getBuilderBuildOutcomeUpdate(platform, 'succeeded')).toEqual({ platform, step: 'successful_cloud_build', status: 'done' }) expect(getBuilderBuildOutcomeUpdate(platform, 'released')).toEqual({ platform, step: 'successful_cloud_build', status: 'done' }) expect(getBuilderBuildOutcomeUpdate(platform, 'failed')).toEqual({ platform, step: 'successful_cloud_build', status: 'warning', annotation: 'cloud_build_failed', annotationType: 'warning', }) }) it.each(['pending', 'running', 'cancelled', 'expired'])('ignores %s', (status) => { expect(getBuilderBuildOutcomeUpdate('ios', status)).toBeNull() }) }) describe('builder cloud build outcome repairs', () => { it('repairs platforms independently and records history', () => { const current = onboarding() const patch = buildBuilderBuildOutcomePatch(current, [ { platform: 'ios', status: 'succeeded' }, { platform: 'android', status: 'failed' }, ], () => FIXED_NOW)! const result = appendAppOnboardingStepHistory( current, applyAppOnboardingPatch(current, patch, () => FIXED_NOW), patch, () => FIXED_NOW, ) as any expect(result.setup.steps.builder.ios.successful_cloud_build).toEqual({ status: 'done', at: FIXED_NOW, update_history: [{ status: 'done', at: FIXED_NOW }], }) expect(result.setup.steps.builder.android.successful_cloud_build).toEqual({ status: 'warning', at: FIXED_NOW, annotation: 'cloud_build_failed', annotation_type: 'warning', update_history: [{ status: 'warning', at: FIXED_NOW }], }) }) it('is idempotent, preserves done, and requires an eligible existing step', () => { const done = onboarding('successful_cloud_build', { status: 'done', at: 'earlier' }) expect(buildBuilderBuildOutcomePatch(done, [{ platform: 'ios', status: 'failed' }], () => FIXED_NOW)).toBeNull() expect(buildBuilderBuildOutcomePatch(done, [{ platform: 'ios', status: 'succeeded' }], () => FIXED_NOW)).toBeNull() const missing = onboarding() as any delete missing.setup.steps.builder.ios.successful_cloud_build expect(buildBuilderBuildOutcomePatch(missing, [{ platform: 'ios', status: 'succeeded' }], () => FIXED_NOW)).toBeNull() expect(buildBuilderBuildOutcomePatch({ setup: { ...onboarding().setup, builder_todo_list_version: '2' } }, [{ platform: 'ios', status: 'succeeded' }], () => FIXED_NOW)).toBeNull() }) it('clears a cloud-build failure annotation after success', () => { const current = onboarding('successful_cloud_build', { status: 'warning', at: 'earlier', annotation: 'cloud_build_failed', annotation_type: 'warning', }) const patch = buildBuilderBuildOutcomePatch(current, [{ platform: 'ios', status: 'released' }], () => FIXED_NOW)! const result = applyAppOnboardingPatch(current, patch, () => FIXED_NOW) as any expect(result.setup.steps.builder.ios.successful_cloud_build).toMatchObject({ status: 'done', at: FIXED_NOW }) expect(result.setup.steps.builder.ios.successful_cloud_build).not.toHaveProperty('annotation') expect(result.setup.steps.builder.ios.successful_cloud_build).not.toHaveProperty('annotation_type') }) }) describe('builder checklist analytics updates', () => { it('updates only the existing iOS Builder step and preserves other paths', () => { const current = onboarding() const result = applyBuilderChecklistUpdate(current, { platform: 'ios', step: 'choose_destination', status: 'done', annotation: 'asc_new', annotationType: 'note', }, () => FIXED_NOW) as any expect(result.setup.steps.builder.ios.choose_destination).toEqual({ status: 'done', at: FIXED_NOW, annotation: 'asc_new', annotation_type: 'note' }) expect(result.setup.steps.builder.android).toEqual(current.setup.steps.builder.android) expect(result.setup.steps.ota).toEqual(current.setup.steps.ota) expect(result.feature_flag).toEqual({ keep: true }) expect(current.setup.steps.builder.ios.choose_destination).toEqual({ status: 'pending' }) }) it('updates only Android setup start when Android is explicit', () => { const current = onboarding() const result = applyBuilderChecklistUpdate(current, { platform: 'android', step: 'start_setup', status: 'done' }, () => FIXED_NOW) as any expect(result.setup.steps.builder.android.start_setup).toEqual({ status: 'done', at: FIXED_NOW }) expect(result.setup.steps.builder.ios.start_setup).toEqual({ status: 'pending' }) expect(current.setup.steps.builder.android.start_setup).toEqual({ status: 'pending' }) }) it('clears a warning after success and preserves unknown step fields', () => { const result = applyBuilderChecklistUpdate(onboarding('connect_app_store', { status: 'warning', annotation: 'asc_key_verification_failed', annotation_type: 'warning', custom: 'keep', }), { platform: 'ios', step: 'connect_app_store', status: 'done' }, () => FIXED_NOW) as any expect(result.setup.steps.builder.ios.connect_app_store).toEqual({ status: 'done', at: FIXED_NOW, custom: 'keep' }) }) it('does not replace completed evidence with a later warning', () => { const current = onboarding('prepare_certificate', { status: 'done', at: 'earlier' }) expect(applyBuilderChecklistUpdate(current, { platform: 'ios', step: 'prepare_certificate', status: 'warning', annotation: 'ios_certificate_creation_failed', annotationType: 'warning', }, () => FIXED_NOW)).toBeNull() }) it('resets destination to pending when the user switches to import', () => { const current = onboarding('choose_destination', { status: 'done', at: 'earlier', annotation: 'asc_new', annotation_type: 'note' }) const result = applyBuilderChecklistUpdate(current, { platform: 'ios', step: 'choose_destination', status: 'pending' }, () => FIXED_NOW) as any expect(result.setup.steps.builder.ios.choose_destination).toEqual({ status: 'pending' }) }) it('is idempotent for an identical completed state', () => { const current = onboarding('choose_destination', { status: 'done', at: 'earlier', annotation: 'asc_new', annotation_type: 'note' }) expect(applyBuilderChecklistUpdate(current, { platform: 'ios', step: 'choose_destination', status: 'done', annotation: 'asc_new', annotationType: 'note', }, () => FIXED_NOW)).toBeNull() }) it.each(['ios', 'android'] as const)('is idempotent for an already completed %s setup start', (platform) => { const current = onboarding() as any current.setup.steps.builder[platform].start_setup = { status: 'done', at: 'earlier' } expect(applyBuilderChecklistUpdate(current, { platform, step: 'start_setup', status: 'done' }, () => FIXED_NOW)).toBeNull() }) it.each([ { setup: { todo_list_version: 3, steps: {} } }, { setup: { ...onboarding().setup, builder_todo_list_version: '2' } }, { setup: { ...onboarding().setup, paths: ['ota'] } }, { setup: { ...onboarding().setup, outcome: 'skipped' } }, { setup: { ...onboarding().setup, steps: { builder: { ios: {} } } } }, ])('does not create or update unsupported checklist shapes %#', (current) => { expect(applyBuilderChecklistUpdate(current, { platform: 'ios', step: 'choose_destination', status: 'done' }, () => FIXED_NOW)).toBeNull() }) }) describe('builder checklist analytics authorization', () => { beforeEach(() => { vi.clearAllMocks() mocks.transaction.mockImplementation(async (fn: any) => fn({ execute: mocks.execute })) }) it.each(['ios', 'android'] as const)('checks write permissions under the app lock before updating %s setup start', async (platform) => { lockedRow(onboarding()) mocks.permission.mockImplementation(async (_c, permission) => permission === 'app.update_settings') await expect(markBuilderChecklistFromAnalytics(context(), 'com.test.builder', startSetupEvent(platform))).resolves.toBe(true) expect(mocks.permission).toHaveBeenCalledWith(expect.anything(), 'app.update_settings', { appId: 'com.test.builder' }, expect.anything(), expect.any(String), 'fixture-key') expect(mocks.execute).toHaveBeenCalledTimes(4) expect(mocks.track).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ event: 'App Onboarding Step Changed', nonPersonTags: expect.objectContaining({ step_id: `builder.${platform}.start_setup`, step_status: 'done' }), })) expect(mocks.close).toHaveBeenCalledOnce() }) it('does not update when the caller has neither accepted write permission', async () => { lockedRow(onboarding()) mocks.permission.mockResolvedValue(false) await expect(markBuilderChecklistFromAnalytics(context(), 'com.test.builder', startSetupEvent('ios'))).resolves.toBe(false) expect(mocks.permission).toHaveBeenNthCalledWith(1, expect.anything(), 'app.update_settings', { appId: 'com.test.builder' }, expect.anything(), expect.any(String), 'fixture-key') expect(mocks.permission).toHaveBeenNthCalledWith(2, expect.anything(), 'org.create_app', { orgId: '22222222-2222-4222-8222-222222222222' }, expect.anything(), expect.any(String), 'fixture-key') expect(mocks.execute).toHaveBeenCalledTimes(3) expect(mocks.track).not.toHaveBeenCalled() }) it('rejects setup start when the resolved app differs from the event app', async () => { const input = startSetupEvent('ios') input.tags['app-id'] = 'com.test.other' await expect(markBuilderChecklistFromAnalytics(context(), 'com.test.other', input)).resolves.toBe(false) expect(mocks.permission).not.toHaveBeenCalled() expect(mocks.execute).not.toHaveBeenCalled() expect(mocks.track).not.toHaveBeenCalled() }) it('rejects a prepared profile when the resolved app differs from the event app', async () => { const input = profilePreparedEvent('imported') input.tags['app-id'] = 'com.test.other' await expect(markBuilderChecklistFromAnalytics(context(), 'com.test.other', input)).resolves.toBe(false) expect(mocks.permission).not.toHaveBeenCalled() expect(mocks.execute).not.toHaveBeenCalled() expect(mocks.track).not.toHaveBeenCalled() }) it.each([ { setup: { ...onboarding().setup, todo_list_version: 3 } }, { setup: { ...onboarding().setup, builder_todo_list_version: '2' } }, { setup: { ...onboarding().setup, paths: ['ota'] } }, ])('does not update an ineligible Builder checklist %#', async (current) => { lockedRow(current) mocks.permission.mockResolvedValue(true) await expect(markBuilderChecklistFromAnalytics(context(), 'com.test.builder', startSetupEvent('android'))).resolves.toBe(false) expect(mocks.execute).toHaveBeenCalledTimes(3) expect(mocks.track).not.toHaveBeenCalled() }) it('keeps the analytics request best-effort when persistence fails', async () => { mocks.execute.mockRejectedValueOnce(new Error('database unavailable')) await expect(markBuilderChecklistFromAnalytics(context(), 'com.test.builder', startSetupEvent('android'))).resolves.toBe(false) expect(mocks.close).toHaveBeenCalledOnce() expect(mocks.track).not.toHaveBeenCalled() }) it('uses the authorized mutation path for a prepared iOS profile', async () => { lockedRow(onboarding()) mocks.permission.mockImplementation(async (_c, permission) => permission === 'app.update_settings') await expect(markBuilderChecklistFromAnalytics(context(), 'com.test.builder', profilePreparedEvent('imported'))).resolves.toBe(true) expect(mocks.permission).toHaveBeenCalledWith(expect.anything(), 'app.update_settings', { appId: 'com.test.builder' }, expect.anything(), expect.any(String), 'fixture-key') expect(mocks.track).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ event: 'App Onboarding Step Changed', nonPersonTags: expect.objectContaining({ step_id: 'builder.ios.prepare_profile', step_status: 'done' }), })) }) it.each([ [preparedKeystoreEvent(), 'builder.android.prepare_keystore'], [connectedGooglePlayEvent(), 'builder.android.connect_google_play'], ])('uses the authorized mutation path for %s', async (input, stepId) => { lockedRow(onboarding()) mocks.permission.mockImplementation(async (_c, permission) => permission === 'app.update_settings') await expect(markBuilderChecklistFromAnalytics(context(), 'com.test.builder', input)).resolves.toBe(true) expect(mocks.permission).toHaveBeenCalledWith(expect.anything(), 'app.update_settings', { appId: 'com.test.builder' }, expect.anything(), expect.any(String), 'fixture-key') expect(mocks.track).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ event: 'App Onboarding Step Changed', nonPersonTags: expect.objectContaining({ step_id: stepId, step_status: 'done' }), })) }) it.each([preparedKeystoreEvent(), connectedGooglePlayEvent()])('rejects Android preparation when the event app differs from the resolved app %#', async (input) => { await expect(markBuilderChecklistFromAnalytics(context(), 'com.test.other', input)).resolves.toBe(false) expect(mocks.permission).not.toHaveBeenCalled() expect(mocks.execute).not.toHaveBeenCalled() expect(mocks.track).not.toHaveBeenCalled() }) }) describe('persisted builder cloud build outcomes', () => { beforeEach(() => { vi.clearAllMocks() mocks.transaction.mockImplementation(async (fn: any) => fn({ execute: mocks.execute })) }) it('persists an eligible iOS outcome and emits system history', async () => { lockedRow(onboarding()) await expect(persistBuilderBuildOutcome(context(), { appId: 'com.test.builder', platform: 'ios', status: 'failed' })).resolves.toBe(true) expect(mocks.permission).not.toHaveBeenCalled() expect(mocks.execute).toHaveBeenCalledTimes(4) expect(mocks.track).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ distinct_id: 'app-onboarding-app:com.test.builder', nonPersonTags: expect.objectContaining({ step_id: 'builder.ios.successful_cloud_build', step_status: 'warning' }), })) }) it.each([ ['succeeded', 'done'], ['failed', 'warning'], ])('persists an immediate Android %s outcome as %s', async (status, stepStatus) => { lockedRow(onboarding()) await expect(persistBuilderBuildOutcome(context(), { appId: 'com.test.builder', platform: 'android', status })).resolves.toBe(true) expect(mocks.execute).toHaveBeenCalledTimes(4) expect(mocks.track).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ distinct_id: 'app-onboarding-app:com.test.builder', nonPersonTags: expect.objectContaining({ step_id: 'builder.android.successful_cloud_build', step_status: stepStatus }), })) }) it.each([ ['succeeded', { status: 'done', at: 'earlier' }], ['failed', { status: 'warning', at: 'earlier', annotation: 'cloud_build_failed', annotation_type: 'warning' }], ])('is idempotent for an already persisted Android %s outcome', async (status, currentStep) => { const current = onboarding() as any current.setup.steps.builder.android.successful_cloud_build = currentStep lockedRow(current) await expect(persistBuilderBuildOutcome(context(), { appId: 'com.test.builder', platform: 'android', status })).resolves.toBe(false) expect(mocks.execute).toHaveBeenCalledTimes(3) expect(mocks.track).not.toHaveBeenCalled() }) it('preserves a completed Android build step after a later failure', async () => { const current = onboarding() as any current.setup.steps.builder.android.successful_cloud_build = { status: 'done', at: 'earlier' } lockedRow(current) await expect(persistBuilderBuildOutcome(context(), { appId: 'com.test.builder', platform: 'android', status: 'failed' })).resolves.toBe(false) expect(mocks.execute).toHaveBeenCalledTimes(3) expect(mocks.track).not.toHaveBeenCalled() }) it('isolates persistence failures from its caller', async () => { mocks.transaction.mockRejectedValue(new Error('database unavailable')) await expect(persistBuilderBuildOutcome(context(), { appId: 'com.test.builder', platform: 'ios', status: 'succeeded' })).resolves.toBe(false) expect(mocks.track).not.toHaveBeenCalled() expect(mocks.close).toHaveBeenCalledOnce() expect(mocks.log).toHaveBeenCalledWith(expect.objectContaining({ message: 'builder onboarding checklist build outcome update failed', app_id: 'com.test.builder', platform: 'ios', status: 'succeeded', })) }) })