tests/compatibility-events-handler.test.ts
Rama main · réplica de lectura del repositorio original.
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()
})
})