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'] { 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 { 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) }) })