Ctrl K
ComunidadGuías…

tests/build-lifecycle-emit.unit.test.ts

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

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