tests/builder-checklist-analytics.unit.test.ts
Rama main · réplica de lectura del repositorio original.
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',
}))
})
})