Ctrl K
ComunidadGuías…

tests/compatibility-events-handler.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 PKG_V6 = [{ name: '@capacitor/core', version: '6.0.0' }]
const PKG_V7 = [{ name: '@capacitor/core', version: '7.0.0' }]

// app_versions rows the handler will load by id.
const appVersions: Record<number, { id: number, name: string, native_packages: unknown[] | null }> = {
  600: { id: 600, name: '6.0.0', native_packages: PKG_V6 },
  700: { id: 700, name: '7.0.0', native_packages: PKG_V7 },
}

const { eventStore, dedupKey, pgQuery } = vi.hoisted(() => {
  const store: any[] = []
  const key = (r: any) => [r.app_id, r.channel_id, r.platform, r.current_version_id, r.previous_version_id, r.change_occurred_at].join('|')
  return { eventStore: store, dedupKey: key, pgQuery: vi.fn() }
})

// A tiny SQL-text router over an in-memory model. The handler only needs the
// channels winner lookup to return "this is the sole public default" and
// app_versions/compatibility_events. `queryJson` wraps statements in
// `WITH q AS (...) SELECT to_json(q.*) AS row FROM q`, so those rows come back as `{ row }`.
async function runQuery(sql: string, params: any[], ctx: { appVersionsById: typeof appVersions, events: any[] }) {
  const wrapped = /^WITH q AS \(([\s\S]*)\) SELECT pg_catalog\.to_json\(q\.\*\) AS row FROM q$/.exec(sql)
  const rows = await routeQuery(wrapped ? wrapped[1] : sql, params, ctx)
  return { rows: wrapped ? rows.map(row => ({ row })) : rows, rowCount: rows.length }
}

async function routeQuery(sql: string, params: any[], ctx: { appVersionsById: typeof appVersions, events: any[] }): Promise<any[]> {
  if (sql.includes('public.channels')) {
    // getCurrentChannel: select by id -> return the record as a public default.
    if (sql.startsWith('SELECT id, app_id, public'))
      return [{ id: params[0], app_id: 'com.test.app', public: true, ios: true, android: false, electron: false, updated_at: 't', created_at: 't' }]
    // getPreviousDefaultChannel: excludes record.id -> no other public default (Case B).
    if (sql.startsWith('SELECT id, version'))
      return []
    // getCurrentPublicWinner: select id where public + platform -> the record itself wins.
    if (sql.startsWith('SELECT id FROM'))
      return [{ id: 101 }]
    // demotion update -> no-op success
    return []
  }

  if (sql.includes('FROM public.app_versions')) {
    const row = ctx.appVersionsById[params[0]]
    return row ? [row] : []
  }

  if (sql.includes('public.compatibility_events')) {
    if (sql.startsWith('INSERT')) {
      const [org_id, app_id, source, platform, channel_id, channel_name, current_version_id, current_version_name, previous_version_id, previous_version_name, offenders, change_occurred_at] = params
      const row = { org_id, app_id, source, platform, channel_id, channel_name, current_version_id, current_version_name, previous_version_id, previous_version_name, offenders: JSON.parse(offenders), change_occurred_at }
      const k = dedupKey(row)
      const existing = ctx.events.find(e => dedupKey(e) === k)
      if (existing) {
        // ON CONFLICT DO UPDATE: overwrite the payload columns of the existing
        // row. The insert payload never carries the resolution columns, so an
        // already-resolved row keeps its resolved_at / resolution_kind /
        // resolution_note across a redelivery.
        Object.assign(existing, row)
        return []
      }
      ctx.events.push({
        id: ctx.events.length + 1,
        resolved_at: null,
        resolved_by: null,
        resolution_kind: null,
        resolution_note: null,
        ...row,
      })
      return []
    }
    if (sql.startsWith('UPDATE')) {
      const [resolvedAt, note, id] = params
      const target = ctx.events.find(e => e.id === id && e.resolved_at == null)
      if (target)
        Object.assign(target, { resolved_at: resolvedAt, resolved_by: null, resolution_kind: 'auto_compatible', resolution_note: note })
      return []
    }
    // select unresolved for app (and channel, for revert detection)
    const channelId = sql.includes('channel_id = $2') ? params[1] : undefined
    return ctx.events.filter(e => e.app_id === params[0] && e.resolved_at == null && (channelId === undefined || e.channel_id === channelId))
  }

  return []
}

vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({
  getPgClient: vi.fn(async () => ({ query: pgQuery })),
  closeClient: vi.fn(),
}))

vi.mock('../supabase/functions/_backend/utils/logging.ts', () => ({
  cloudlog: vi.fn(),
  cloudlogErr: vi.fn(),
}))

// Stub the API-secret guard and provide a minimal triggerValidator that mirrors
// the real one (sets webhookBody + oldRecord for UPDATE payloads).
vi.mock('../supabase/functions/_backend/utils/hono.ts', () => ({
  BRES: { status: 'ok' },
  middlewareAPISecret: async (_c: unknown, next: () => Promise<void>) => next(),
  simpleError: (code: string, message: string) => new Error(`${code}: ${message}`),
  triggerValidator: () => async (c: any, next: () => Promise<void>) => {
    const body = await c.req.json()
    if (body.type === 'UPDATE' && body.record) {
      c.set('webhookBody', body.record)
      c.set('oldRecord', body.old_record)
    }
    await next()
  },
}))

const { app } = await import('../supabase/functions/_backend/triggers/on_channel_update.ts')

function channelRecord(overrides: Record<string, unknown> = {}) {
  return {
    id: 101,
    app_id: 'com.test.app',
    owner_org: 'org-1',
    name: 'production',
    version: 700,
    public: true,
    ios: true,
    android: false,
    electron: false,
    disable_auto_update: 'major',
    created_at: 't',
    updated_at: 't',
    created_by: 'user-1',
    ...overrides,
  }
}

function updatePayload(record: Record<string, unknown>, oldRecord: Record<string, unknown>) {
  return {
    type: 'UPDATE',
    table: 'channels',
    schema: 'public',
    record,
    old_record: oldRecord,
  }
}

function post(body: unknown) {
  return app.request(new Request('http://local/', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  }))
}

describe('on_channel_update compatibility events (integration)', () => {
  beforeEach(() => {
    vi.stubEnv('CAPGO_PREVENT_BACKGROUND_FUNCTIONS', 'true')
    vi.clearAllMocks()
    eventStore.length = 0
    pgQuery.mockImplementation((sql: string, params: any[] = []) => runQuery(sql, params, { appVersionsById: appVersions, events: eventStore }))
  })

  it('records one incompatible event on a default-channel version change (Case B)', async () => {
    const response = await post(updatePayload(channelRecord({ version: 700 }), channelRecord({ version: 600 })))

    expect(response.status).toBe(200)
    expect(eventStore).toHaveLength(1)
    expect(eventStore[0]).toMatchObject({
      app_id: 'com.test.app',
      org_id: 'org-1',
      source: 'default_channel_version_changed',
      platform: 'ios',
      channel_id: 101,
      channel_name: 'production',
      current_version_id: 700,
      current_version_name: '7.0.0',
      previous_version_id: 600,
      previous_version_name: '6.0.0',
      offenders: ['@capacitor/core'],
    })
  })

  it('is idempotent: re-POSTing the same payload does not duplicate the row', async () => {
    const payload = updatePayload(channelRecord({ version: 700 }), channelRecord({ version: 600 }))

    await post(payload)
    await post(payload)

    expect(eventStore).toHaveLength(1)
  })

  it('preserves a resolved row across a redelivery (ON CONFLICT DO UPDATE keeps resolution columns)', async () => {
    const payload = updatePayload(channelRecord({ version: 700 }), channelRecord({ version: 600 }))

    await post(payload)
    expect(eventStore).toHaveLength(1)

    // Someone (auto-resolve or manual accept) resolves the row out of band.
    Object.assign(eventStore[0], {
      resolved_at: '2026-06-03T00:00:00.000Z',
      resolved_by: 'user-9',
      resolution_kind: 'accepted',
      resolution_note: 'reviewed',
    })

    // A redelivery of the same webhook re-upserts the payload columns but must
    // NOT clobber the resolution columns (they are absent from the insert).
    await post(payload)

    expect(eventStore).toHaveLength(1)
    expect(eventStore[0]).toMatchObject({
      resolved_at: '2026-06-03T00:00:00.000Z',
      resolved_by: 'user-9',
      resolution_kind: 'accepted',
      resolution_note: 'reviewed',
    })
  })

  it('creates a NEW unresolved row when the same transition re-occurs later', async () => {
    await post(updatePayload(channelRecord({ version: 700 }), channelRecord({ version: 600 })))
    expect(eventStore).toHaveLength(1)

    // The first occurrence gets resolved (auto-resolve or manual accept).
    Object.assign(eventStore[0], {
      resolved_at: '2026-06-03T00:00:00.000Z',
      resolved_by: 'user-9',
      resolution_kind: 'accepted',
      resolution_note: 'reviewed',
    })

    // The SAME bundle pair goes live again later — a new channel update, so a
    // new `updated_at` (the occurrence identity in the dedup key). It must
    // create a fresh unresolved row instead of being absorbed by the resolved
    // first occurrence.
    await post(updatePayload(
      channelRecord({ version: 700, updated_at: 't2' }),
      channelRecord({ version: 600, updated_at: 't' }),
    ))

    expect(eventStore).toHaveLength(2)
    expect(eventStore[0].resolution_kind).toBe('accepted')
    expect(eventStore[1].resolved_at).toBeNull()
    expect(eventStore[1].change_occurred_at).toBe('t2')
  })

  it('records no event when the version change is OTA-compatible', async () => {
    // 6.0.0 -> 6.0.0 (same packages): compatible, no event. Use a distinct
    // current bundle id so it is treated as a real change but still compatible.
    appVersions[650] = { id: 650, name: '6.0.1', native_packages: PKG_V6 }
    const response = await post(updatePayload(channelRecord({ version: 650 }), channelRecord({ version: 600 })))

    expect(response.status).toBe(200)
    expect(eventStore).toHaveLength(0)
  })

  it('fans out one event per default platform (ios + android, not electron)', async () => {
    const platforms = { ios: true, android: true, electron: false }
    const response = await post(updatePayload(
      channelRecord({ version: 700, ...platforms }),
      channelRecord({ version: 600, ...platforms }),
    ))

    expect(response.status).toBe(200)
    expect(eventStore).toHaveLength(2)
    expect(eventStore.map(e => e.platform).sort()).toEqual(['android', 'ios'])
    expect(eventStore.some(e => e.platform === 'electron')).toBe(false)
  })

  it('auto-resolves an unresolved event when the default reverts to a compatible bundle', async () => {
    // The reverted default (id 800) ships v6 packages, compatible with baseline 600.
    appVersions[800] = { id: 800, name: '6.0.1', native_packages: PKG_V6 }

    // Seed one open ios event raised earlier (baseline 600 -> incompatible 700).
    eventStore.push({
      id: 1,
      app_id: 'com.test.app',
      channel_id: 101,
      platform: 'ios',
      current_version_id: 700,
      previous_version_id: 600,
      previous_version_name: '6.0.0',
      resolved_at: null,
      resolved_by: null,
      resolution_kind: null,
      resolution_note: null,
    })

    // oldRecord version equals record version -> no NEW event is created; only the
    // auto-resolve path runs against the now-compatible default (800).
    const response = await post(updatePayload(
      channelRecord({ version: 800 }),
      channelRecord({ version: 800 }),
    ))

    expect(response.status).toBe(200)
    expect(eventStore).toHaveLength(1)
    expect(eventStore[0].resolved_at).not.toBeNull()
    expect(eventStore[0].resolution_kind).toBe('auto_compatible')
    expect(eventStore[0].resolved_by).toBeNull()
  })
})