tests/app-onboarding-bento.unit.test.ts
Rama main · réplica de lectura del repositorio original.
import type { AppOnboardingStepHistoryChange } from '../supabase/functions/_backend/utils/appOnboarding.ts'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildAppOnboardingMilestoneBentoEvent, emitCommittedAppOnboardingBento } from '../supabase/functions/_backend/utils/app_onboarding_bento.ts'
import { emitCommittedAppOnboardingHistory, emitCommittedSystemAppOnboardingHistory } from '../supabase/functions/_backend/utils/app_onboarding_posthog.ts'
import { refreshAppOnboardingTodoBatch } from '../supabase/functions/_backend/utils/app_onboarding_todo_refresh.ts'
const mocks = vi.hoisted(() => ({ names: vi.fn(), send: vi.fn(), close: vi.fn(), track: vi.fn(), batch: vi.fn(), log: vi.fn() }))
vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ getPgClient: async () => ({}), getDrizzleClient: () => ({ execute: mocks.names }), closeClient: mocks.close }))
vi.mock('../supabase/functions/_backend/utils/tracking.ts', () => ({ sendEventToTracking: mocks.send }))
vi.mock('../supabase/functions/_backend/utils/posthog.ts', () => ({ trackPosthogEvent: mocks.track, trackPosthogEventBatch: mocks.batch }))
vi.mock('../supabase/functions/_backend/utils/logging.ts', () => ({ cloudlogErr: mocks.log, serializeError: String }))
vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ backgroundTask: async (_c: unknown, task: Promise<unknown>) => await task }))
const input = { appId: 'com.example.app', orgId: 'org-id', appName: 'Example app', orgName: 'Example org' }
const change = (stepId: AppOnboardingStepHistoryChange['stepId'], status: AppOnboardingStepHistoryChange['status'] = 'done'): AppOnboardingStepHistoryChange => ({ stepId, status, at: '2026-10-25T10:00:00Z', historyLength: 1, historyFull: false })
const committed = (historyChanges: AppOnboardingStepHistoryChange[]) => ({ appId: input.appId, orgId: input.orgId, onboarding: {}, historyChanges })
const c = { get: (key: string) => key === 'auth' ? { authType: 'jwt', userId: 'user-id' } : 'request-id' } as any
beforeEach(() => {
vi.clearAllMocks()
mocks.names.mockResolvedValue({ rows: [{ app_id: input.appId, owner_org: input.orgId, app_name: input.appName, org_name: input.orgName }] })
})
describe('onboarding milestone Bento payloads', () => {
it.concurrent('uses the existing CLI OTA signal and de-duplication key', () => {
expect(buildAppOnboardingMilestoneBentoEvent({ ...input, change: change('test_update') })).toEqual({
cron: '* * * * *', event: 'app:updated', preferenceKey: 'onboarding', uniqId: `app:updated:${input.appId}`,
data: { org_id: input.orgId, org_name: input.orgName, app_name: input.appName },
})
})
it.concurrent.each(['ios', 'android'] as const)('uses the existing Builder completion payload for %s', (platform) => {
expect(buildAppOnboardingMilestoneBentoEvent({ ...input, change: change(`builder.${platform}.successful_cloud_build`) })).toEqual({
cron: '* * * * *', event: 'builder_onboarding_completed', preferenceKey: 'builder_onboarding',
uniqId: `builder_onboarding_completed:${input.appId}:${platform}`,
data: { org_id: input.orgId, org_name: input.orgName, app_id: input.appId, app_name: input.appName, platform, step: 'build-complete' },
})
})
it.concurrent.each([
['test_update', 'skipped'], ['test_update', 'blocked'],
['builder.ios.successful_cloud_build', 'warning'], ['builder.android.successful_cloud_build', 'skipped'],
['add_code', 'done'], ['add_updater', 'done'], ['upload_bundle', 'done'], ['builder.ios.prepare_profile', 'done'],
['builder.web.successful_cloud_build', 'done'],
])('ignores %s with status %s', (stepId, status) => {
expect(buildAppOnboardingMilestoneBentoEvent({ ...input, change: change(stepId as any, status as any) })).toBeUndefined()
})
})
describe('committed milestone hooks', () => {
it.each([emitCommittedAppOnboardingHistory, emitCommittedSystemAppOnboardingHistory])('sends milestones through %s without adding PostHog events', async (emit) => {
await emit(c, [committed([change('test_update'), change('builder.android.successful_cloud_build'), change('add_code')])])
expect(mocks.names).toHaveBeenCalledOnce()
expect(mocks.close).toHaveBeenCalledOnce()
expect(mocks.track).toHaveBeenCalledTimes(3)
expect(mocks.send).toHaveBeenCalledTimes(2)
expect(mocks.send).toHaveBeenCalledWith(c, expect.objectContaining({
groups: { organization: input.orgId }, sentToBento: true,
bento: buildAppOnboardingMilestoneBentoEvent({ ...input, change: change('test_update') }),
}), { posthog: false, background: false })
expect(mocks.send).toHaveBeenCalledWith(c, expect.objectContaining({
bento: buildAppOnboardingMilestoneBentoEvent({ ...input, change: change('builder.android.successful_cloud_build') }),
}), { posthog: false, background: false })
})
it('does no lookup or sending for unchanged history or unrelated steps', async () => {
await emitCommittedAppOnboardingBento(c, [committed([]), committed([change('add_code'), change('builder.ios.successful_cloud_build', 'warning')])])
expect(mocks.names).not.toHaveBeenCalled()
expect(mocks.send).not.toHaveBeenCalled()
})
it('keeps committed progress and PostHog delivery when the name lookup fails', async () => {
mocks.names.mockRejectedValueOnce(new Error('lookup unavailable'))
await emitCommittedSystemAppOnboardingHistory(c, [committed([change('test_update')])])
expect(mocks.close).toHaveBeenCalledOnce()
expect(mocks.track).toHaveBeenCalledOnce()
expect(mocks.send).not.toHaveBeenCalled()
expect(mocks.log).toHaveBeenCalledOnce()
})
it.each(['ota', 'builder'])('sends the %s cron milestones after commit with one lookup for 25 apps, and no signals on replay', async (path) => {
const rows = Array.from({ length: 25 }, (_, i) => ({
app_id: `com.example.batch.${i}`, owner_org: input.orgId,
onboarding: { setup: {
todo_list_version: 4, outcome: 'in_progress', paths: [path],
...(path === 'ota' ? { ota_todo_list_version: '1', steps: { ota: {} } } : { builder_todo_list_version: '1', steps: { builder: { ios: { successful_cloud_build: { status: 'pending' } } } } }),
} },
}))
let didCommit = false
const tx = { execute: vi.fn().mockResolvedValue({ rows: [] }) }
tx.execute.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows })
const database = {
execute: vi.fn().mockResolvedValueOnce({ rows }).mockResolvedValue({ rows: path === 'ota' ? [] : rows.map(row => ({ app_id: row.app_id, platform: 'ios', succeeded: true, failed: false })) }),
transaction: async (fn: any) => { const result = await fn(tx); didCommit = true; return result },
} as any
mocks.names.mockImplementation(async () => {
expect(didCommit).toBe(true)
return { rows: rows.map(row => ({ ...row, app_name: input.appName, org_name: input.orgName })) }
})
const body = { appIds: rows.map(row => row.app_id), queuedAt: '2026-10-25T10:00:00Z' }
const options = { gatherEvidence: async () => ({ channel: new Set<string>(), device: new Set<string>(), bundle: new Set<string>(), update: new Set(path === 'ota' ? body.appIds : []), errors: [] }) }
expect(await refreshAppOnboardingTodoBatch(c, database, body, options)).toMatchObject({ updated: 25 })
expect(mocks.names).toHaveBeenCalledOnce()
expect(mocks.send).toHaveBeenCalledTimes(25)
expect(mocks.send.mock.calls[0][1].bento).toMatchObject({
event: path === 'ota' ? 'app:updated' : 'builder_onboarding_completed',
data: { app_name: input.appName, org_name: input.orgName, ...(path === 'builder' ? { platform: 'ios', step: 'build-complete' } : {}) },
})
expect(mocks.batch).toHaveBeenCalledOnce()
// Read the actual persisted JSON on replay so no transition is fabricated.
const updateQuery = tx.execute.mock.calls[2][0]
const updates = JSON.parse(updateQuery.queryChunks.find((chunk: any) => typeof chunk === 'string' && chunk.startsWith('[{')))
const replayRows = rows.map(row => ({ ...row, onboarding: updates.find((update: any) => update.app_id === row.app_id).onboarding }))
database.execute.mockResolvedValueOnce({ rows: replayRows })
tx.execute.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: replayRows })
expect(await refreshAppOnboardingTodoBatch(c, database, body, options)).toMatchObject({ steps: 0 })
expect(mocks.names).toHaveBeenCalledOnce()
expect(mocks.send).toHaveBeenCalledTimes(25)
})
})