tests/compatibility-events-decide.unit.test.ts
Rama main · réplica de lectura del repositorio original.
import type {
CompatibilityBundle,
CurrentDefaultForPlatform,
DecideCompatibilityEventsInput,
PreviousDefault,
UnresolvedCompatibilityEvent,
} from '../supabase/functions/_backend/triggers/compatibility_events.ts'
import type { NativePackage } from '../supabase/functions/_backend/utils/bundle_compatibility.ts'
import { describe, expect, it } from 'vitest'
import {
decideAutoResolves,
decideCompatibilityEvents,
} from '../supabase/functions/_backend/triggers/compatibility_events.ts'
// ---- fixtures -------------------------------------------------------------
const CHANGE_AT = '2026-06-06T12:00:00.000Z'
const PKG_V6: NativePackage[] = [{ name: '@capacitor/core', version: '6.0.0' }]
// Major bump → version ranges do not intersect → incompatible.
const PKG_V7: NativePackage[] = [{ name: '@capacitor/core', version: '7.0.0' }]
// Same native packages as v7 (a native-identical successor).
const PKG_V7_DUP: NativePackage[] = [{ name: '@capacitor/core', version: '7.0.0' }]
function bundle(id: number, name: string, nativePackages: NativePackage[] | null): CompatibilityBundle {
return { id, name, nativePackages }
}
function newChannel(overrides: Partial<DecideCompatibilityEventsInput['newChannel']> = {}): DecideCompatibilityEventsInput['newChannel'] {
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',
// The platform downgrade guard, on by default (mirrors the column default).
disable_auto_update_under_native: true,
...overrides,
}
}
describe('decideCompatibilityEvents', () => {
it('emits an event for a same-channel incompatible version change (Case B)', () => {
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel(),
currentBundle: bundle(700, '7.0.0', PKG_V7),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(600, '6.0.0', PKG_V6),
}],
})
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
org_id: 'org-1',
app_id: 'com.test.app',
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('emits an event for a default-channel switch (Case A)', () => {
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel(),
currentBundle: bundle(700, '7.0.0', PKG_V7),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_changed',
bundle: bundle(600, '6.0.0', PKG_V6),
}],
})
expect(events).toHaveLength(1)
expect(events[0].source).toBe('default_channel_changed')
expect(events[0].previous_version_id).toBe(600)
})
it('emits no event when the change is OTA-compatible', () => {
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel(),
currentBundle: bundle(700, '6.0.1', PKG_V6),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(600, '6.0.0', PKG_V6),
}],
})
expect(events).toHaveLength(0)
})
it('emits no event under the metadata (version_number) strategy', () => {
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel({ disable_auto_update: 'version_number' }),
currentBundle: bundle(700, '7.0.0', PKG_V7),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(600, '6.0.0', PKG_V6),
}],
})
expect(events).toHaveLength(0)
})
it('emits no event when the current bundle has no native_packages', () => {
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel(),
currentBundle: bundle(700, '7.0.0', null),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(600, '6.0.0', PKG_V6),
}],
})
expect(events).toHaveLength(0)
})
it('emits no event when the previous bundle has no native_packages', () => {
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel(),
currentBundle: bundle(700, '7.0.0', PKG_V7),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(600, '6.0.0', []),
}],
})
expect(events).toHaveLength(0)
})
it('does not drop a soft-deleted baseline (still has native_packages)', () => {
// A soft-deleted previous bundle is still a valid baseline as long as its
// metadata is present: we must still raise the event.
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel(),
currentBundle: bundle(700, '7.0.0', PKG_V7),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(600, '6.0.0', PKG_V6),
}],
})
expect(events).toHaveLength(1)
})
it('fans out one event per default platform (ios + android, not electron)', () => {
const previous = (platform: PreviousDefault['platform']): PreviousDefault => ({
platform,
source: 'default_channel_version_changed',
bundle: bundle(600, '6.0.0', PKG_V6),
})
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel({ ios: true, android: true, electron: false }),
currentBundle: bundle(700, '7.0.0', PKG_V7),
previousDefaults: [previous('ios'), previous('android'), previous('electron')],
})
const platforms = events.map(e => e.platform).sort()
expect(platforms).toEqual(['android', 'ios'])
})
it('emits no event when the channel is not public', () => {
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel({ public: false }),
currentBundle: bundle(700, '7.0.0', PKG_V7),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(600, '6.0.0', PKG_V6),
}],
})
expect(events).toHaveLength(0)
})
it('emits no event when previous and current are the same bundle id', () => {
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel(),
currentBundle: bundle(700, '7.0.0', PKG_V7),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(700, '7.0.0', PKG_V7),
}],
})
expect(events).toHaveLength(0)
})
it.concurrent('suppresses the mirror event when reverting to an unresolved event\'s baseline', () => {
// E1 (prev=600, cur=700) is unresolved; the channel reverts 700 -> 600.
// Without suppression this raises E2 (prev=700, cur=600) and every rollback
// would raise the next mirror event, forever.
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel({ version: 600 }),
currentBundle: bundle(600, '6.0.0', PKG_V6),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(700, '7.0.0', PKG_V7),
}],
unresolvedEvents: [{
id: 1,
platform: 'ios',
channel_id: 101,
previous_version_id: 600,
previous_version_name: '6.0.0',
current_version_id: 700,
}],
})
expect(events).toHaveLength(0)
})
it.concurrent('does NOT suppress a non-inverse transition into a known baseline (800 -> 600 with 600 -> 700 open)', () => {
// Only the exact inverse of the open event (700 -> 600) is a rollback; a
// transition from some other bundle (800) into the baseline must still
// raise its own event, because its immediate baseline is 800, not 700.
const pkgV8: NativePackage[] = [{ name: '@capacitor/core', version: '8.0.0' }]
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel({ version: 600 }),
currentBundle: bundle(600, '6.0.0', PKG_V6),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(800, '8.0.0', pkgV8),
}],
unresolvedEvents: [{
id: 1,
platform: 'ios',
channel_id: 101,
previous_version_id: 600,
previous_version_name: '6.0.0',
current_version_id: 700,
}],
})
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ previous_version_id: 800, current_version_id: 600 })
})
it.concurrent('does NOT suppress the mirror event when the downgrade guard is off', () => {
// With disable_auto_update_under_native off, devices that already installed
// the newer native build CAN receive the rolled-back bundle, so the mirror
// event is a real warning and must be raised.
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel({ version: 600, disable_auto_update_under_native: false }),
currentBundle: bundle(600, '6.0.0', PKG_V6),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(700, '7.0.0', PKG_V7),
}],
unresolvedEvents: [{
id: 1,
platform: 'ios',
channel_id: 101,
previous_version_id: 600,
previous_version_name: '6.0.0',
current_version_id: 700,
}],
})
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ previous_version_id: 700, current_version_id: 600 })
})
it.concurrent('does NOT suppress when the unresolved baseline belongs to another channel', () => {
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel({ version: 600 }),
currentBundle: bundle(600, '6.0.0', PKG_V6),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(700, '7.0.0', PKG_V7),
}],
unresolvedEvents: [{
id: 1,
platform: 'ios',
channel_id: 999,
previous_version_id: 600,
previous_version_name: '6.0.0',
current_version_id: 700,
}],
})
expect(events).toHaveLength(1)
})
it.concurrent('does NOT suppress an incompatible change to a bundle no unresolved event knows as a baseline', () => {
const events = decideCompatibilityEvents({
changeOccurredAt: CHANGE_AT,
newChannel: newChannel({ version: 600 }),
currentBundle: bundle(600, '6.0.0', PKG_V6),
previousDefaults: [{
platform: 'ios',
source: 'default_channel_version_changed',
bundle: bundle(700, '7.0.0', PKG_V7),
}],
unresolvedEvents: [{
id: 1,
platform: 'ios',
channel_id: 101,
previous_version_id: 500,
previous_version_name: '5.0.0',
current_version_id: 700,
}],
})
expect(events).toHaveLength(1)
})
})
describe('decideAutoResolves', () => {
function unresolved(overrides: Partial<UnresolvedCompatibilityEvent> = {}): UnresolvedCompatibilityEvent {
return {
id: 1,
platform: 'ios',
channel_id: 101,
previous_version_id: 600,
previous_version_name: '6.0.0',
current_version_id: 700,
...overrides,
}
}
it('auto-resolves when the current default is now compatible with the baseline', () => {
// Reverted to a v6-compatible default (id 800) — compatible with baseline 600.
const currentDefault: CurrentDefaultForPlatform[] = [{ platform: 'ios', bundle: bundle(800, '6.0.1', PKG_V6) }]
const bundles = new Map([[600, bundle(600, '6.0.0', PKG_V6)]])
const resolves = decideAutoResolves([unresolved()], currentDefault, bundles)
expect(resolves).toHaveLength(1)
expect(resolves[0].id).toBe(1)
expect(resolves[0].note).toContain('6.0.1')
expect(resolves[0].note).toContain('6.0.0')
})
it('does NOT auto-resolve on a native-identical successor', () => {
// The current default is the very same bundle that raised the event: users
// are still stranded, so the event must stay open.
const currentDefault: CurrentDefaultForPlatform[] = [{ platform: 'ios', bundle: bundle(700, '7.0.0', PKG_V7) }]
const bundles = new Map([[600, bundle(600, '6.0.0', PKG_V6)]])
const resolves = decideAutoResolves([unresolved({ current_version_id: 700 })], currentDefault, bundles)
expect(resolves).toHaveLength(0)
})
it('does NOT auto-resolve a different-but-still-incompatible successor (id ≠ current, native re-uploaded)', () => {
// A new bundle id (900) but native-identical to the incompatible v7: still
// incompatible with the v6 baseline, so it must not clear the event.
const currentDefault: CurrentDefaultForPlatform[] = [{ platform: 'ios', bundle: bundle(900, '7.0.1', PKG_V7_DUP) }]
const bundles = new Map([[600, bundle(600, '6.0.0', PKG_V6)]])
const resolves = decideAutoResolves([unresolved({ current_version_id: 700 })], currentDefault, bundles)
expect(resolves).toHaveLength(0)
})
it('does NOT auto-resolve when the current default for the platform is unknown', () => {
const resolves = decideAutoResolves([unresolved()], [], new Map([[600, bundle(600, '6.0.0', PKG_V6)]]))
expect(resolves).toHaveLength(0)
})
})