Ctrl K
ComunidadGuías…

tests/builder-checklist-analytics.unit.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<unknown>) => await task }))

const FIXED_NOW = '2026-09-22T12:00:00.000Z'
function event(tags: Record<string, string>, overrides: Record<string, unknown> = {}) {
  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<string, string>) {
  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<string, unknown> = { 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<MiddlewareKeyVariables>
}

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',
    }))
  })
})