import withKlaviyoIos from '../plugin/withKlaviyoIos'; import { KlaviyoPluginIosProps } from '../plugin/types'; import { createMockIosConfig, createMockIosProps } from './utils/testHelpers'; describe('withKlaviyoIos', () => { let mockConfig: any; let mockProps: KlaviyoPluginIosProps; beforeEach(() => { jest.clearAllMocks(); mockConfig = createMockIosConfig(); mockProps = createMockIosProps(); }); describe('withRemoteNotificationsPermissions', () => { it('should add klaviyo_app_group and klaviyo_badge_autoclearing to existing Info.plist', () => { const modifiedConfig = withKlaviyoIos(mockConfig, mockProps) as any; expect(modifiedConfig.modResults).toBeDefined(); expect(modifiedConfig.modResults.klaviyo_app_group).toBe('group.com.test.app.KlaviyoNotificationServiceExtension.shared'); expect(modifiedConfig.modResults.klaviyo_badge_autoclearing).toBe(true); }); it('should add flags to Info.plist with badgeAutoclearing set to false', () => { const propsWithBadgeClearingDisabled = createMockIosProps({ badgeAutoclearing: false, }); const modifiedConfig = withKlaviyoIos(mockConfig, propsWithBadgeClearingDisabled) as any; expect(modifiedConfig.modResults).toBeDefined(); expect(modifiedConfig.modResults.klaviyo_app_group).toBe('group.com.test.app.KlaviyoNotificationServiceExtension.shared'); expect(modifiedConfig.modResults.klaviyo_badge_autoclearing).toBe(false); }); it('should add flags to Info.plist with minimal existing content', () => { const minimalConfig = createMockIosConfig({ modResults: { CFBundleIdentifier: 'com.test.app', }, }); const modifiedConfig = withKlaviyoIos(minimalConfig, mockProps) as any; expect(modifiedConfig.modResults).toBeDefined(); expect(modifiedConfig.modResults.klaviyo_app_group).toBe('group.com.test.app.KlaviyoNotificationServiceExtension.shared'); expect(modifiedConfig.modResults.klaviyo_badge_autoclearing).toBe(true); expect(modifiedConfig.modResults.CFBundleIdentifier).toBe('com.test.app'); }); it('should update existing klaviyo flags if they already exist', () => { const configWithExistingFlags = createMockIosConfig({ modResults: { ...mockConfig.modResults, klaviyo_app_group: 'group.existing.app.shared', klaviyo_badge_autoclearing: false, }, }); const modifiedConfig = withKlaviyoIos(configWithExistingFlags, mockProps) as any; expect(modifiedConfig.modResults).toBeDefined(); expect(modifiedConfig.modResults.klaviyo_app_group).toBe('group.com.test.app.KlaviyoNotificationServiceExtension.shared'); expect(modifiedConfig.modResults.klaviyo_badge_autoclearing).toBe(true); }); it('should handle config without ios bundleIdentifier', () => { const configWithoutBundleId = createMockIosConfig({ ios: {}, }); expect(() => { withKlaviyoIos(configWithoutBundleId, mockProps); }).toThrow('iOS bundle identifier is required but not found in app configuration'); }); it('should handle empty modResults', () => { const configWithEmptyResults = createMockIosConfig({ modResults: {}, }); const modifiedConfig = withKlaviyoIos(configWithEmptyResults, mockProps) as any; expect(modifiedConfig.modResults).toBeDefined(); expect(modifiedConfig.modResults.klaviyo_app_group).toBe('group.com.test.app.KlaviyoNotificationServiceExtension.shared'); expect(modifiedConfig.modResults.klaviyo_badge_autoclearing).toBe(true); }); }); describe('withKlaviyoAppGroup', () => { it('should add app group to entitlements when none exist', () => { const configWithEntitlements = createMockIosConfig({ modResults: {}, }); const modifiedConfig = withKlaviyoIos(configWithEntitlements, mockProps); expect(modifiedConfig).toBeDefined(); }); it('should add app group to existing entitlements', () => { const configWithExistingEntitlements = createMockIosConfig({ modResults: { 'com.apple.security.application-groups': ['group.existing.app'], }, }); const modifiedConfig = withKlaviyoIos(configWithExistingEntitlements, mockProps); expect(modifiedConfig).toBeDefined(); }); }); describe('plugin integration', () => { it('should apply all plugins in the correct order', () => { const modifiedConfig = withKlaviyoIos(mockConfig, mockProps) as any; expect(modifiedConfig.modResults).toBeDefined(); expect(modifiedConfig.modResults.klaviyo_app_group).toBe('group.com.test.app.KlaviyoNotificationServiceExtension.shared'); expect(modifiedConfig.modResults.klaviyo_badge_autoclearing).toBe(true); }); it('should handle missing project name gracefully', () => { const configWithoutProjectName = createMockIosConfig({ modRequest: { ...mockConfig.modRequest, projectName: undefined, }, }); const modifiedConfig = withKlaviyoIos(configWithoutProjectName, mockProps) as any; expect(modifiedConfig.modResults).toBeDefined(); expect(modifiedConfig.modResults.klaviyo_app_group).toBe('group.com.test.app.KlaviyoNotificationServiceExtension.shared'); expect(modifiedConfig.modResults.klaviyo_badge_autoclearing).toBe(true); }); }); describe('app group identifier consistency', () => { const expectedAppGroupName = 'group.com.test.app.KlaviyoNotificationServiceExtension.shared'; const nseTargetName = 'KlaviyoNotificationServiceExtension'; it('should use consistent app group name across all configurations', () => { const modifiedConfig = withKlaviyoIos(mockConfig, mockProps) as any; // Verify main app Info.plist has correct app group expect(modifiedConfig.modResults.klaviyo_app_group).toBe(expectedAppGroupName); }); it('should add correct app group to main app entitlements', () => { const configWithEntitlements = createMockIosConfig({ modResults: {}, }); const modifiedConfig = withKlaviyoIos(configWithEntitlements, mockProps); // The withKlaviyoAppGroup plugin should add the correct app group expect(modifiedConfig).toBeDefined(); }); it('should validate app group name format matches expected pattern', () => { // This test validates that the app group name follows the correct format // and includes the NSE target name as expected expect(expectedAppGroupName).toMatch(/^group\.com\.test\.app\.KlaviyoNotificationServiceExtension\.shared$/); expect(expectedAppGroupName).toContain(nseTargetName); expect(expectedAppGroupName).not.toContain('group.com.test.app.shared'); }); it('should ensure plugin generates consistent app group name', () => { // Test that the plugin consistently generates the same app group name const config1 = withKlaviyoIos(mockConfig, mockProps) as any; const config2 = withKlaviyoIos(mockConfig, mockProps) as any; expect(config1.modResults.klaviyo_app_group).toBe(config2.modResults.klaviyo_app_group); expect(config1.modResults.klaviyo_app_group).toBe(expectedAppGroupName); }); it('should validate app group name is not the incorrect short format', () => { // This test ensures we're not using the incorrect short format const incorrectAppGroupName = 'group.com.test.app.shared'; expect(expectedAppGroupName).not.toBe(incorrectAppGroupName); expect(expectedAppGroupName).toContain(nseTargetName); }); it('should handle different bundle identifiers consistently', () => { const testBundleIds = [ 'com.example.app', 'com.example.app.preview', 'com.example.app.development' ]; testBundleIds.forEach(bundleId => { const configWithBundleId = createMockIosConfig({ ios: { bundleIdentifier: bundleId } }); const modifiedConfig = withKlaviyoIos(configWithBundleId, mockProps) as any; // The app group should use the actual bundle identifier from the config const expectedAppGroupForBundle = `group.${bundleId}.KlaviyoNotificationServiceExtension.shared`; expect(modifiedConfig.modResults.klaviyo_app_group).toBe(expectedAppGroupForBundle); }); }); }); describe('version synchronization', () => { describe('main app target Info.plist', () => { it('should set CFBundleShortVersionString from props.marketingVersion', () => { const propsWithVersion = createMockIosProps({ marketingVersion: '0.11.0', projectVersion: '25', }); const modifiedConfig = withKlaviyoIos(mockConfig, propsWithVersion) as any; expect(modifiedConfig.modResults).toBeDefined(); expect(modifiedConfig.modResults.CFBundleShortVersionString).toBe('0.11.0'); }); it('should set CFBundleVersion from props.projectVersion', () => { const propsWithVersion = createMockIosProps({ marketingVersion: '0.11.0', projectVersion: '25', }); const modifiedConfig = withKlaviyoIos(mockConfig, propsWithVersion) as any; expect(modifiedConfig.modResults).toBeDefined(); expect(modifiedConfig.modResults.CFBundleVersion).toBe('25'); }); it('should default CFBundleShortVersionString to "1.0" when marketingVersion is not provided', () => { const propsWithoutVersion = createMockIosProps({ marketingVersion: undefined, projectVersion: '25', }); const modifiedConfig = withKlaviyoIos(mockConfig, propsWithoutVersion) as any; expect(modifiedConfig.modResults).toBeDefined(); expect(modifiedConfig.modResults.CFBundleShortVersionString).toBe('1.0'); }); it('should default CFBundleVersion to "1" when projectVersion is not provided', () => { const propsWithoutVersion = createMockIosProps({ marketingVersion: '0.11.0', projectVersion: undefined, }); const modifiedConfig = withKlaviyoIos(mockConfig, propsWithoutVersion) as any; expect(modifiedConfig.modResults).toBeDefined(); expect(modifiedConfig.modResults.CFBundleVersion).toBe('1'); }); it('should override existing CFBundleShortVersionString in Info.plist', () => { const configWithExistingVersion = createMockIosConfig({ modResults: { CFBundleShortVersionString: '2.0.0', CFBundleVersion: '100', }, }); const propsWithVersion = createMockIosProps({ marketingVersion: '0.11.0', projectVersion: '25', }); const modifiedConfig = withKlaviyoIos(configWithExistingVersion, propsWithVersion) as any; expect(modifiedConfig.modResults.CFBundleShortVersionString).toBe('0.11.0'); expect(modifiedConfig.modResults.CFBundleVersion).toBe('25'); }); }); describe('NSE extension Info.plist', () => { const { FileManager } = require('../plugin/support/fileManager'); const mockInfoPlistContent = ` CFBundleVersion 1 CFBundleShortVersionString 1.0 `; async function runIosMod(config: any, props: any) { const result = withKlaviyoIos(config, props) as any; if (result.mods && result.mods.ios) { return await result.mods.ios(result); } return result; } beforeEach(() => { jest.clearAllMocks(); FileManager.readFile.mockResolvedValue(mockInfoPlistContent); FileManager.writeFile.mockResolvedValue(undefined); FileManager.copyFile.mockResolvedValue(undefined); FileManager.dirExists.mockReturnValue(true); }); it('should update CFBundleShortVersionString in NSE Info.plist from props.marketingVersion', async () => { const propsWithVersion = createMockIosProps({ marketingVersion: '0.11.0', projectVersion: '25', }); await runIosMod(mockConfig, propsWithVersion); expect(FileManager.writeFile).toHaveBeenCalled(); const writeCall = FileManager.writeFile.mock.calls.find(call => call && call[0] && typeof call[0] === 'string' && call[0].includes('KlaviyoNotificationServiceExtension-Info.plist') ); expect(writeCall).toBeDefined(); const writtenContent = writeCall[1]; expect(writtenContent).toContain('CFBundleShortVersionString'); expect(writtenContent).toContain('0.11.0'); }); it('should update CFBundleVersion in NSE Info.plist from props.projectVersion', async () => { const propsWithVersion = createMockIosProps({ marketingVersion: '0.11.0', projectVersion: '25', }); await runIosMod(mockConfig, propsWithVersion); expect(FileManager.writeFile).toHaveBeenCalled(); const writeCall = FileManager.writeFile.mock.calls.find(call => call && call[0] && typeof call[0] === 'string' && call[0].includes('KlaviyoNotificationServiceExtension-Info.plist') ); expect(writeCall).toBeDefined(); const writtenContent = writeCall[1]; expect(writtenContent).toContain('CFBundleVersion'); expect(writtenContent).toContain('25'); }); it('should default CFBundleShortVersionString to "1.0" in NSE when marketingVersion is not provided', async () => { const propsWithoutVersion = createMockIosProps({ marketingVersion: undefined, projectVersion: '25', }); await runIosMod(mockConfig, propsWithoutVersion); expect(FileManager.writeFile).toHaveBeenCalled(); const writeCall = FileManager.writeFile.mock.calls.find(call => call && call[0] && typeof call[0] === 'string' && call[0].includes('KlaviyoNotificationServiceExtension-Info.plist') ); const writtenContent = writeCall[1]; expect(writtenContent).toContain('1.0'); }); it('should default CFBundleVersion to "1" in NSE when projectVersion is not provided', async () => { const propsWithoutVersion = createMockIosProps({ marketingVersion: '0.11.0', projectVersion: undefined, }); await runIosMod(mockConfig, propsWithoutVersion); expect(FileManager.writeFile).toHaveBeenCalled(); const writeCall = FileManager.writeFile.mock.calls.find(call => call && call[0] && typeof call[0] === 'string' && call[0].includes('KlaviyoNotificationServiceExtension-Info.plist') ); const writtenContent = writeCall[1]; expect(writtenContent).toContain('1'); }); it('should update both version fields correctly in NSE Info.plist', async () => { const propsWithVersion = createMockIosProps({ marketingVersion: '2.5.3', projectVersion: '42', }); await runIosMod(mockConfig, propsWithVersion); expect(FileManager.writeFile).toHaveBeenCalled(); const writeCall = FileManager.writeFile.mock.calls.find(call => call && call[0] && typeof call[0] === 'string' && call[0].includes('KlaviyoNotificationServiceExtension-Info.plist') ); const writtenContent = writeCall[1]; expect(writtenContent).toContain('2.5.3'); expect(writtenContent).toContain('42'); expect(writtenContent).not.toContain('1.0'); expect(writtenContent).not.toContain('1'); }); it('should ensure main app and NSE extension have matching versions', async () => { const propsWithVersion = createMockIosProps({ marketingVersion: '0.11.0', projectVersion: '25', }); const modifiedConfig = await runIosMod(mockConfig, propsWithVersion) as any; // Check main app version expect(modifiedConfig.modResults.CFBundleShortVersionString).toBe('0.11.0'); expect(modifiedConfig.modResults.CFBundleVersion).toBe('25'); // Check NSE extension version was written expect(FileManager.writeFile).toHaveBeenCalled(); const writeCall = FileManager.writeFile.mock.calls.find(call => call && call[0] && typeof call[0] === 'string' && call[0].includes('KlaviyoNotificationServiceExtension-Info.plist') ); const writtenContent = writeCall[1]; expect(writtenContent).toContain('0.11.0'); expect(writtenContent).toContain('25'); }); }); }); describe('withGeofencingPodspec', () => { describe('UIBackgroundModes configuration', () => { it('should add location to UIBackgroundModes when geofencingEnabled is true', () => { const propsWithGeofencing = createMockIosProps({ geofencingEnabled: true, }); const modifiedConfig = withKlaviyoIos(mockConfig, propsWithGeofencing) as any; expect(modifiedConfig.modResults).toBeDefined(); expect(modifiedConfig.modResults.UIBackgroundModes).toBeDefined(); expect(Array.isArray(modifiedConfig.modResults.UIBackgroundModes)).toBe(true); expect(modifiedConfig.modResults.UIBackgroundModes).toContain('location'); }); it('should not add location to UIBackgroundModes when geofencingEnabled is false', () => { const propsWithoutGeofencing = createMockIosProps({ geofencingEnabled: false, }); const modifiedConfig = withKlaviyoIos(mockConfig, propsWithoutGeofencing) as any; expect(modifiedConfig.modResults).toBeDefined(); // If UIBackgroundModes exists, it should not contain 'location' if (modifiedConfig.modResults.UIBackgroundModes) { expect(modifiedConfig.modResults.UIBackgroundModes).not.toContain('location'); } }); it('should not add location to UIBackgroundModes when geofencingEnabled is undefined', () => { const propsWithoutGeofencing = createMockIosProps({ geofencingEnabled: undefined, }); const modifiedConfig = withKlaviyoIos(mockConfig, propsWithoutGeofencing) as any; expect(modifiedConfig.modResults).toBeDefined(); // If UIBackgroundModes exists, it should not contain 'location' if (modifiedConfig.modResults.UIBackgroundModes) { expect(modifiedConfig.modResults.UIBackgroundModes).not.toContain('location'); } }); it('should not duplicate location if it already exists in UIBackgroundModes', () => { const configWithLocation = createMockIosConfig({ modResults: { UIBackgroundModes: ['remote-notification', 'location'], }, }); const propsWithGeofencing = createMockIosProps({ geofencingEnabled: true, }); const modifiedConfig = withKlaviyoIos(configWithLocation, propsWithGeofencing) as any; const locationCount = modifiedConfig.modResults.UIBackgroundModes.filter( (mode: string) => mode === 'location' ).length; expect(locationCount).toBe(1); }); }); describe('podspec and Swift file modifications', () => { const { FileManager } = require('../plugin/support/fileManager'); const mockPodspecContent = `require 'json' package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) Pod::Spec.new do |s| s.name = 'ExpoKlaviyo' s.version = package['version'] s.dependency 'KlaviyoSwift' end `; const mockSwiftContent = `import ExpoModulesCore import KlaviyoSwift public final class KlaviyoAppDelegate: ExpoAppDelegateSubscriber, UNUserNotificationCenterDelegate { public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { let center = UNUserNotificationCenter.current() center.delegate = self return true } } `; async function runIosMod(config: any, props: any) { const result = withKlaviyoIos(config, props) as any; if (result.mods && result.mods.ios) { return await result.mods.ios(result); } return result; } beforeEach(() => { jest.clearAllMocks(); FileManager.readFile.mockImplementation((path: string) => { if (path.includes('ExpoKlaviyo.podspec')) { return Promise.resolve(mockPodspecContent); } if (path.includes('KlaviyoAppDelegate.swift')) { return Promise.resolve(mockSwiftContent); } return Promise.resolve(''); }); FileManager.writeFile.mockResolvedValue(undefined); }); it('should inject KlaviyoLocation dependency into podspec when geofencingEnabled is true', async () => { const propsWithGeofencing = createMockIosProps({ geofencingEnabled: true, }); await runIosMod(mockConfig, propsWithGeofencing); // Find the podspec write call const podspecWriteCall = FileManager.writeFile.mock.calls.find(call => call && call[0] && typeof call[0] === 'string' && call[0].includes('ExpoKlaviyo.podspec') ); expect(podspecWriteCall).toBeDefined(); const writtenContent = podspecWriteCall[1]; expect(writtenContent).toContain("s.dependency 'KlaviyoLocation'"); expect(writtenContent).toContain("# KLAVIYO_LOCATION_DEPENDENCY"); }); it('should not inject KlaviyoLocation dependency into podspec when geofencingEnabled is false', async () => { const propsWithoutGeofencing = createMockIosProps({ geofencingEnabled: false, }); await runIosMod(mockConfig, propsWithoutGeofencing); // Find the podspec write call const podspecWriteCall = FileManager.writeFile.mock.calls.find(call => call && call[0] && typeof call[0] === 'string' && call[0].includes('ExpoKlaviyo.podspec') ); expect(podspecWriteCall).toBeDefined(); const writtenContent = podspecWriteCall[1]; expect(writtenContent).not.toContain("s.dependency 'KlaviyoLocation'"); expect(writtenContent).toContain("# KLAVIYO_LOCATION_DEPENDENCY"); }); it('should inject KlaviyoLocation import and registerGeofencing into Swift file when geofencingEnabled is true', async () => { const propsWithGeofencing = createMockIosProps({ geofencingEnabled: true, }); await runIosMod(mockConfig, propsWithGeofencing); // Find the Swift file write call const swiftWriteCall = FileManager.writeFile.mock.calls.find(call => call && call[0] && typeof call[0] === 'string' && call[0].includes('KlaviyoAppDelegate.swift') ); expect(swiftWriteCall).toBeDefined(); const writtenContent = swiftWriteCall[1]; expect(writtenContent).toContain("import KlaviyoLocation"); expect(writtenContent).toContain("// KLAVIYO_GEOFENCING_IMPORT"); expect(writtenContent).toContain("KlaviyoSDK().registerGeofencing()"); expect(writtenContent).toContain("// KLAVIYO_GEOFENCING_REGISTER"); }); it('should not inject KlaviyoLocation import and registerGeofencing into Swift file when geofencingEnabled is false', async () => { const propsWithoutGeofencing = createMockIosProps({ geofencingEnabled: false, }); await runIosMod(mockConfig, propsWithoutGeofencing); // Find the Swift file write call const swiftWriteCall = FileManager.writeFile.mock.calls.find(call => call && call[0] && typeof call[0] === 'string' && call[0].includes('KlaviyoAppDelegate.swift') ); expect(swiftWriteCall).toBeDefined(); const writtenContent = swiftWriteCall[1]; expect(writtenContent).not.toContain("import KlaviyoLocation"); expect(writtenContent).toContain("// KLAVIYO_GEOFENCING_IMPORT"); expect(writtenContent).not.toContain("KlaviyoSDK().registerGeofencing()"); expect(writtenContent).toContain("// KLAVIYO_GEOFENCING_REGISTER"); }); }); }); describe('withKlaviyoPodfile', () => { const { FileManager } = require('../plugin/support/fileManager'); const mockPodfileContent = `platform :ios, '13.0' use_frameworks! :linkage => :static target 'TestApp' do config = use_native_modules! use_react_native!( :path => config[:reactNativePath], :hermes_enabled => true ) end `; async function runIosMod(config: any, props: any) { const result = withKlaviyoIos(config, props) as any; if (result.mods && result.mods.ios) { return await result.mods.ios(result); } return result; } beforeEach(() => { jest.clearAllMocks(); FileManager.readFile.mockResolvedValue(mockPodfileContent); FileManager.writeFile.mockResolvedValue(undefined); FileManager.dirExists.mockReturnValue(true); }); it('should inject KlaviyoNotificationServiceExtension target in Podfile when geofencingEnabled is true', async () => { const propsWithGeofencing = createMockIosProps({ geofencingEnabled: true, }); await runIosMod(mockConfig, propsWithGeofencing); expect(FileManager.readFile).toHaveBeenCalled(); // Check if writeFile was called - it should be called for Podfile const allWriteCalls = FileManager.writeFile.mock.calls; // Find the Podfile write call - the path should end with /Podfile or contain Podfile const writeCall = allWriteCalls.find(call => { if (!call || !call[0]) return false; const filePath = call[0]; return typeof filePath === 'string' && (filePath.includes('Podfile') || filePath.endsWith('Podfile')); }); expect(writeCall).toBeDefined(); expect(writeCall[0]).toContain('Podfile'); const writtenContent = writeCall[1]; expect(writtenContent).toContain("target 'KlaviyoNotificationServiceExtension' do"); expect(writtenContent).toContain("pod 'KlaviyoSwiftExtension'"); }); it('should not inject KlaviyoNotificationServiceExtension target in Podfile when geofencingEnabled is false', async () => { const propsWithoutGeofencing = createMockIosProps({ geofencingEnabled: false, }); await runIosMod(mockConfig, propsWithoutGeofencing); // Find the Podfile write call if it exists const allWriteCalls = FileManager.writeFile.mock.calls; const writeCall = allWriteCalls.find(call => { if (!call || !call[0]) return false; const filePath = call[0]; return typeof filePath === 'string' && (filePath.includes('Podfile') || filePath.endsWith('Podfile')); }); // When geofencing is disabled and the extension wasn't in the Podfile, writeFile should not be called // (since we only write when we need to remove something) if (!writeCall) { // This is expected - no write needed if extension wasn't present expect(FileManager.writeFile).not.toHaveBeenCalledWith( expect.stringContaining('Podfile'), expect.anything() ); } }); }); });