tests/build-lifecycle-emit.unit.test.ts
Rama main · réplica de lectura del repositorio original.
import { beforeEach, describe, expect, it, vi } from 'vitest'
const sendEventToTrackingMock = vi.hoisted(() => vi.fn())
const sendDiscordAlertMock = vi.hoisted(() => vi.fn())
const maybeSingleMock = vi.hoisted(() => vi.fn())
const backgroundTaskMock = vi.hoisted(() => vi.fn())
vi.mock('../supabase/functions/_backend/utils/tracking.ts', () => ({
sendEventToTracking: sendEventToTrackingMock,
}))
vi.mock('../supabase/functions/_backend/utils/discord.ts', () => ({
sendDiscordAlert: sendDiscordAlertMock,
}))
// maybeSingleMock keeps its supabase-js `{ data, error }` shape; translate it to a pg query result.
vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({
getPgClient: async () => ({
async query() {
const { data, error } = await maybeSingleMock()
if (error)
throw error
return { rows: data ? [data] : [] }
},
}),
closeClient: () => {},
}))
vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({
backgroundTask: backgroundTaskMock,
}))
vi.mock('../supabase/functions/_backend/utils/mcp_events.ts', () => ({
dispatchMcpBuildEvent: vi.fn().mockResolvedValue(undefined),
}))
const { emitBuildTransitionEvent } = await import('../supabase/functions/_backend/utils/build_tracking.ts')
const baseBuild = {
app_id: 'com.example.app',
platform: 'ios',
build_mode: 'release',
owner_org: 'org-uuid-1',
requested_by: 'user-uuid-1',
}
function fakeContext() {
return {} as any
}
function emitBuildTransition(input: Omit<Parameters<typeof emitBuildTransitionEvent>[1], 'build'>) {
return emitBuildTransitionEvent(fakeContext(), { ...input, build: baseBuild })
}
describe('emitBuildTransitionEvent', () => {
beforeEach(() => {
sendEventToTrackingMock.mockReset()
sendEventToTrackingMock.mockResolvedValue(undefined)
sendDiscordAlertMock.mockReset()
sendDiscordAlertMock.mockResolvedValue(true)
maybeSingleMock.mockReset()
maybeSingleMock.mockResolvedValue({ data: { email: 'developer@example.test' }, error: null })
backgroundTaskMock.mockReset()
backgroundTaskMock.mockResolvedValue(null)
})
it('emits Build Started with no duration_seconds and no failure_category', async () => {
await emitBuildTransition({
previousStatus: 'pending',
effectiveStatus: 'running',
timeoutApplied: false,
})
expect(sendEventToTrackingMock).toHaveBeenCalledTimes(1)
const [, payload] = sendEventToTrackingMock.mock.calls[0]
expect(payload).toMatchObject({
event: 'Build Started',
channel: 'build-lifecycle',
user_id: 'user-uuid-1',
groups: { organization: 'org-uuid-1' },
tags: {
app_id: 'com.example.app',
org_id: 'org-uuid-1',
platform: 'ios',
build_mode: 'release',
},
})
expect(payload.tags.duration_seconds).toBeUndefined()
expect(payload.tags.failure_category).toBeUndefined()
})
it('emits Build Succeeded with duration_seconds when provided', async () => {
await emitBuildTransition({
previousStatus: 'running',
effectiveStatus: 'succeeded',
timeoutApplied: false,
effectiveBuildTimeSeconds: 123,
})
const [, payload] = sendEventToTrackingMock.mock.calls[0]
expect(payload).toMatchObject({
event: 'Build Succeeded',
tags: {
duration_seconds: '123',
},
})
expect(payload.tags.failure_category).toBeUndefined()
})
it('emits Build Failed with failure_category=builder_error for a generic error message', async () => {
await emitBuildTransition({
previousStatus: 'running',
effectiveStatus: 'failed',
timeoutApplied: false,
effectiveError: 'gradle compile failed',
effectiveBuildTimeSeconds: 42,
})
const [, payload] = sendEventToTrackingMock.mock.calls[0]
expect(payload).toMatchObject({
event: 'Build Failed',
tags: {
failure_category: 'builder_error',
duration_seconds: '42',
},
})
})
it('emits Build Failed with failure_category=validation_error for validation-style messages', async () => {
await emitBuildTransition({
previousStatus: 'running',
effectiveStatus: 'failed',
timeoutApplied: false,
effectiveError: 'missing credentials',
})
const [, payload] = sendEventToTrackingMock.mock.calls[0]
expect(payload.tags.failure_category).toBe('validation_error')
})
it('emits Build Timed Out with failure_category=timeout and capped duration', async () => {
await emitBuildTransition({
previousStatus: 'running',
effectiveStatus: 'failed',
timeoutApplied: true,
effectiveError: 'Build timed out after N seconds',
effectiveBuildTimeSeconds: 1800,
})
const [, payload] = sendEventToTrackingMock.mock.calls[0]
expect(payload).toMatchObject({
event: 'Build Timed Out',
tags: {
failure_category: 'timeout',
duration_seconds: '1800',
},
})
})
it.each([
['previous status is already terminal', 'succeeded', 'succeeded'],
['previous === next and no timeout applied', 'running', 'running'],
] as const)('does NOT call sendEventToTracking when %s', async (_name, previousStatus, effectiveStatus) => {
await emitBuildTransition({ previousStatus, effectiveStatus, timeoutApplied: false })
expect(sendEventToTrackingMock).not.toHaveBeenCalled()
})
it('does NOT include duration_seconds for the started transition even when effectiveBuildTimeSeconds is set', async () => {
await emitBuildTransition({
previousStatus: 'pending',
effectiveStatus: 'running',
timeoutApplied: false,
effectiveBuildTimeSeconds: 7,
})
const [, payload] = sendEventToTrackingMock.mock.calls[0]
expect(payload.tags.duration_seconds).toBeUndefined()
})
it('does NOT include duration_seconds when value is null', async () => {
await emitBuildTransition({
previousStatus: 'running',
effectiveStatus: 'succeeded',
timeoutApplied: false,
effectiveBuildTimeSeconds: null,
})
const [, payload] = sendEventToTrackingMock.mock.calls[0]
expect(payload.tags.duration_seconds).toBeUndefined()
})
it('alerts Discord below the 10-second boundary and includes identifying fields', async () => {
let resolveEmailLookup!: (value: unknown) => void
maybeSingleMock.mockReturnValue(new Promise(resolve => resolveEmailLookup = resolve))
await emitBuildTransition({
jobId: 'job-uuid-1',
previousStatus: 'running',
effectiveStatus: 'failed',
timeoutApplied: false,
effectiveBuildTimeSeconds: 9,
})
expect(backgroundTaskMock).toHaveBeenCalledTimes(2)
expect(sendDiscordAlertMock).not.toHaveBeenCalled()
resolveEmailLookup({ data: { email: 'developer@example.test' }, error: null })
await backgroundTaskMock.mock.calls[0][1]
expect(sendDiscordAlertMock).toHaveBeenCalledTimes(1)
const payload = sendDiscordAlertMock.mock.calls[0][1]
const fields = Object.fromEntries(payload.embeds[0].fields.map((field: any) => [field.name, field.value]))
expect(payload.allowed_mentions).toEqual({ parse: [] })
expect(fields).toEqual({
'Job ID': 'job-uuid-1',
'Platform': 'ios',
'App ID': 'com.example.app',
'Email': 'developer@example.test',
'Runtime': '9s',
})
sendDiscordAlertMock.mockClear()
backgroundTaskMock.mockClear()
await emitBuildTransition({
jobId: 'job-uuid-2',
previousStatus: 'running',
effectiveStatus: 'failed',
timeoutApplied: false,
effectiveBuildTimeSeconds: 10,
})
expect(sendDiscordAlertMock).not.toHaveBeenCalled()
expect(backgroundTaskMock).toHaveBeenCalledTimes(1)
})
})