import { describe, expect, it } from 'vitest' import { BUNDLE_INCOMPATIBLE_EVENT, buildBundleCompatibilityBentoEvent, bundleIncompatibleEmailOutcome, isBreakingChangeGatedByChannelStrategy, isCliTrueTag } from '../supabase/functions/_backend/utils/bundle_compatibility_recovery.ts' const base = { event: BUNDLE_INCOMPATIBLE_EVENT, orgId: 'org-1', appId: 'com.demo.app', channelOverwritten: true, channel: 'production', source: 'upload', versionNewId: '101', versionNewName: '1.0.1', versionOldId: '100', versionOldName: '1.0.0', orgName: 'Demo Org', appName: 'Demo', // 'none' never gates, so the base case is the crash-warning event. disableAutoUpdate: 'none', minUpdateVersion: null, } describe('buildBundleCompatibilityBentoEvent', () => { it.concurrent('exposes the trigger event name', () => { expect(BUNDLE_INCOMPATIBLE_EVENT).toBe('Bundle Incompatible') }) it.concurrent('builds a full payload for an incompatible upload that went live', () => { const r = buildBundleCompatibilityBentoEvent(base) expect(r).toBeDefined() expect(r!.event).toBe('bundle_incompatible') expect(r!.preferenceKey).toBe('bundle_incompatible') // Permanent per-version dedupe (no reopening cron window), so retries of the // same incompatible version don't re-email org admins. expect(r!.once).toBe(true) expect(r!.cron).toBeUndefined() expect(r!.uniqId).toBe('bundle_incompatible:com.demo.app:production:1.0.1') expect(r!.data).toMatchObject({ disable_auto_update: 'none', gated: false, min_update_version: '', org_id: 'org-1', org_name: 'Demo Org', app_id: 'com.demo.app', app_name: 'Demo', channel: 'production', source: 'upload', version_new_id: '101', version_new_name: '1.0.1', version_old_id: '100', version_old_name: '1.0.0', }) }) // Email gate: only an incompatible upload that overwrote the channel's live // version produces a payload. PostHog still records every incompatible upload // upstream — this only controls the org-member email. it.concurrent('returns undefined when the channel was not overwritten', () => { expect(buildBundleCompatibilityBentoEvent({ ...base, channelOverwritten: false })).toBeUndefined() }) it.concurrent('returns undefined when channel_overwritten is missing', () => { expect(buildBundleCompatibilityBentoEvent({ ...base, channelOverwritten: undefined })).toBeUndefined() }) it.concurrent('returns undefined when the caller accepted the incompatibility', () => { expect(buildBundleCompatibilityBentoEvent({ ...base, incompatibilityAccepted: true })).toBeUndefined() }) it.concurrent('parses CLI boolean tags as true or the string true', () => { expect(isCliTrueTag(true)).toBe(true) expect(isCliTrueTag('true')).toBe(true) expect(isCliTrueTag(false)).toBe(false) expect(isCliTrueTag('false')).toBe(false) expect(isCliTrueTag(undefined)).toBe(false) }) it.concurrent('maps accepted incompatibility to the skipped_accepted email outcome', () => { expect(bundleIncompatibleEmailOutcome(true, false)).toBe('skipped_accepted') expect(bundleIncompatibleEmailOutcome(true, true)).toBe('skipped_accepted') expect(bundleIncompatibleEmailOutcome(false, true)).toBe('sent_expected') expect(bundleIncompatibleEmailOutcome(false, false)).toBe('sent') }) it.concurrent('falls back to the old version in uniqId when the new version is absent', () => { const r = buildBundleCompatibilityBentoEvent({ ...base, versionNewId: undefined, versionNewName: undefined }) expect(r).toBeDefined() expect(r!.uniqId).toBe('bundle_incompatible:com.demo.app:production:1.0.0') expect(r!.data.version_new_id).toBe('') expect(r!.data.version_new_name).toBe('') expect(r!.data.version_old_name).toBe('1.0.0') }) it.concurrent('returns undefined for other event names', () => { expect(buildBundleCompatibilityBentoEvent({ ...base, event: 'Bundle Upload Compatibility Checked' })).toBeUndefined() }) it.concurrent('returns undefined when org or app id is missing', () => { expect(buildBundleCompatibilityBentoEvent({ ...base, orgId: undefined })).toBeUndefined() expect(buildBundleCompatibilityBentoEvent({ ...base, appId: undefined })).toBeUndefined() }) it.concurrent('defaults missing fields to safe empties', () => { const r = buildBundleCompatibilityBentoEvent({ ...base, source: undefined, channel: undefined, orgName: undefined, appName: undefined, versionNewId: undefined, versionNewName: undefined, versionOldId: undefined, versionOldName: undefined, }) expect(r).toBeDefined() expect(r!.data.source).toBe('unknown') expect(r!.data.channel).toBe('') expect(r!.data.org_name).toBe('') expect(r!.data.app_name).toBe('') expect(r!.data.version_new_id).toBe('') expect(r!.data.version_old_id).toBe('') // No version names left to key off; uniqId trails with empty segments. expect(r!.uniqId).toBe('bundle_incompatible:com.demo.app::') }) }) // The channel's update strategy decides which of the two Bento signals fires: // when the version bump keeps the bundle off devices still running the previous // (incompatible) native build, the breaking change was done correctly. describe('isBreakingChangeGatedByChannelStrategy', () => { it.concurrent.each([ // strategy, old, new, gated ['major', '1.10.35', '2.0.0', true], ['major', '1.10.35', '1.11.0', false], ['major', '1.10.35', '1.10.36', false], ['minor', '1.10.35', '1.11.0', true], ['minor', '1.10.35', '2.0.0', true], ['minor', '1.10.35', '1.10.36', false], ['patch', '1.10.35', '1.10.36', true], ['patch', '1.10.35', '1.11.0', true], ['patch', '1.10.35', '1.10.35', false], ['none', '1.10.35', '2.0.0', false], ['unknown_strategy', '1.10.35', '2.0.0', false], ])('%s: %s -> %s gated=%s', (strategy, versionOldName, versionNewName, expected) => { expect(isBreakingChangeGatedByChannelStrategy({ strategy, versionOldName, versionNewName })).toBe(expected) }) // `version_number` gates on the new bundle's min_update_version vs the device // native version, so a minimum that is not above the previous version still // reaches those devices. it.concurrent.each([ ['1.11.0', true], ['1.10.36', true], ['1.10.35', false], ['1.10.0', false], [null, false], ['not-semver', false], ])('version_number with min_update_version %s gated=%s', (minUpdateVersion, expected) => { expect(isBreakingChangeGatedByChannelStrategy({ strategy: 'version_number', versionOldName: '1.10.35', versionNewName: '1.11.0', minUpdateVersion, })).toBe(expected) }) // Fail closed: without two parseable versions we cannot prove the strategy // gates delivery, so the crash warning wins. it.concurrent('is not gated when a version name is unparseable or missing', () => { expect(isBreakingChangeGatedByChannelStrategy({ strategy: 'minor', versionOldName: 'builtin', versionNewName: '1.11.0' })).toBe(false) expect(isBreakingChangeGatedByChannelStrategy({ strategy: 'minor', versionOldName: '1.10.35', versionNewName: 'nightly' })).toBe(false) expect(isBreakingChangeGatedByChannelStrategy({ strategy: 'minor', versionOldName: undefined, versionNewName: '1.11.0' })).toBe(false) expect(isBreakingChangeGatedByChannelStrategy({ strategy: 'minor', versionOldName: '1.10.35', versionNewName: undefined })).toBe(false) expect(isBreakingChangeGatedByChannelStrategy({ strategy: null, versionOldName: '1.10.35', versionNewName: '2.0.0' })).toBe(false) expect(isBreakingChangeGatedByChannelStrategy({ strategy: 'version_number', versionOldName: 'builtin', versionNewName: '1.11.0', minUpdateVersion: '1.11.0' })).toBe(false) }) }) describe('buildBundleCompatibilityBentoEvent event split', () => { const incident = { ...base, versionOldName: '1.10.35', versionNewName: '1.11.0', channel: 'staging' } it.concurrent.each([ ['minor', null, 'gated'], ['patch', null, 'gated'], ['version_number', '1.11.0', 'gated'], ['version_number', '1.10.0', 'warning'], ['version_number', null, 'warning'], ['major', null, 'warning'], ['none', null, 'warning'], ])('%s strategy (min %s) on a minor bump emits the %s event', (strategy, minUpdateVersion, expectation) => { const r = buildBundleCompatibilityBentoEvent({ ...incident, disableAutoUpdate: strategy, minUpdateVersion }) expect(r).toBeDefined() if (expectation === 'gated') { expect(r!.event).toBe('bundle_incompatible_expected') expect(r!.preferenceKey).toBe('bundle_incompatible_expected') expect(r!.uniqId).toBe('bundle_incompatible_expected:com.demo.app:staging:1.11.0') expect(r!.data).toMatchObject({ disable_auto_update: strategy, gated: true, min_update_version: minUpdateVersion ?? '' }) } else { expect(r!.event).toBe('bundle_incompatible') expect(r!.preferenceKey).toBe('bundle_incompatible') expect(r!.uniqId).toBe('bundle_incompatible:com.demo.app:staging:1.11.0') expect(r!.data).toMatchObject({ disable_auto_update: strategy, gated: false }) } }) it.concurrent('emits the expected event for a patch bump on the patch strategy', () => { const r = buildBundleCompatibilityBentoEvent({ ...base, versionOldName: '1.10.35', versionNewName: '1.10.36', disableAutoUpdate: 'patch' }) expect(r!.event).toBe('bundle_incompatible_expected') }) it.concurrent('emits the warning event for a patch bump on the minor strategy', () => { const r = buildBundleCompatibilityBentoEvent({ ...base, versionOldName: '1.10.35', versionNewName: '1.10.36', disableAutoUpdate: 'minor' }) expect(r!.event).toBe('bundle_incompatible') }) it.concurrent('emits the expected event for a major bump on the major strategy', () => { const r = buildBundleCompatibilityBentoEvent({ ...base, versionOldName: '1.10.35', versionNewName: '2.0.0', disableAutoUpdate: 'major' }) expect(r!.event).toBe('bundle_incompatible_expected') }) it.concurrent('emits the warning event when versions are unparseable even on a gating strategy', () => { const r = buildBundleCompatibilityBentoEvent({ ...base, versionOldName: 'builtin', versionNewName: 'nightly', disableAutoUpdate: 'minor' }) expect(r!.event).toBe('bundle_incompatible') }) it.concurrent('skips both emails when the caller accepted the incompatibility', () => { expect(buildBundleCompatibilityBentoEvent({ ...incident, disableAutoUpdate: 'minor', incompatibilityAccepted: true, })).toBeUndefined() }) it.concurrent('returns undefined for a gating strategy when the channel was not overwritten', () => { expect(buildBundleCompatibilityBentoEvent({ ...incident, disableAutoUpdate: 'minor', channelOverwritten: false })).toBeUndefined() }) })