diff --git a/CHANGELOG.md b/CHANGELOG.md index 7138cbd05..b479b8fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [6.6.0-beta3] + +- This release includes fixes for the Anonymous user activation private beta: + - Criteria is now fetched on foregrounding the app by default. This feature can be turned off setting enableForegroundCriteriaFetch flag to false. + - anonymous user ids are only generated once when multiple track calls are made. +- Anonymous user activation is currently in private beta. If you'd like to learn more about it or discuss using it, talk to your Iterable customer success manager (who can also provide detailed documentation). + +## [6.6.0-beta2] + +- This release fixes beta1 release which was released from the wrong branch. + +## [6.6.0-beta1] + +- This release includes initial support for Anonymous user activation, a feature that allows marketers to convert valuable visitors into customers. With this feature, the SDK can: + - Fetch anonymous profile creation criteria from your Iterable project, and then automatically create Iterable user profiles for anonymous users who meet these criteria. + - Save information about a user's previous interactions with your application to their anonymous profile, after it's created. + - Display personalized messages for anonymous users (in-app, push, and embedded messages). + - Merge anonymous profiles into an existing, known user profiles (when needed). +- Anonymous user activation is currently in private beta. If you'd like to learn more about it or discuss using it, talk to your Iterable customer success manager (who can also provide detailed documentation). + ## [Unreleased] - Adding section for unreleased changes diff --git a/Iterable-iOS-AppExtensions.podspec b/Iterable-iOS-AppExtensions.podspec index 8c20ae421..14244f4e3 100644 --- a/Iterable-iOS-AppExtensions.podspec +++ b/Iterable-iOS-AppExtensions.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "Iterable-iOS-AppExtensions" s.module_name = "IterableAppExtensions" - s.version = "6.5.11" + s.version = "6.6.0-beta3" s.summary = "App Extensions for Iterable SDK" s.description = <<-DESC diff --git a/Iterable-iOS-SDK.podspec b/Iterable-iOS-SDK.podspec index 445f79320..a02097315 100644 --- a/Iterable-iOS-SDK.podspec +++ b/Iterable-iOS-SDK.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "Iterable-iOS-SDK" s.module_name = "IterableSDK" - s.version = "6.5.11" + s.version = "6.6.0-beta3" s.summary = "Iterable's official SDK for iOS" s.description = <<-DESC diff --git a/sample-apps/swift-sample-app/swift-sample-app.xcodeproj/project.pbxproj b/sample-apps/swift-sample-app/swift-sample-app.xcodeproj/project.pbxproj index be948faa3..2c9947be0 100644 --- a/sample-apps/swift-sample-app/swift-sample-app.xcodeproj/project.pbxproj +++ b/sample-apps/swift-sample-app/swift-sample-app.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 37088F332B3C38250000B218 /* IterableAppExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 37088F322B3C38250000B218 /* IterableAppExtensions */; }; + 37088F352B3C38250000B218 /* IterableSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 37088F342B3C38250000B218 /* IterableSDK */; }; 551A5FF1251AB1950004C9A0 /* IterableSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 551A5FF0251AB1950004C9A0 /* IterableSDK */; }; 551A5FF3251AB19B0004C9A0 /* IterableAppExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 551A5FF2251AB19B0004C9A0 /* IterableAppExtensions */; }; AC1BDF5820E304BC000010CA /* CoffeeListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5ECD9E20E304000081E1DA /* CoffeeListTableViewController.swift */; }; @@ -79,6 +81,7 @@ buildActionMask = 2147483647; files = ( 551A5FF1251AB1950004C9A0 /* IterableSDK in Frameworks */, + 37088F352B3C38250000B218 /* IterableSDK in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -86,6 +89,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 37088F332B3C38250000B218 /* IterableAppExtensions in Frameworks */, 551A5FF3251AB19B0004C9A0 /* IterableAppExtensions in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -219,6 +223,7 @@ name = "swift-sample-app"; packageProductDependencies = ( 551A5FF0251AB1950004C9A0 /* IterableSDK */, + 37088F342B3C38250000B218 /* IterableSDK */, ); productName = "swift-sample-app"; productReference = ACA3A13520E2F6AF00FEF74F /* swift-sample-app.app */; @@ -239,6 +244,7 @@ name = "swift-sample-app-notification-extension"; packageProductDependencies = ( 551A5FF2251AB19B0004C9A0 /* IterableAppExtensions */, + 37088F322B3C38250000B218 /* IterableAppExtensions */, ); productName = "swift-sample-app-notification-extension"; productReference = ACA3A14E20E2F83D00FEF74F /* swift-sample-app-notification-extension.appex */; @@ -281,6 +287,8 @@ Base, ); mainGroup = ACA3A12C20E2F6AF00FEF74F; + packageReferences = ( + ); productRefGroup = ACA3A13620E2F6AF00FEF74F /* Products */; projectDirPath = ""; projectRoot = ""; @@ -592,6 +600,14 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ + 37088F322B3C38250000B218 /* IterableAppExtensions */ = { + isa = XCSwiftPackageProductDependency; + productName = IterableAppExtensions; + }; + 37088F342B3C38250000B218 /* IterableSDK */ = { + isa = XCSwiftPackageProductDependency; + productName = IterableSDK; + }; 551A5FF0251AB1950004C9A0 /* IterableSDK */ = { isa = XCSwiftPackageProductDependency; productName = IterableSDK; diff --git a/sample-apps/swift-sample-app/swift-sample-app/AppDelegate.swift b/sample-apps/swift-sample-app/swift-sample-app/AppDelegate.swift index b0c80f51a..4cd304d21 100644 --- a/sample-apps/swift-sample-app/swift-sample-app/AppDelegate.swift +++ b/sample-apps/swift-sample-app/swift-sample-app/AppDelegate.swift @@ -12,12 +12,28 @@ import UserNotifications import IterableSDK @UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate { +class AppDelegate: UIResponder, UIApplicationDelegate, IterableAuthDelegate { + func onAuthTokenRequested(completion: @escaping IterableSDK.AuthTokenRetrievalHandler) { + // ITBL: Set your actual secret. + let jwt = IterableTokenGenerator.generateJwtForUserId( + secret: "", + iat: Int(Date().timeIntervalSince1970), + exp: Int(Date().timeIntervalSince1970) + (24*60), + userId: IterableAPI.userId ?? "") + print(jwt) + completion(jwt) + } + + + func onAuthFailure(_ authFailure: IterableSDK.AuthFailure) { + + } + var window: UIWindow? // ITBL: Set your actual api key here. let iterableApiKey = "" - + func application(_: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // ITBL: Setup Notification setupNotifications() @@ -27,7 +43,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { config.customActionDelegate = self config.urlDelegate = self config.inAppDisplayInterval = 1 - + config.anonUserDelegate = self + config.enableAnonTracking = true + config.authDelegate = self IterableAPI.initialize(apiKey: iterableApiKey, launchOptions: launchOptions, config: config) @@ -157,6 +175,12 @@ extension AppDelegate: IterableURLDelegate { } } +extension AppDelegate: IterableAnonUserDelegate { + func onAnonUserCreated(userId: String) { + print("UserId Created from anonsession: \(userId)") + } +} + // MARK: IterableCustomActionDelegate extension AppDelegate: IterableCustomActionDelegate { @@ -171,3 +195,4 @@ extension AppDelegate: IterableCustomActionDelegate { return false } } + diff --git a/sample-apps/swift-sample-app/swift-sample-app/Base.lproj/Main.storyboard b/sample-apps/swift-sample-app/swift-sample-app/Base.lproj/Main.storyboard index c122a01bc..dc743b508 100644 --- a/sample-apps/swift-sample-app/swift-sample-app/Base.lproj/Main.storyboard +++ b/sample-apps/swift-sample-app/swift-sample-app/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -19,7 +19,7 @@ - + @@ -35,6 +35,14 @@ + + + + + + + + diff --git a/sample-apps/swift-sample-app/swift-sample-app/CoffeeListTableViewController.swift b/sample-apps/swift-sample-app/swift-sample-app/CoffeeListTableViewController.swift index 50eb7e2d1..0164b8bca 100644 --- a/sample-apps/swift-sample-app/swift-sample-app/CoffeeListTableViewController.swift +++ b/sample-apps/swift-sample-app/swift-sample-app/CoffeeListTableViewController.swift @@ -60,22 +60,42 @@ class CoffeeListTableViewController: UITableViewController { } // MARK: - TableViewDataSourceDelegate Functions - - override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { - filtering ? filteredCoffees.count : coffees.count + override func numberOfSections(in tableView: UITableView) -> Int { + return 2 + } + override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { + if section == 0 { + return 1 + } else { + return filtering ? filteredCoffees.count : coffees.count + } + } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "coffeeCell", for: indexPath) - - let coffeeList = filtering ? filteredCoffees : coffees - let coffee = coffeeList[indexPath.row] - cell.textLabel?.text = coffee.name - cell.imageView?.image = coffee.image - - return cell + if indexPath.section == 0 { + let cell = tableView.dequeueReusableCell(withIdentifier: "anonymousUsageTrackCell", for: indexPath) + cell.textLabel?.text = IterableAPI.getAnonymousUsageTracked() ? "Tap to disable Anonymous Usage Track" : "Tap to enable Anonymous Usage Track" + cell.textLabel?.numberOfLines = 0 + cell.accessoryType = IterableAPI.getAnonymousUsageTracked() ? .checkmark : .none + return cell + } else { + let cell = tableView.dequeueReusableCell(withIdentifier: "coffeeCell", for: indexPath) + let coffeeList = filtering ? filteredCoffees : coffees + let coffee = coffeeList[indexPath.row] + cell.textLabel?.text = coffee.name + cell.imageView?.image = coffee.image + return cell + } } - + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if indexPath.section == 0 { + let permissionToTrack = IterableAPI.getAnonymousUsageTracked() + IterableAPI.setAnonymousUsageTracked(isAnonymousUsageTracked: !permissionToTrack) + self.tableView.reloadData() + } + } + // MARK: Tap Handlers @IBAction func loginOutBarButtonTapped(_: UIBarButtonItem) { @@ -93,7 +113,7 @@ class CoffeeListTableViewController: UITableViewController { // MARK: - Navigation override func prepare(for segue: UIStoryboardSegue, sender _: Any?) { - guard let indexPath = tableView.indexPathForSelectedRow else { + guard let indexPath = tableView.indexPathForSelectedRow, indexPath.section == 1 else { return } diff --git a/swift-sdk.xcodeproj/project.pbxproj b/swift-sdk.xcodeproj/project.pbxproj index 3f7dea558..bd9d9389a 100644 --- a/swift-sdk.xcodeproj/project.pbxproj +++ b/swift-sdk.xcodeproj/project.pbxproj @@ -12,6 +12,19 @@ 00B6FAD1210E8D90007535CF /* dev-1.mobileprovision in Resources */ = {isa = PBXBuildFile; fileRef = 00B6FAD0210E8D90007535CF /* dev-1.mobileprovision */; }; 00CB31B621096129004ACDEC /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00CB31B4210960C4004ACDEC /* TestUtils.swift */; }; 092D01942D3038F600E3066A /* NotificationObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 092D01932D3038F600E3066A /* NotificationObserverTests.swift */; }; + 09CAA47B2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09CAA47A2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift */; }; + 1802C00F2CA2C99E009DEA2B /* CombinationComplexCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1802C00E2CA2C99E009DEA2B /* CombinationComplexCriteria.swift */; }; + 181063DB2C9841460078E0ED /* CustomEventUserUpdateTestCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 181063DA2C9841460078E0ED /* CustomEventUserUpdateTestCaseTests.swift */; }; + 181063DD2C994FA40078E0ED /* ValidateCustomEventUserUpdateAPITest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 181063DC2C994FA40078E0ED /* ValidateCustomEventUserUpdateAPITest.swift */; }; + 181063DF2C9D51000078E0ED /* ValidateStoredEventCheckUnknownToKnownUserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 181063DE2C9D51000078E0ED /* ValidateStoredEventCheckUnknownToKnownUserTest.swift */; }; + 182A2A152C661C9A002FF058 /* DataTypeComparatorSearchQueryCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182A2A142C661C9A002FF058 /* DataTypeComparatorSearchQueryCriteria.swift */; }; + 1881A21B2C7602F80020C64D /* ComparatorDataTypeWithArrayInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1881A21A2C7602F80020C64D /* ComparatorDataTypeWithArrayInput.swift */; }; + 18A3520A2C7DC51C007FED53 /* NestedFieldSupportForArrayData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A352092C7DC51C007FED53 /* NestedFieldSupportForArrayData.swift */; }; + 18A3520C2C85BAF0007FED53 /* IsOneOfInNotOneOfCriteareaTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A3520B2C85BAF0007FED53 /* IsOneOfInNotOneOfCriteareaTest.swift */; }; + 18BB8B7A2C64DC8D007EBF23 /* ComparatorTypeDoesNotEqualMatchTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18BB8B792C64DC8D007EBF23 /* ComparatorTypeDoesNotEqualMatchTest.swift */; }; + 18E23AE02C6CDE97002B2D92 /* CombinationLogicEventTypeCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E23ADF2C6CDE97002B2D92 /* CombinationLogicEventTypeCriteria.swift */; }; + 18E5B5D12CC77BCE00A558EC /* IterableTokenGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E5B5D02CC77BCE00A558EC /* IterableTokenGenerator.swift */; }; + 18E5B5D32CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E5B5D22CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift */; }; 1CBFFE1A2A97AEEF00ED57EE /* EmbeddedManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CBFFE162A97AEEE00ED57EE /* EmbeddedManagerTests.swift */; }; 1CBFFE1B2A97AEEF00ED57EE /* EmbeddedMessagingProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CBFFE172A97AEEE00ED57EE /* EmbeddedMessagingProcessorTests.swift */; }; 1CBFFE1C2A97AEEF00ED57EE /* EmbeddedSessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CBFFE182A97AEEE00ED57EE /* EmbeddedSessionManagerTests.swift */; }; @@ -162,6 +175,7 @@ 5B5AA716284F1A6D0093FED4 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5AA710284F1A6D0093FED4 /* MockNetworkSession.swift */; }; 5B5AA717284F1A6D0093FED4 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5AA710284F1A6D0093FED4 /* MockNetworkSession.swift */; }; 5B6C3C1127CE871F00B9A753 /* NavInboxSessionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6C3C1027CE871F00B9A753 /* NavInboxSessionUITests.swift */; }; + 5B88BC482805D09D004016E5 /* (null) in Sources */ = {isa = PBXBuildFile; }; 8AAA8BA92D07310600DF8220 /* IterableSDK.h in Headers */ = {isa = PBXBuildFile; fileRef = 8AAA8B6C2D07310600DF8220 /* IterableSDK.h */; }; 8AAA8BAB2D07310600DF8220 /* IterableAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAA8B1F2D07310600DF8220 /* IterableAction.swift */; }; 8AAA8BB12D07310600DF8220 /* IterableActionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAA8B202D07310600DF8220 /* IterableActionContext.swift */; }; @@ -284,6 +298,7 @@ 8AAA8CDF2D074C2000DF8220 /* APNSTypeChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAA8C532D074C2000DF8220 /* APNSTypeChecker.swift */; }; 8AAA8CE02D074C2000DF8220 /* Auth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAA8C552D074C2000DF8220 /* Auth.swift */; }; 8AB8D7D22D3805A900DECFE5 /* IterableAPIMobileFrameworkDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB8D7D12D3805A900DECFE5 /* IterableAPIMobileFrameworkDetector.swift */; }; + 9F0616412C9CA9D400FE2E6A /* IterableIdentityResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0616402C9CA9D200FE2E6A /* IterableIdentityResolution.swift */; }; 9FF05EAC2AFEA5FA005311F7 /* MockAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF05EAB2AFEA5FA005311F7 /* MockAuthManager.swift */; }; 9FF05EAD2AFEA5FA005311F7 /* MockAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF05EAB2AFEA5FA005311F7 /* MockAuthManager.swift */; }; 9FF05EAE2AFEA5FA005311F7 /* MockAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF05EAB2AFEA5FA005311F7 /* MockAuthManager.swift */; }; @@ -408,9 +423,24 @@ ACFF42B02465B4AE00FDF10D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACFF42AF2465B4AE00FDF10D /* AppDelegate.swift */; }; BA2BB8192BADD5A500EA0229 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = BA2BB8182BADD5A500EA0229 /* PrivacyInfo.xcprivacy */; }; BA2BB81A2BADD5A500EA0229 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = BA2BB8182BADD5A500EA0229 /* PrivacyInfo.xcprivacy */; }; + DF7302152C2C176E0002633A /* AnonymousUserComplexCriteriaMatchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF7302142C2C176E0002633A /* AnonymousUserComplexCriteriaMatchTests.swift */; }; + DF97D12B2C2D4A060034D38C /* AnonymousUserCriteriaIsSetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF97D12A2C2D4A060034D38C /* AnonymousUserCriteriaIsSetTests.swift */; }; + DFFD62392C3681B900010883 /* UserMergeScenariosTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFFD62382C3681B900010883 /* UserMergeScenariosTests.swift */; }; + E9EA7C9F2C1EDE5800A9D6FB /* AnonymousUserManager+Functions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EA7C9B2C1EDE5800A9D6FB /* AnonymousUserManager+Functions.swift */; }; + E9EA7CA02C1EDE5800A9D6FB /* AnonymousUserMerge.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EA7C9C2C1EDE5800A9D6FB /* AnonymousUserMerge.swift */; }; + E9EA7CA12C1EDE5800A9D6FB /* AnonymousUserManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EA7C9D2C1EDE5800A9D6FB /* AnonymousUserManagerProtocol.swift */; }; + E9EA7CA22C1EDE5800A9D6FB /* AnonymousUserManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EA7C9E2C1EDE5800A9D6FB /* AnonymousUserManager.swift */; }; + E9EA7CA82C1EE3BA00A9D6FB /* AnonymousUserCriteriaMatchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9EA7CA62C1EE3BA00A9D6FB /* AnonymousUserCriteriaMatchTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 373268002B4D51B200CC82C9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AC2263D620CF49B8009800EB /* Project object */; + proxyType = 1; + remoteGlobalIDString = AC2263DE20CF49B8009800EB; + remoteInfo = "swift-sdk"; + }; 5B38881D27FAE6DB00482BE7 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = AC2263D620CF49B8009800EB /* Project object */; @@ -546,6 +576,19 @@ 00B6FAD0210E8D90007535CF /* dev-1.mobileprovision */ = {isa = PBXFileReference; lastKnownFileType = file; path = "dev-1.mobileprovision"; sourceTree = ""; }; 00CB31B4210960C4004ACDEC /* TestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtils.swift; sourceTree = ""; }; 092D01932D3038F600E3066A /* NotificationObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationObserverTests.swift; sourceTree = ""; }; + 09CAA47A2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IterableApiCriteriaFetchTests.swift; sourceTree = ""; }; + 1802C00E2CA2C99E009DEA2B /* CombinationComplexCriteria.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinationComplexCriteria.swift; sourceTree = ""; }; + 181063DA2C9841460078E0ED /* CustomEventUserUpdateTestCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEventUserUpdateTestCaseTests.swift; sourceTree = ""; }; + 181063DC2C994FA40078E0ED /* ValidateCustomEventUserUpdateAPITest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateCustomEventUserUpdateAPITest.swift; sourceTree = ""; }; + 181063DE2C9D51000078E0ED /* ValidateStoredEventCheckUnknownToKnownUserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateStoredEventCheckUnknownToKnownUserTest.swift; sourceTree = ""; }; + 182A2A142C661C9A002FF058 /* DataTypeComparatorSearchQueryCriteria.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataTypeComparatorSearchQueryCriteria.swift; sourceTree = ""; }; + 1881A21A2C7602F80020C64D /* ComparatorDataTypeWithArrayInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComparatorDataTypeWithArrayInput.swift; sourceTree = ""; }; + 18A352092C7DC51C007FED53 /* NestedFieldSupportForArrayData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NestedFieldSupportForArrayData.swift; sourceTree = ""; }; + 18A3520B2C85BAF0007FED53 /* IsOneOfInNotOneOfCriteareaTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsOneOfInNotOneOfCriteareaTest.swift; sourceTree = ""; }; + 18BB8B792C64DC8D007EBF23 /* ComparatorTypeDoesNotEqualMatchTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComparatorTypeDoesNotEqualMatchTest.swift; sourceTree = ""; }; + 18E23ADF2C6CDE97002B2D92 /* CombinationLogicEventTypeCriteria.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CombinationLogicEventTypeCriteria.swift; sourceTree = ""; }; + 18E5B5D02CC77BCE00A558EC /* IterableTokenGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IterableTokenGenerator.swift; sourceTree = ""; }; + 18E5B5D22CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateTokenForDestinationUserTest.swift; sourceTree = ""; }; 1CBFFE162A97AEEE00ED57EE /* EmbeddedManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedManagerTests.swift; sourceTree = ""; }; 1CBFFE172A97AEEE00ED57EE /* EmbeddedMessagingProcessorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedMessagingProcessorTests.swift; sourceTree = ""; }; 1CBFFE182A97AEEE00ED57EE /* EmbeddedSessionManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedSessionManagerTests.swift; sourceTree = ""; }; @@ -710,6 +753,7 @@ 8AAA8C832D074C2000DF8220 /* RequestProcessorUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestProcessorUtil.swift; sourceTree = ""; }; 8AAA8C842D074C2000DF8220 /* RequestSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestSender.swift; sourceTree = ""; }; 8AB8D7D12D3805A900DECFE5 /* IterableAPIMobileFrameworkDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IterableAPIMobileFrameworkDetector.swift; sourceTree = ""; }; + 9F0616402C9CA9D200FE2E6A /* IterableIdentityResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IterableIdentityResolution.swift; sourceTree = ""; }; 9FF05EAB2AFEA5FA005311F7 /* MockAuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAuthManager.swift; sourceTree = ""; }; AC02CAA5234E50B5006617E0 /* RegistrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationTests.swift; sourceTree = ""; }; AC05644A26387B54001FB810 /* MockPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPersistence.swift; sourceTree = ""; }; @@ -816,9 +860,24 @@ ACFF42AE24656ECF00FDF10D /* ui-tests-app.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "ui-tests-app.entitlements"; sourceTree = ""; }; ACFF42AF2465B4AE00FDF10D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; BA2BB8182BADD5A500EA0229 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + DF7302142C2C176E0002633A /* AnonymousUserComplexCriteriaMatchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnonymousUserComplexCriteriaMatchTests.swift; sourceTree = ""; }; + DF97D12A2C2D4A060034D38C /* AnonymousUserCriteriaIsSetTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnonymousUserCriteriaIsSetTests.swift; sourceTree = ""; }; + DFFD62382C3681B900010883 /* UserMergeScenariosTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMergeScenariosTests.swift; sourceTree = ""; }; + E9EA7C9B2C1EDE5800A9D6FB /* AnonymousUserManager+Functions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AnonymousUserManager+Functions.swift"; sourceTree = ""; }; + E9EA7C9C2C1EDE5800A9D6FB /* AnonymousUserMerge.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnonymousUserMerge.swift; sourceTree = ""; }; + E9EA7C9D2C1EDE5800A9D6FB /* AnonymousUserManagerProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnonymousUserManagerProtocol.swift; sourceTree = ""; }; + E9EA7C9E2C1EDE5800A9D6FB /* AnonymousUserManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnonymousUserManager.swift; sourceTree = ""; }; + E9EA7CA62C1EE3BA00A9D6FB /* AnonymousUserCriteriaMatchTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnonymousUserCriteriaMatchTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 373267F82B4D51B200CC82C9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; AC2263DB20CF49B8009800EB /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -917,6 +976,13 @@ name = "Test Files"; sourceTree = ""; }; + 095D04D92D394DA100B23572 /* Recovered References */ = { + isa = PBXGroup; + children = ( + ); + name = "Recovered References"; + sourceTree = ""; + }; 1CBFFE152A97AEDC00ED57EE /* embedded-messaging-tests */ = { isa = PBXGroup; children = ( @@ -1226,6 +1292,7 @@ AC90C4C520D8632E00EECA5D /* notification-extension */, ACFCA72920EB02DB00BFB277 /* tests */, 5550F22324217CFC0014456A /* misc */, + 095D04D92D394DA100B23572 /* Recovered References */, ); sourceTree = ""; }; @@ -1251,6 +1318,8 @@ AC2263E120CF49B8009800EB /* swift-sdk */ = { isa = PBXGroup; children = ( + AC72A0BB20CF4C8C004D7997 /* Internal */, + 18E5B5D02CC77BCE00A558EC /* IterableTokenGenerator.swift */, 8AAA8B6C2D07310600DF8220 /* IterableSDK.h */, 8AAA8B312D07310600DF8220 /* core */, 8AAA8C852D074C2000DF8220 /* internal */, @@ -1358,9 +1427,42 @@ name = "local-storage-tests"; sourceTree = ""; }; + AC5E888724E1B7AD00752321 /* Request Processing */ = { + isa = PBXGroup; + children = ( + ); + name = "Request Processing"; + sourceTree = ""; + }; + AC72A0AC20CF4C08004D7997 /* Util */ = { + isa = PBXGroup; + children = ( + ); + name = Util; + sourceTree = ""; + }; + AC72A0BB20CF4C8C004D7997 /* Internal */ = { + isa = PBXGroup; + children = ( + E9EA7C9A2C1EDE4400A9D6FB /* AnonymousTracking */, + AC7A525F227BB9B80064D67E /* Initialization */, + AC5E888724E1B7AD00752321 /* Request Processing */, + AC72A0AC20CF4C08004D7997 /* Util */, + ); + path = Internal; + sourceTree = ""; + }; + AC7A525F227BB9B80064D67E /* Initialization */ = { + isa = PBXGroup; + children = ( + ); + name = Initialization; + sourceTree = ""; + }; AC7B142C20D02CE200877BFE /* unit-tests */ = { isa = PBXGroup; children = ( + E9EA7CA52C1EE39A00A9D6FB /* anonymous-tracking-tests */, 1CBFFE152A97AEDC00ED57EE /* embedded-messaging-tests */, 552A0AAA280E24E400A80963 /* api-tests */, AC3A3029262EE04400425435 /* deep-linking-tests */, @@ -1590,6 +1692,41 @@ path = ../..; sourceTree = ""; }; + E9EA7C9A2C1EDE4400A9D6FB /* AnonymousTracking */ = { + isa = PBXGroup; + children = ( + 9F0616402C9CA9D200FE2E6A /* IterableIdentityResolution.swift */, + E9EA7C9E2C1EDE5800A9D6FB /* AnonymousUserManager.swift */, + E9EA7C9B2C1EDE5800A9D6FB /* AnonymousUserManager+Functions.swift */, + E9EA7C9D2C1EDE5800A9D6FB /* AnonymousUserManagerProtocol.swift */, + E9EA7C9C2C1EDE5800A9D6FB /* AnonymousUserMerge.swift */, + ); + name = AnonymousTracking; + sourceTree = ""; + }; + E9EA7CA52C1EE39A00A9D6FB /* anonymous-tracking-tests */ = { + isa = PBXGroup; + children = ( + 09CAA47A2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift */, + E9EA7CA62C1EE3BA00A9D6FB /* AnonymousUserCriteriaMatchTests.swift */, + DF97D12A2C2D4A060034D38C /* AnonymousUserCriteriaIsSetTests.swift */, + DF7302142C2C176E0002633A /* AnonymousUserComplexCriteriaMatchTests.swift */, + DFFD62382C3681B900010883 /* UserMergeScenariosTests.swift */, + 182A2A142C661C9A002FF058 /* DataTypeComparatorSearchQueryCriteria.swift */, + 18BB8B792C64DC8D007EBF23 /* ComparatorTypeDoesNotEqualMatchTest.swift */, + 1881A21A2C7602F80020C64D /* ComparatorDataTypeWithArrayInput.swift */, + 18E23ADF2C6CDE97002B2D92 /* CombinationLogicEventTypeCriteria.swift */, + 18A352092C7DC51C007FED53 /* NestedFieldSupportForArrayData.swift */, + 18A3520B2C85BAF0007FED53 /* IsOneOfInNotOneOfCriteareaTest.swift */, + 181063DA2C9841460078E0ED /* CustomEventUserUpdateTestCaseTests.swift */, + 181063DC2C994FA40078E0ED /* ValidateCustomEventUserUpdateAPITest.swift */, + 181063DE2C9D51000078E0ED /* ValidateStoredEventCheckUnknownToKnownUserTest.swift */, + 1802C00E2CA2C99E009DEA2B /* CombinationComplexCriteria.swift */, + 18E5B5D22CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift */, + ); + name = "anonymous-tracking-tests"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1612,6 +1749,23 @@ /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ + 373267FA2B4D51B200CC82C9 /* AnonymousUserMerge */ = { + isa = PBXNativeTarget; + buildConfigurationList = 373268042B4D51B200CC82C9 /* Build configuration list for PBXNativeTarget "AnonymousUserMerge" */; + buildPhases = ( + 373267F72B4D51B200CC82C9 /* Sources */, + 373267F82B4D51B200CC82C9 /* Frameworks */, + 373267F92B4D51B200CC82C9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 373268012B4D51B200CC82C9 /* PBXTargetDependency */, + ); + name = AnonymousUserMerge; + productName = AnonymousUserMerge; + productType = "com.apple.product-type.bundle.unit-test"; + }; AC2263DE20CF49B8009800EB /* swift-sdk */ = { isa = PBXNativeTarget; buildConfigurationList = AC2263F320CF49B8009800EB /* Build configuration list for PBXNativeTarget "swift-sdk" */; @@ -1831,6 +1985,9 @@ LastUpgradeCheck = 1420; ORGANIZATIONNAME = Iterable; TargetAttributes = { + 373267FA2B4D51B200CC82C9 = { + CreatedOnToolsVersion = 15.1; + }; AC2263DE20CF49B8009800EB = { CreatedOnToolsVersion = 9.4; }; @@ -1907,11 +2064,19 @@ ACDA976B23159C39004C412E /* inbox-ui-tests */, AC28480624AA44C600C1FC7F /* endpoint-tests */, ACFD5AB724C8200C008E497A /* offline-events-tests */, + 373267FA2B4D51B200CC82C9 /* AnonymousUserMerge */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 373267F92B4D51B200CC82C9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; AC2263DD20CF49B8009800EB /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2014,6 +2179,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 373267F72B4D51B200CC82C9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; AC2263DA20CF49B8009800EB /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2138,6 +2310,13 @@ 8AAA8C1B2D07310600DF8220 /* IterableConfig.swift in Sources */, 8AAA8C1C2D07310600DF8220 /* IterableInboxCell.swift in Sources */, AC50865424C60172001DC132 /* IterableDataModel.xcdatamodeld in Sources */, + 5B88BC482805D09D004016E5 /* (null) in Sources */, + 9F0616412C9CA9D400FE2E6A /* IterableIdentityResolution.swift in Sources */, + 18E5B5D12CC77BCE00A558EC /* IterableTokenGenerator.swift in Sources */, + E9EA7C9F2C1EDE5800A9D6FB /* AnonymousUserManager+Functions.swift in Sources */, + E9EA7CA22C1EDE5800A9D6FB /* AnonymousUserManager.swift in Sources */, + E9EA7CA02C1EDE5800A9D6FB /* AnonymousUserMerge.swift in Sources */, + E9EA7CA12C1EDE5800A9D6FB /* AnonymousUserManagerProtocol.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2185,23 +2364,31 @@ 55AEA95925F05B7D00B38CED /* InAppMessageProcessorTests.swift in Sources */, ACC362B824D17005002C67BA /* IterableRequestTests.swift in Sources */, AC2C668720D3435700D46CC9 /* ActionRunnerTests.swift in Sources */, + 181063DB2C9841460078E0ED /* CustomEventUserUpdateTestCaseTests.swift in Sources */, 00CB31B621096129004ACDEC /* TestUtils.swift in Sources */, AC89661E2124FBCE0051A6CD /* AutoRegistrationTests.swift in Sources */, 9FF05EAF2AFEA5FA005311F7 /* MockAuthManager.swift in Sources */, ACA8D1A921965B7D001B1332 /* InAppTests.swift in Sources */, 5588DFB928C045E3000697D7 /* MockInAppDelegate.swift in Sources */, 5588DFD128C0465E000697D7 /* MockAPNSTypeChecker.swift in Sources */, + DF7302152C2C176E0002633A /* AnonymousUserComplexCriteriaMatchTests.swift in Sources */, + 18BB8B7A2C64DC8D007EBF23 /* ComparatorTypeDoesNotEqualMatchTest.swift in Sources */, 00B6FACC210E8484007535CF /* APNSTypeCheckerTests.swift in Sources */, + 09CAA47B2D4B9AD80057FB72 /* IterableApiCriteriaFetchTests.swift in Sources */, AC8F35A2239806B500302994 /* InboxViewControllerViewModelTests.swift in Sources */, 092D01942D3038F600E3066A /* NotificationObserverTests.swift in Sources */, AC995F9A2166EEB50099A184 /* CommonMocks.swift in Sources */, + E9EA7CA82C1EE3BA00A9D6FB /* AnonymousUserCriteriaMatchTests.swift in Sources */, 5588DFE128C046B7000697D7 /* MockLocalStorage.swift in Sources */, + 18A3520A2C7DC51C007FED53 /* NestedFieldSupportForArrayData.swift in Sources */, 1CBFFE1B2A97AEEF00ED57EE /* EmbeddedMessagingProcessorTests.swift in Sources */, 5588DF8128C04494000697D7 /* MockUrlDelegate.swift in Sources */, 5585DF8F22A73390000A32B9 /* IterableInboxViewControllerTests.swift in Sources */, + 18A3520C2C85BAF0007FED53 /* IsOneOfInNotOneOfCriteareaTest.swift in Sources */, 55B9F15124B3D33700E8198A /* AuthTests.swift in Sources */, 55B06F3829D5102800C3B1BC /* BlankApiClient.swift in Sources */, 5588DFE928C046D7000697D7 /* MockInboxState.swift in Sources */, + 181063DF2C9D51000078E0ED /* ValidateStoredEventCheckUnknownToKnownUserTest.swift in Sources */, ACED4C01213F50B30055A497 /* LoggingTests.swift in Sources */, AC52C5B8272A8B32000DCDCF /* KeychainWrapperTests.swift in Sources */, ACC3FD9E2536D7A30004A2E0 /* InAppFilePersistenceTests.swift in Sources */, @@ -2210,10 +2397,14 @@ 5588DFA928C045AE000697D7 /* MockInAppFetcher.swift in Sources */, 55CC257B2462064F00A77FD5 /* InAppPresenterTests.swift in Sources */, AC4BA00224163D8F007359F1 /* IterableHtmlMessageViewControllerTests.swift in Sources */, + 18E5B5D32CC7853D00A558EC /* ValidateTokenForDestinationUserTest.swift in Sources */, 55B37FC822975A840042F13A /* InboxMessageViewModelTests.swift in Sources */, + 182A2A152C661C9A002FF058 /* DataTypeComparatorSearchQueryCriteria.swift in Sources */, 55E6F462238E066400808BCE /* DeepLinkTests.swift in Sources */, + 18E23AE02C6CDE97002B2D92 /* CombinationLogicEventTypeCriteria.swift in Sources */, 55B37FC1229620D20042F13A /* CommerceItemTests.swift in Sources */, 5588DFC128C0460E000697D7 /* MockNotificationCenter.swift in Sources */, + DFFD62392C3681B900010883 /* UserMergeScenariosTests.swift in Sources */, 5588DFC928C04642000697D7 /* MockInAppPersister.swift in Sources */, AC776DA4211A17C700C27C27 /* IterableRequestUtilTests.swift in Sources */, ACAA816E231163660035C743 /* RequestCreatorTests.swift in Sources */, @@ -2225,6 +2416,7 @@ 5536781F2576FF9000DB3652 /* IterableUtilTests.swift in Sources */, AC2C668020D31B1F00D46CC9 /* NotificationResponseTests.swift in Sources */, 55E02D39253F8D86009DB8BC /* WebViewProtocolTests.swift in Sources */, + 1881A21B2C7602F80020C64D /* ComparatorDataTypeWithArrayInput.swift in Sources */, AC750A4A234CD67900561902 /* InAppHelperTests.swift in Sources */, AC87172621A4E47E00FEA369 /* TestInAppPayloadGenerator.swift in Sources */, AC1670CD2230A91C00989F8E /* InboxTests.swift in Sources */, @@ -2234,16 +2426,19 @@ AC64626B2140AACF0046E1BD /* IterableAPIResponseTests.swift in Sources */, 1CBFFE1D2A97AEEF00ED57EE /* EmbeddedMessagingSerializationTests.swift in Sources */, 5588DFB128C045C9000697D7 /* MockInAppDisplayer.swift in Sources */, + DF97D12B2C2D4A060034D38C /* AnonymousUserCriteriaIsSetTests.swift in Sources */, 55B37FC6229752DD0042F13A /* OrderedDictionaryTests.swift in Sources */, 1CBFFE1C2A97AEEF00ED57EE /* EmbeddedSessionManagerTests.swift in Sources */, 5588DFD928C04683000697D7 /* MockWebView.swift in Sources */, 55B5498423973B5C00243E87 /* InboxSessionManagerTests.swift in Sources */, ACB37AB0240268A60093A8EA /* SampleInboxViewDelegateImplementations.swift in Sources */, 5588DF7928C04463000697D7 /* MockNotificationResponse.swift in Sources */, + 181063DD2C994FA40078E0ED /* ValidateCustomEventUserUpdateAPITest.swift in Sources */, AC3A2FF0262EDD4C00425435 /* InAppPriorityTests.swift in Sources */, ACD6116E21080564003E7F6B /* IterableAPITests.swift in Sources */, 5588DF8928C044BE000697D7 /* MockCustomActionDelegate.swift in Sources */, AC02CAA6234E50B5006617E0 /* RegistrationTests.swift in Sources */, + 1802C00F2CA2C99E009DEA2B /* CombinationComplexCriteria.swift in Sources */, 5588DFA128C04570000697D7 /* MockApplicationStateProvider.swift in Sources */, 5588DFF128C046FF000697D7 /* MockMessageViewControllerEventTracker.swift in Sources */, ACEDF41F2183C436000B9BFE /* PendingTests.swift in Sources */, @@ -2461,6 +2656,12 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 373268012B4D51B200CC82C9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = AC2263DE20CF49B8009800EB /* swift-sdk */; + targetProxy = 373268002B4D51B200CC82C9 /* PBXContainerItemProxy */; + }; 5B38881E27FAE6DB00482BE7 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = ACF560D220E443BF000AAC23 /* host-app */; @@ -2580,6 +2781,58 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 373268022B4D51B200CC82C9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.2; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = AnonymousUserMergeTests.AnonymousUserMerge; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 373268032B4D51B200CC82C9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.2; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = AnonymousUserMergeTests.AnonymousUserMerge; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; AC2263F120CF49B8009800EB /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3219,6 +3472,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 373268042B4D51B200CC82C9 /* Build configuration list for PBXNativeTarget "AnonymousUserMerge" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 373268022B4D51B200CC82C9 /* Debug */, + 373268032B4D51B200CC82C9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; AC2263D920CF49B8009800EB /* Build configuration list for PBXProject "swift-sdk" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/swift-sdk.xcodeproj/xcshareddata/xcschemes/swift-sdk.xcscheme b/swift-sdk.xcodeproj/xcshareddata/xcschemes/swift-sdk.xcscheme index a17141305..f990bb92a 100644 --- a/swift-sdk.xcodeproj/xcshareddata/xcschemes/swift-sdk.xcscheme +++ b/swift-sdk.xcodeproj/xcshareddata/xcschemes/swift-sdk.xcscheme @@ -80,8 +80,8 @@ diff --git a/swift-sdk/Core/Constants.swift b/swift-sdk/Core/Constants.swift index 65333bae9..afd7dc221 100644 --- a/swift-sdk/Core/Constants.swift +++ b/swift-sdk/Core/Constants.swift @@ -10,12 +10,23 @@ enum Endpoint { static let api = Endpoint.apiHostName + Const.apiPath } +enum EventType { + static let customEvent = "customEvent" + static let purchase = "purchase" + static let updateUser = "user" + static let updateCart = "updateCart" + static let anonSession = "anonSession" + static let tokenRegistration = "tokenRegistration" + static let trackEvent = "trackEvent" +} + enum Const { static let apiPath = "/api/" static let deepLinkRegex = "/a/[a-zA-Z0-9]+" static let href = "href" static let exponentialFactor = 2.0 + static let criteriaFetchingCooldown = 120000.0 // 120 seconds = 120,000 milliseconds enum Http { static let GET = "GET" @@ -40,6 +51,9 @@ enum Const { static let updateEmail = "users/updateEmail" static let updateSubscriptions = "users/updateSubscriptions" static let getRemoteConfiguration = "mobile/getRemoteConfiguration" + static let mergeUser = "users/merge"; + static let getCriteria = "anonymoususer/list"; + static let trackAnonSession = "anonymoususer/events/session"; static let getEmbeddedMessages = "embedded-messaging/messages" static let embeddedMessageReceived = "embedded-messaging/events/received" static let embeddedMessageClick = "embedded-messaging/events/click" @@ -57,6 +71,13 @@ enum Const { static let deviceId = "itbl_device_id" static let sdkVersion = "itbl_sdk_version" static let offlineMode = "itbl_offline_mode" + static let anonymousUserEvents = "itbl_anonymous_user_events" + static let anonymousUserUpdate = "itbl_anonymous_user_update" + static let criteriaData = "itbl_criteria_data" + static let anonymousSessions = "itbl_anon_sessions" + static let matchedCriteria = "itbl_matched_criteria" + static let eventList = "itbl_event_list" + static let anonymousUsageTrack = "itbl_anonymous_usage_track" static let isNotificationsEnabled = "itbl_isNotificationsEnabled" static let hasStoredNotificationSetting = "itbl_hasStoredNotificationSetting" @@ -69,6 +90,7 @@ enum Const { enum Key { static let email = "itbl_email" static let userId = "itbl_userid" + static let userIdAnnon = "itbl_userid_annon" static let authToken = "itbl_auth_token" } } @@ -117,6 +139,11 @@ enum JsonKey { static let subscribedMessageTypeIds = "subscribedMessageTypeIds" static let preferUserId = "preferUserId" + static let sourceEmail = "sourceEmail" + static let sourceUserId = "sourceUserId" + static let destinationEmail = "destinationEmail" + static let destinationUserId = "destinationUserId" + static let mergeNestedObjects = "mergeNestedObjects" static let inboxMetadata = "inboxMetadata" @@ -173,6 +200,7 @@ enum JsonKey { static let actionIdentifier = "actionIdentifier" static let userText = "userText" static let appAlreadyRunning = "appAlreadyRunning" + static let anonSessionContext = "anonSessionContext" static let html = "html" @@ -182,6 +210,57 @@ enum JsonKey { static let contentType = "Content-Type" + // AUT + static let createNewFields = "createNewFields" + static let eventType = "dataType" + static let eventTimeStamp = "eventTimeStamp" + static let criteriaSets = "criteriaSets" + static let matchedCriteriaId = "matchedCriteriaId" + static let mobilePushOptIn = "mobilePushOptIn" + + enum CriteriaItem { + static let searchQuery = "searchQuery" + static let criteriaId = "criteriaId" + static let searchQueries = "searchQueries" + static let combinator = "combinator" + static let searchCombo = "searchCombo" + static let field = "field" + static let comparatorType = "comparatorType" + static let fieldType = "fieldType" + static let value = "value" + static let values = "values" + static let minMatch = "minMatch" + + enum Combinator { + static let and = "And" + static let or = "Or" + static let not = "Not" + } + + enum CartEventItemsPrefix { + static let updateCartItemPrefix = "updateCart.updatedShoppingCartItems" + static let purchaseItemPrefix = "shoppingCartItems" + } + + enum CartEventPrefix { + static let updateCartItemPrefix = CartEventItemsPrefix.updateCartItemPrefix + "." + static let purchaseItemPrefix = CartEventItemsPrefix.purchaseItemPrefix + "." + } + + enum Comparator { + static let Equals = "Equals" + static let DoesNotEquals = "DoesNotEqual" + static let IsSet = "IsSet" + static let GreaterThan = "GreaterThan" + static let LessThan = "LessThan" + static let GreaterThanOrEqualTo = "GreaterThanOrEqualTo" + static let LessThanOrEqualTo = "LessThanOrEqualTo" + static let Contains = "Contains" + static let StartsWith = "StartsWith" + static let MatchesRegex = "MatchesRegex" + } + } + static let mobileFrameworkInfo = "mobileFrameworkInfo" static let frameworkType = "frameworkType" @@ -421,6 +500,12 @@ public enum IterableCustomActionName: String, CaseIterable { case delete } +public enum MergeResult: String { + case mergenotrequired + case mergesuccessful + case mergefailed +} + public typealias ITEActionBlock = (String?) -> Void public typealias ITBURLCallback = (URL?) -> Void public typealias OnSuccessHandler = (_ data: [AnyHashable: Any]?) -> Void @@ -428,3 +513,4 @@ public typealias OnFailureHandler = (_ reason: String?, _ data: Data?) -> Void public typealias UrlHandler = (URL) -> Bool public typealias CustomActionHandler = (String) -> Bool public typealias AuthTokenRetrievalHandler = (String?) -> Void +public typealias MergeActionHandler = (MergeResult, String?) -> Void diff --git a/swift-sdk/Internal/AnonymousUserManager+Functions.swift b/swift-sdk/Internal/AnonymousUserManager+Functions.swift new file mode 100644 index 000000000..a481dbbce --- /dev/null +++ b/swift-sdk/Internal/AnonymousUserManager+Functions.swift @@ -0,0 +1,690 @@ +// +// File.swift +// +// +// Created by HARDIK MASHRU on 13/11/23. +// + +import Foundation + +// Convert commerce items to dictionaries +func convertCommerceItemsToDictionary(_ items: [CommerceItem]) -> [[AnyHashable:Any]] { + let dictionaries = items.map { item in + return item.toDictionary() + } + return dictionaries +} + +// Convert to commerce items from dictionaries +func convertCommerceItems(from dictionaries: [[AnyHashable: Any]]) -> [CommerceItem] { + return dictionaries.compactMap { dictionary in + let item = CommerceItem(id: dictionary[JsonKey.CommerceItem.id] as? String ?? "", name: dictionary[JsonKey.CommerceItem.name] as? String ?? "", price: dictionary[JsonKey.CommerceItem.price] as? NSNumber ?? 0, quantity: dictionary[JsonKey.CommerceItem.quantity] as? UInt ?? 0) + item.sku = dictionary[JsonKey.CommerceItem.sku] as? String + item.itemDescription = dictionary[JsonKey.CommerceItem.description] as? String + item.url = dictionary[JsonKey.CommerceItem.url] as? String + item.imageUrl = dictionary[JsonKey.CommerceItem.imageUrl] as? String + item.categories = dictionary[JsonKey.CommerceItem.categories] as? [String] + item.dataFields = dictionary[JsonKey.CommerceItem.dataFields] as? [AnyHashable: Any] + + return item + } +} + +func convertToDictionary(data: Codable) -> [AnyHashable: Any] { + do { + let encoder = JSONEncoder() + let data = try encoder.encode(data) + if let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [AnyHashable: Any] { + return dictionary + } + } catch { + print("Error converting to dictionary: \(error)") + } + return [:] +} + +// Converts UTC Datetime from current time +func getUTCDateTime() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + dateFormatter.timeZone = TimeZone(identifier: "UTC") + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + + let utcDate = Date() + return dateFormatter.string(from: utcDate) +} + +struct CriteriaCompletionChecker { + init(anonymousCriteria: Data, anonymousEvents: [[AnyHashable: Any]]) { + self.anonymousEvents = anonymousEvents + self.anonymousCriteria = anonymousCriteria + } + + func getMatchedCriteria() -> String? { + var criteriaId: String? = nil + if let json = try? JSONSerialization.jsonObject(with: anonymousCriteria, options: []) as? [String: Any] { + // Access the criteriaList + if let criteriaList = json[JsonKey.criteriaSets] as? [[String: Any]] { + // Iterate over the criteria + for criteria in criteriaList { + // Perform operations on each criteria + if let searchQuery = criteria[JsonKey.CriteriaItem.searchQuery] as? [String: Any], let currentCriteriaId = criteria[JsonKey.CriteriaItem.criteriaId] as? String { + // we will split purhase/updatecart event items as seperate events because we need to compare it against the single item in criteria json + var eventsToProcess = getEventsWithCartItems() + eventsToProcess.append(contentsOf: getNonCartEvents()) + let result = evaluateTree(node: searchQuery, localEventData: eventsToProcess) + if (result) { + criteriaId = currentCriteriaId + break + } + } + } + } + } + return criteriaId + } + + func getMappedKeys(event: [AnyHashable: Any]) -> [String] { + var itemKeys: [String] = [] + for (_ , value) in event { + if let arrayValue = value as? [[AnyHashable: Any]], arrayValue.count > 0 { // this is a special case of items array in purchase event + // If the value is an array, handle it + itemKeys.append(contentsOf: extractKeys(dict: arrayValue[0])) + } else { + itemKeys.append(contentsOf: extractKeys(dict: event)) + } + } + return itemKeys + } + + func getNonCartEvents() -> [[AnyHashable: Any]] { + let nonPurchaseEvents = anonymousEvents.filter { dictionary in + if let dataType = dictionary[JsonKey.eventType] as? String { + return dataType != EventType.purchase && dataType != EventType.updateCart + } + return false + } + var processedEvents: [[AnyHashable: Any]] = [] + for eventItem in nonPurchaseEvents { + var updatedItem = eventItem + // handle dataFields if any + if let dataFields = eventItem[JsonKey.CommerceItem.dataFields] as? [AnyHashable: Any] { + for (key, value) in dataFields { + if key is String { + updatedItem[key] = value + } + } + updatedItem.removeValue(forKey: JsonKey.CommerceItem.dataFields) + } + processedEvents.append(updatedItem) + } + return processedEvents + } + + private func processEvent(eventItem: [AnyHashable: Any], eventType: String, eventName: String, prefix: String) -> [AnyHashable: Any] { + var updatedItem = [AnyHashable: Any]() + if let items = eventItem[JsonKey.Commerce.items] as? [[AnyHashable: Any]] { + let updatedCartOrPurchaseItems = items.map { item -> [AnyHashable: Any] in + var updateCartOrPurchaseItem = [AnyHashable: Any]() + for (key, value) in item { + if let stringKey = key as? String { + updateCartOrPurchaseItem[prefix + stringKey] = value + } + } + return updateCartOrPurchaseItem + } + if eventName.isEmpty { + updatedItem[JsonKey.CriteriaItem.CartEventItemsPrefix.purchaseItemPrefix] = updatedCartOrPurchaseItems + } else { + updatedItem[JsonKey.CriteriaItem.CartEventItemsPrefix.updateCartItemPrefix] = updatedCartOrPurchaseItems + } + } + + + // handle dataFields if any + if let dataFields = eventItem[JsonKey.CommerceItem.dataFields] as? [AnyHashable: Any] { + for (key, value) in dataFields { + if key is String { + updatedItem[key] = value + } + } + } + + for (key, value) in eventItem { + if (key as! String != JsonKey.Commerce.items && key as! String != JsonKey.CommerceItem.dataFields) { + if (key as! String == JsonKey.eventType) { + updatedItem[key] = EventType.customEvent; + } else { + updatedItem[key] = value + } + } + } + + updatedItem[JsonKey.eventType] = eventType + if !eventName.isEmpty { + updatedItem[JsonKey.eventName] = eventName + } + updatedItem.removeValue(forKey: JsonKey.Commerce.items) + return updatedItem; + } + + func getEventsWithCartItems() -> [[AnyHashable: Any]] { + let purchaseEvents = anonymousEvents.filter { dictionary in + if let dataType = dictionary[JsonKey.eventType] as? String { + return dataType == EventType.purchase || dataType == EventType.updateCart + } + return false + } + + var processedEvents: [[AnyHashable: Any]] = [] + for var eventItem in purchaseEvents { + if let eventType = eventItem[JsonKey.eventType] as? String, eventType == EventType.purchase { + processedEvents.append(processEvent(eventItem: eventItem, eventType: EventType.purchase, eventName: "", prefix: JsonKey.CriteriaItem.CartEventPrefix.purchaseItemPrefix)) + + } else if let eventType = eventItem[JsonKey.eventType] as? String, eventType == EventType.updateCart { + processedEvents.append(processEvent(eventItem: eventItem, eventType: EventType.customEvent, eventName: EventType.updateCart, prefix: JsonKey.CriteriaItem.CartEventPrefix.updateCartItemPrefix)) + } + eventItem.removeValue(forKey: JsonKey.CommerceItem.dataFields) + } + return processedEvents + } + + func extractKeys(jsonObject: [String: Any]) -> [String] { + return Array(jsonObject.keys) + } + + func extractKeys(dict: [AnyHashable: Any]) -> [String] { + var keys: [String] = [] + for key in dict.keys { + if let stringKey = key as? String { + // If needed, use stringKey which is now guaranteed to be a String + keys.append(stringKey) + } + } + return keys + } + + func evaluateTree(node: [String: Any], localEventData: [[AnyHashable: Any]]) -> Bool { + if let searchQueries = node[JsonKey.CriteriaItem.searchQueries] as? [[String: Any]], let combinator = node[JsonKey.CriteriaItem.combinator] as? String { + if combinator == JsonKey.CriteriaItem.Combinator.and { + for query in searchQueries { + if !evaluateTree(node: query, localEventData: localEventData) { + return false // If any subquery fails, return false + } + } + return true // If all subqueries pass, return true + } else if combinator == JsonKey.CriteriaItem.Combinator.or { + for query in searchQueries { + if evaluateTree(node: query, localEventData: localEventData) { + return true // If any subquery passes, return true + } + } + return false // If all subqueries fail, return false + } else if combinator == JsonKey.CriteriaItem.Combinator.not { + for var query in searchQueries { + query["isNot"] = true + if evaluateTree(node: query, localEventData: localEventData) { + return false // If all subquery passes, return false + } + } + return true // If any subqueries fail, return true + } + } else if node[JsonKey.CriteriaItem.searchCombo] is [String: Any] { + return evaluateSearchQueries(node: node, localEventData: localEventData) + } + + return false + } + + func evaluateSearchQueries(node: [String: Any], localEventData: [[AnyHashable: Any]]) -> Bool { + // Make a mutable copy of the node + var mutableNode = node + for (index, eventData) in localEventData.enumerated() { + guard let trackingType = eventData[JsonKey.eventType] as? String else { continue } + let dataType = mutableNode[JsonKey.eventType] as? String + if eventData[JsonKey.CriteriaItem.criteriaId] == nil && dataType == trackingType { + if let searchCombo = mutableNode[JsonKey.CriteriaItem.searchCombo] as? [String: Any] { + let searchQueries = searchCombo[JsonKey.CriteriaItem.searchQueries] as? [[AnyHashable: Any]] ?? [] + let combinator = searchCombo[JsonKey.CriteriaItem.combinator] as? String ?? "" + let isNot = node["isNot"] as? Bool ?? false + if evaluateEvent(eventData: eventData, searchQueries: searchQueries, combinator: combinator) { + if var minMatch = mutableNode[JsonKey.CriteriaItem.minMatch] as? Int { + minMatch -= 1 + if minMatch > 0 { + mutableNode[JsonKey.CriteriaItem.minMatch] = minMatch + continue + } + } + if isNot && index + 1 != localEventData.count { + continue + } + return true + } else if (isNot){ + return false; + } + } + } + } + return false + } + + + // Evaluate the event based on search queries and combinator + private func evaluateEvent(eventData: [AnyHashable: Any], searchQueries: [[AnyHashable: Any]], combinator: String) -> Bool { + return evaluateFieldLogic(searchQueries: searchQueries, eventData: eventData) + } + + + + // Check if item criteria exists in search queries + private func doesItemCriteriaExist(searchQueries: [[AnyHashable: Any]]) -> Bool { + return searchQueries.contains { query in + if let field = query[JsonKey.CriteriaItem.field] as? String { + return field.hasPrefix(JsonKey.CriteriaItem.CartEventItemsPrefix.updateCartItemPrefix) || + field.hasPrefix(JsonKey.CriteriaItem.CartEventItemsPrefix.purchaseItemPrefix) + } + return false + } + } + + // Check if an item matches the search queries + private func doesItemMatchQueries(item: [String: Any], searchQueries: [[AnyHashable: Any]]) -> Bool { + // Filter searchQueries based on whether the item's keys contain the query field + var filteredSearchQueries: [[AnyHashable: Any]] = [] + for searchQuery in searchQueries { + if let field = searchQuery[JsonKey.CriteriaItem.field] as? String { + if field.hasPrefix(JsonKey.CriteriaItem.CartEventPrefix.updateCartItemPrefix) || + field.hasPrefix(JsonKey.CriteriaItem.CartEventPrefix.purchaseItemPrefix) { + if !item.keys.contains(where: { $0 == field }) { + return false + } + filteredSearchQueries.append(searchQuery) + } + } + } + // Return false if no queries are left after filtering + if filteredSearchQueries.isEmpty { + return false + } + + let result = filteredSearchQueries.allSatisfy { query in + let field = query[JsonKey.CriteriaItem.field] + if let value = item[field as! String], let comparatorType = query[JsonKey.CriteriaItem.comparatorType] as? String{ + return evaluateComparison(comparatorType: comparatorType, matchObj: value, valueToCompare: query[JsonKey.CriteriaItem.value] ?? query[JsonKey.CriteriaItem.values]) + } + return false + } + + if !result { + return result + } + + if !filteredSearchQueries.isEmpty { + return true + } + + return false + } + + // Evaluate the field logic against the event data + private func evaluateFieldLogic(searchQueries: [[AnyHashable: Any]], eventData: [AnyHashable: Any]) -> Bool { + let localDataKeys = Array(eventData.keys) + var itemMatchedResult = false + var itemsKey: String? = nil + + if localDataKeys.contains(JsonKey.CriteriaItem.CartEventItemsPrefix.updateCartItemPrefix) { + itemsKey = JsonKey.CriteriaItem.CartEventItemsPrefix.updateCartItemPrefix + } else if localDataKeys.contains(JsonKey.CriteriaItem.CartEventItemsPrefix.purchaseItemPrefix) { + itemsKey = JsonKey.CriteriaItem.CartEventItemsPrefix.purchaseItemPrefix + } + if let itemsKey = itemsKey { + if let items = eventData[itemsKey] as? [[String: Any]] { + let result = items.contains { doesItemMatchQueries(item: $0, searchQueries: searchQueries) } + if !result && doesItemCriteriaExist(searchQueries: searchQueries) { + return result + } + itemMatchedResult = result + } + } + + // Assuming localDataKeys is [String] + let filteredLocalDataKeys = localDataKeys.filter { $0 as! String != JsonKey.CriteriaItem.CartEventItemsPrefix.updateCartItemPrefix } + if filteredLocalDataKeys.isEmpty { + return itemMatchedResult + } + + // Assuming searchQueries is [[String: Any]] + let filteredSearchQueries = searchQueries.filter { query in + if let field = query[JsonKey.CriteriaItem.field] as? String { + return !field.hasPrefix(JsonKey.CriteriaItem.CartEventPrefix.updateCartItemPrefix) && + !field.hasPrefix(JsonKey.CriteriaItem.CartEventPrefix.purchaseItemPrefix) + } + return false + } + + if filteredSearchQueries.isEmpty { + return itemMatchedResult + } + + let matchResult = filteredSearchQueries.allSatisfy { query in + let field = query[JsonKey.CriteriaItem.field] as! String + var doesKeyExist = false + if let eventType = query[JsonKey.eventType] as? String, eventType == EventType.customEvent, let fieldType = query[JsonKey.CriteriaItem.fieldType] as? String, fieldType == "object", let comparatorType = query[JsonKey.CriteriaItem.comparatorType] as? String, comparatorType == JsonKey.CriteriaItem.Comparator.IsSet, let eventName = eventData[JsonKey.eventName] as? String { + if (eventName == EventType.updateCart && field == eventName) || + (field == eventName) { + return true + } + } else { + doesKeyExist = filteredLocalDataKeys.filter {$0 as! String == field }.count > 0 + } + + if field.contains(".") { + var fields = field.split(separator: ".").map { String($0) } + if let type = eventData[JsonKey.eventType] as? String, let name = eventData[JsonKey.eventName] as? String, type == EventType.customEvent, name == fields.first { + fields = Array(fields.dropFirst()) + } + + var fieldValue: Any = eventData + var isSubFieldArray = false + var isSubMatch = false + + for subField in fields { + if let subFieldValue = (fieldValue as? [String: Any])?[subField] { + if let arrayValue = subFieldValue as? [[String: Any]] { + isSubFieldArray = true + isSubMatch = arrayValue.contains { item in + let data = fields.reversed().reduce([String: Any]()) { acc, key in + if key == subField { + return [key: item] + } + return [key: acc] + } + return evaluateFieldLogic(searchQueries: searchQueries, eventData: eventData.merging(data) { $1 }) + } + } else { + fieldValue = subFieldValue + } + } + } + + if isSubFieldArray { + return isSubMatch + } + + if let valueFromObj = getFieldValue(data: eventData, field: field), let comparatorType = query[JsonKey.CriteriaItem.comparatorType] as? String { + return evaluateComparison(comparatorType: comparatorType, matchObj: valueFromObj, valueToCompare: query[JsonKey.CriteriaItem.value] ?? query[JsonKey.CriteriaItem.values]) + } + } else if doesKeyExist { + if let comparatorType = query[JsonKey.CriteriaItem.comparatorType] as? String, (evaluateComparison(comparatorType: comparatorType, matchObj: eventData[field] ?? "", valueToCompare: query[JsonKey.CriteriaItem.value] ?? query[JsonKey.CriteriaItem.values])) { + return true + } + } + + return false + } + return matchResult + } + + + func getFieldValue(data: Any, field: String) -> Any? { + var fields = field.split(separator: ".").map(String.init) + if let dictionary = data as? [String: Any] ,let dataType = dictionary[JsonKey.eventType] as? String, dataType == EventType.customEvent, let firstField = fields.first, let eventName = dictionary[JsonKey.eventName] as? String, firstField == eventName { + fields.removeFirst() + } + var currentValue: Any? = data + for (index, currentField) in fields.enumerated() { + if index == fields.count - 1 { + if let currentDict = currentValue as? [String: Any] { + return currentDict[currentField] + } + } else { + if let currentDict = currentValue as? [String: Any], let nextValue = currentDict[currentField] { + currentValue = nextValue + } else { + return nil + } + } + } + return nil + } + + + func evaluateComparison(comparatorType: String, matchObj: Any, valueToCompare: Any?) -> Bool { + if var stringValue = valueToCompare as? String { + if let doubleValue = Double(stringValue) { + stringValue = formattedDoubleValue(doubleValue) + } + + switch comparatorType { + case JsonKey.CriteriaItem.Comparator.Equals: + return compareValueEquality(matchObj, stringValue) + case JsonKey.CriteriaItem.Comparator.DoesNotEquals: + return !compareValueEquality(matchObj, stringValue) + case JsonKey.CriteriaItem.Comparator.IsSet: + return compareValueIsSet(matchObj) + case JsonKey.CriteriaItem.Comparator.GreaterThan: + return compareNumericValues(matchObj, stringValue, compareOperator: >) + case JsonKey.CriteriaItem.Comparator.LessThan: + return compareNumericValues(matchObj, stringValue, compareOperator: <) + case JsonKey.CriteriaItem.Comparator.GreaterThanOrEqualTo: + return compareNumericValues(matchObj, stringValue, compareOperator: >=) + case JsonKey.CriteriaItem.Comparator.LessThanOrEqualTo: + return compareNumericValues(matchObj, stringValue, compareOperator: <=) + case JsonKey.CriteriaItem.Comparator.Contains: + return compareStringContains(matchObj, stringValue) + case JsonKey.CriteriaItem.Comparator.StartsWith: + return compareStringStartsWith(matchObj, stringValue) + case JsonKey.CriteriaItem.Comparator.MatchesRegex: + return compareWithRegex(matchObj, pattern: stringValue) + default: + return false + } + } else if var arrayOfString = valueToCompare as? [String] { + arrayOfString = arrayOfString.compactMap({ stringValue in + if let doubleValue = Double(stringValue) { + return formattedDoubleValue(doubleValue) + } + return stringValue + }) + switch comparatorType { + case JsonKey.CriteriaItem.Comparator.Equals: + return compareValuesEquality(matchObj, arrayOfString) + case JsonKey.CriteriaItem.Comparator.DoesNotEquals: + return !compareValuesEquality(matchObj, arrayOfString) + default: + return false + } + } + return false + } + + func formattedDoubleValue(_ d: Double) -> String { + if d == Double(Int64(d)) { + return String(format: "%lld", Int64(d)) + } else { + return String(format: "%f", d).trimmingCharacters(in: CharacterSet(charactersIn: "0")) + } + } + + func compareValueEquality(_ sourceTo: Any, _ stringValue: String) -> Bool { + switch (sourceTo, stringValue) { + case (let doubleNumber as Double, let value): return doubleNumber == Double(value) + case (let intNumber as Int, let value): return intNumber == Int(value) + case (let longNumber as Int64, let value): return longNumber == Int64(value) + case (let booleanValue as Bool, let value): return booleanValue == Bool(value) + case (let stringTypeValue as String, let value): return stringTypeValue == value + case (let doubleNumbers as [Double], let value): + guard let doubleValue = Double(value) else { return false } + return doubleNumbers.contains(doubleValue) + case (let intNumbers as [Int], let value): + guard let intValue = Int(value) else { return false } + return intNumbers.contains(intValue) + case (let longNumbers as [Int64], let value): + guard let intValue = Int64(value) else { return false } + return longNumbers.contains(intValue) + case (let stringTypeValues as [String], let value): + return stringTypeValues.contains(value) + default: return false + } + } + + func compareValuesEquality(_ sourceTo: Any, _ stringsValue: [String]) -> Bool { + switch (sourceTo, stringsValue) { + case (let doubleNumber as Double, let values): return values.compactMap({Double($0)}).contains(doubleNumber) + case (let intNumber as Int, let values): return values.compactMap({Int($0)}).contains(intNumber) + case (let longNumber as Int64, let values): return values.compactMap({Int64($0)}).contains(longNumber) + case (let booleanValue as Bool, let values): return values.compactMap({Bool($0)}).contains(booleanValue) + case (let stringTypeValue as String, let values): return values.contains(stringTypeValue) + case (let doubleNumbers as [Double], let values): + let set1 = Set(doubleNumbers) + let set2 = Set(values.compactMap({Double($0)})) + return !set1.intersection(set2).isEmpty + case (let intNumbers as [Int], let values): + let set1 = Set(intNumbers) + let set2 = Set(values.compactMap({Int($0)})) + return !set1.intersection(set2).isEmpty + case (let longNumbers as [Int64], let values): + let set1 = Set(longNumbers) + let set2 = Set(values.compactMap({Int64($0)})) + return !set1.intersection(set2).isEmpty + case (let stringTypeValues as [String], let values): + let set1 = Set(stringTypeValues) + let set2 = Set(values) + return !set1.intersection(set2).isEmpty + default: return false + } + } + + func compareValueIsSet(_ sourceTo: Any?) -> Bool { + switch sourceTo { + case let doubleValue as Double: + return !doubleValue.isNaN // Checks if the Double is not NaN (not a number) + + case _ as Int: + return true // Ints are always set (0 is a valid value) + + case _ as Int64: + return true // Int64s are always set (0 is a valid value) + + case _ as Bool: + return true // Bools are always set (false is a valid value) + + case let stringValue as String: + return !stringValue.isEmpty // Checks if the string is not empty + + case let arrayValue as [Any]: + return !arrayValue.isEmpty // Checks if the array is not empty + + case let dictValue as [AnyHashable: Any]: + return !dictValue.isEmpty // Checks if the dictionary is not empty + + default: + return sourceTo != nil // Return false for nil or other unspecified types + } + } + + func compareNumericValues(_ sourceTo: Any, _ stringValue: String, compareOperator: (Double, Double) -> Bool) -> Bool { + if let sourceNumber = Double(stringValue) { + switch sourceTo { + case let doubleNumber as Double: + return compareOperator(doubleNumber, sourceNumber) + case let intNumber as Int: + return compareOperator(Double(intNumber), sourceNumber) + case let longNumber as Int64: + return compareOperator(Double(longNumber), sourceNumber) + case let stringNumber as String: + if let doubleFromString = Double(stringNumber) { + return compareOperator(doubleFromString, sourceNumber) + } else { + return false // Handle the case where string cannot be converted to a Double + } + case (let doubleNumbers as [Double]): + for value in doubleNumbers { + if compareOperator(Double(value), sourceNumber) { + return true + } + } + return false + case (let intNumbers as [Int]): + for value in intNumbers { + if compareOperator(Double(value), sourceNumber) { + return true + } + } + return false + case (let longNumbers as [Int64]): + for value in longNumbers { + if compareOperator(Double(value), sourceNumber) { + return true + } + } + return false + case (let stringTypeValues as [String]): + for value in stringTypeValues { + if let doubleFromString = Double(value), compareOperator(doubleFromString, sourceNumber) { + return true + } + } + return false + default: + return false + } + } else { + return false // Handle the case where stringValue cannot be converted to a Double + } + } + + func compareStringContains(_ sourceTo: Any, _ stringValue: String) -> Bool { + if let stringTypeValue = sourceTo as? String { + // sourceTo is a String + return stringTypeValue.contains(stringValue) + } else if let arrayTypeValue = sourceTo as? [String] { + // sourceTo is an Array of String + return arrayTypeValue.contains(stringValue) + } + return false + } + + func compareStringStartsWith(_ sourceTo: Any, _ stringValue: String) -> Bool { + if let stringTypeValue = sourceTo as? String { + // sourceTo is a String + return stringTypeValue.hasPrefix(stringValue) + } else if let arrayTypeValue = sourceTo as? [String] { + // sourceTo is an Array of String + for value in arrayTypeValue { + if value.hasPrefix(stringValue) { + return true + } + } + } + return false + } + + func compareWithRegex(_ sourceTo: Any, pattern: String) -> Bool { + if let stringTypeValue = sourceTo as? String { + do { + let regex = try NSRegularExpression(pattern: pattern) + let range = NSRange(stringTypeValue.startIndex.. Double { + return lastCriteriaFetch + } + + /// Sets the last criteria fetch time in milliseconds + public func updateLastCriteriaFetch(currentTime: Double) { + lastCriteriaFetch = currentTime + } + + /// Creates a user after criterias met and login the user and then sync the data through track APIs + private func createAnonymousUser(_ criteriaId: String) { + var anonSessions = convertToDictionary(data: localStorage.anonymousSessions?.itbl_anon_sessions) + let userId = IterableUtil.generateUUID() + anonSessions[JsonKey.matchedCriteriaId] = Int(criteriaId) + let appName = Bundle.main.appPackageName ?? "" + notificationStateProvider.isNotificationsEnabled { isEnabled in + if !appName.isEmpty && isEnabled { + anonSessions[JsonKey.mobilePushOptIn] = appName + } + + //track anon session for new user + IterableAPI.implementation?.apiClient.trackAnonSession( + createdAt: IterableUtil.secondsFromEpoch(for: self.dateProvider.currentDate), + withUserId: userId, + dataFields: self.localStorage.anonymousUserUpdate, + requestJson: anonSessions + ).onError { error in + self.isCriteriaMatched = false + if error.httpStatusCode == 409 { + self.getAnonCriteria() // refetch the criteria + } + }.onSuccess { success in + self.localStorage.userIdAnnon = userId + self.config.anonUserDelegate?.onAnonUserCreated(userId: userId) + + IterableAPI.implementation?.setUserId(userId, isAnon: true) + + self.syncNonSyncedEvents() + } + } + } + + /// Checks if criterias are being met and returns criteriaId if it matches the criteria. + private func evaluateCriteriaAndReturnID() -> String? { + guard let criteriaData = localStorage.criteriaData else { return nil } + + var events = [[AnyHashable: Any]]() + + if let anonymousUserEvents = localStorage.anonymousUserEvents { + events.append(contentsOf: anonymousUserEvents) + } + + if let userUpdate = localStorage.anonymousUserUpdate { + events.append(userUpdate) + } + + guard events.count > 0 else { return nil } + + return CriteriaCompletionChecker(anonymousCriteria: criteriaData, anonymousEvents: events).getMatchedCriteria() + } + + /// Stores event data locally + private func storeEventData(type: String, data: [AnyHashable: Any], shouldOverWrite: Bool = false) { + // Early return if no AUT consent was given + if !self.localStorage.anonymousUsageTrack { + ITBInfo("AUT CONSENT NOT GIVEN - no events being stored") + return + } + + if type == EventType.updateUser { + processAndStoreUserUpdate(data: data) + } else { + processAndStoreEvent(type: type, data: data) + } + + if let criteriaId = evaluateCriteriaAndReturnID(), !isCriteriaMatched { + isCriteriaMatched = true + createAnonymousUser(criteriaId) + } + } + + /// Stores User Update data + private func processAndStoreUserUpdate(data: [AnyHashable: Any]) { + var userUpdate = localStorage.anonymousUserUpdate ?? [:] + + // Merge new data into userUpdate + userUpdate.merge(data) { (_, new) in new } + + userUpdate.setValue(for: JsonKey.eventType, value: EventType.updateUser) + userUpdate.setValue(for: JsonKey.eventTimeStamp, value: IterableUtil.secondsFromEpoch(for: dateProvider.currentDate)) + + localStorage.anonymousUserUpdate = userUpdate + } + + /// Stores all other event data + private func processAndStoreEvent(type: String, data: [AnyHashable: Any]) { + var eventsDataObjects: [[AnyHashable: Any]] = localStorage.anonymousUserEvents ?? [] + + var newEventData = data + newEventData.setValue(for: JsonKey.eventType, value: type) + newEventData.setValue(for: JsonKey.eventTimeStamp, value: IterableUtil.secondsFromEpoch(for: dateProvider.currentDate)) // this we use as unique idenfier too + + eventsDataObjects.append(newEventData) + + if eventsDataObjects.count > config.eventThresholdLimit { + eventsDataObjects = eventsDataObjects.suffix(config.eventThresholdLimit) + } + + localStorage.anonymousUserEvents = eventsDataObjects + } +} diff --git a/swift-sdk/Internal/AnonymousUserManagerProtocol.swift b/swift-sdk/Internal/AnonymousUserManagerProtocol.swift new file mode 100644 index 000000000..e4dcd8682 --- /dev/null +++ b/swift-sdk/Internal/AnonymousUserManagerProtocol.swift @@ -0,0 +1,20 @@ +// +// AnonymousUserManagerProtocol.swift +// +// +// Created by HARDIK MASHRU on 09/11/23. +// +import Foundation +@objc public protocol AnonymousUserManagerProtocol { + func trackAnonEvent(name: String, dataFields: [AnyHashable: Any]?) + func trackAnonPurchaseEvent(total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?) + func trackAnonUpdateCart(items: [CommerceItem]) + func trackAnonTokenRegistration(token: String) + func trackAnonUpdateUser(_ dataFields: [AnyHashable: Any]) + func updateAnonSession() + func getLastCriteriaFetch() -> Double + func updateLastCriteriaFetch(currentTime: Double) + func getAnonCriteria() + func syncEvents() + func clearVisitorEventsAndUserData() +} diff --git a/swift-sdk/Internal/AnonymousUserMerge.swift b/swift-sdk/Internal/AnonymousUserMerge.swift new file mode 100644 index 000000000..68cc48fd8 --- /dev/null +++ b/swift-sdk/Internal/AnonymousUserMerge.swift @@ -0,0 +1,44 @@ +// +// AnonymousUserMerge.swift +// Iterable-iOS-SDK +// +// Created by Hani Vora on 19/12/23. +// + +import Foundation + +protocol AnonymousUserMergeProtocol { + func tryMergeUser(destinationUser: String?, isEmail: Bool, merge: Bool, onMergeResult: @escaping MergeActionHandler) +} + +class AnonymousUserMerge: AnonymousUserMergeProtocol { + + var anonymousUserManager: AnonymousUserManagerProtocol + var apiClient: ApiClient + private var localStorage: LocalStorageProtocol + + init(apiClient: ApiClient, anonymousUserManager: AnonymousUserManagerProtocol, localStorage: LocalStorageProtocol) { + self.apiClient = apiClient + self.anonymousUserManager = anonymousUserManager + self.localStorage = localStorage + } + + func tryMergeUser(destinationUser: String?, isEmail: Bool, merge: Bool, onMergeResult: @escaping MergeActionHandler) { + let anonymousUserId = localStorage.userIdAnnon + + if (anonymousUserId != nil && destinationUser != nil && merge) { + let destinationEmail = isEmail ? destinationUser : nil + let destinationUserId = isEmail ? nil : destinationUser + + apiClient.mergeUser(sourceEmail: nil, sourceUserId: anonymousUserId, destinationEmail: destinationEmail, destinationUserId: destinationUserId).onSuccess {_ in + onMergeResult(MergeResult.mergesuccessful, nil) + }.onError {error in + print("Merge failed error: \(error)") + onMergeResult(MergeResult.mergefailed, error.reason) + } + } else { + // this will return mergeResult true in case of anon userId doesn't exist or destinationUserIdOrEmail is nil because merge is not required + onMergeResult(MergeResult.mergenotrequired, nil) + } + } +} diff --git a/swift-sdk/Internal/Auth.swift b/swift-sdk/Internal/Auth.swift index 77c34f4c3..0f84826d4 100644 --- a/swift-sdk/Internal/Auth.swift +++ b/swift-sdk/Internal/Auth.swift @@ -12,12 +12,15 @@ struct Auth { let userId: String? let email: String? let authToken: String? + let userIdAnon: String? var emailOrUserId: EmailOrUserId { if let email = email { return .email(email) } else if let userId = userId { return .userId(userId) + } else if let userIdAnon = userIdAnon { + return .userIdAnon(userIdAnon) } else { return .none } @@ -26,6 +29,7 @@ struct Auth { enum EmailOrUserId { case email(String) case userId(String) + case userIdAnon(String) case none } } diff --git a/swift-sdk/Internal/AuthManager.swift b/swift-sdk/Internal/AuthManager.swift index 0bd7e6fbb..da5d4b613 100644 --- a/swift-sdk/Internal/AuthManager.swift +++ b/swift-sdk/Internal/AuthManager.swift @@ -89,6 +89,13 @@ class AuthManager: IterableAuthManagerProtocol { storeAuthToken() clearRefreshTimer() + + if localStorage.email != nil || localStorage.userId != nil || localStorage.userIdAnnon != nil { + localStorage.anonymousUserEvents = nil + localStorage.anonymousSessions = nil + localStorage.anonymousUserUpdate = nil + } + isLastAuthTokenValid = false } @@ -154,15 +161,19 @@ class AuthManager: IterableAuthManagerProtocol { let isRefreshQueued = queueAuthTokenExpirationRefresh(retrievedAuthToken, onSuccess: onSuccess) if !isRefreshQueued { onSuccess?(authToken) + authToken = retrievedAuthToken + storeAuthToken() + } else { + authToken = retrievedAuthToken + storeAuthToken() + onSuccess?(authToken) } } else { handleAuthFailure(failedAuthToken: nil, reason: .authTokenNull) scheduleAuthTokenRefreshTimer(interval: getNextRetryInterval(), successCallback: onSuccess) + authToken = retrievedAuthToken + storeAuthToken() } - - authToken = retrievedAuthToken - - storeAuthToken() } func handleAuthFailure(failedAuthToken: String?, reason: AuthFailureReason) { diff --git a/swift-sdk/Internal/InternalIterableAPI.swift b/swift-sdk/Internal/InternalIterableAPI.swift index 9c6aca25f..7bc9dce40 100644 --- a/swift-sdk/Internal/InternalIterableAPI.swift +++ b/swift-sdk/Internal/InternalIterableAPI.swift @@ -66,7 +66,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } var auth: Auth { - Auth(userId: userId, email: email, authToken: authManager.getAuthToken()) + Auth(userId: userId, email: email, authToken: authManager.getAuthToken(), userIdAnon: localStorage.userIdAnnon) } var dependencyContainer: DependencyContainerProtocol @@ -82,6 +82,14 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { self.dependencyContainer.createAuthManager(config: self.config) }() + lazy var anonymousUserManager: AnonymousUserManagerProtocol = { + self.dependencyContainer.createAnonymousUserManager(config: self.config) + }() + + lazy var anonymousUserMerge: AnonymousUserMergeProtocol = { + self.dependencyContainer.createAnonymousUserMerge(apiClient: apiClient as! ApiClient, anonymousUserManager: anonymousUserManager, localStorage: localStorage) + }() + lazy var embeddedManager: IterableInternalEmbeddedManagerProtocol = { self.dependencyContainer.createEmbeddedManager(config: self.config, apiClient: self.apiClient) @@ -122,63 +130,136 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { _payloadData = data } - func setEmail(_ email: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil) { - ITBInfo() + func setEmail(_ email: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil, identityResolution: IterableIdentityResolution? = nil) { - if _email == email && email != nil && authToken != nil { - checkAndUpdateAuthToken(authToken) + ITBInfo() + if self._email == email && email != nil { + self.checkAndUpdateAuthToken(authToken) return } - - if _email == email { + + if self._email == email { return } - logoutPreviousUser() - - _email = email - _userId = nil - _successCallback = successHandler - _failureCallback = failureHandler - - storeIdentifierData() + self.logoutPreviousUser() + + self._email = email + self._userId = nil + + self.onLogin(authToken) { [weak self] in + guard let config = self?.config else { + return + } + let merge = identityResolution?.mergeOnAnonymousToKnown ?? config.identityResolution.mergeOnAnonymousToKnown + let replay = identityResolution?.replayOnVisitorToKnown ?? config.identityResolution.replayOnVisitorToKnown + if config.enableAnonActivation, let email = email { + self?.attemptAndProcessMerge( + merge: merge ?? true, + replay: replay ?? true, + destinationUser: email, + isEmail: true, + failureHandler: failureHandler + ) + self?.localStorage.userIdAnnon = nil + } + } - onLogin(authToken) + + self._successCallback = successHandler + self._failureCallback = failureHandler + self.storeIdentifierData() } - func setUserId(_ userId: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil) { + func setUserId(_ userId: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil, isAnon: Bool = false, identityResolution: IterableIdentityResolution? = nil) { ITBInfo() - - if _userId == userId && userId != nil && authToken != nil { - checkAndUpdateAuthToken(authToken) + + if self._userId == userId && userId != nil { + self.checkAndUpdateAuthToken(authToken) return } - - if _userId == userId { + + if self._userId == userId { return } + + self.logoutPreviousUser() + + self._email = nil + self._userId = userId - logoutPreviousUser() - - _email = nil - _userId = userId - _successCallback = successHandler - _failureCallback = failureHandler - - storeIdentifierData() - - onLogin(authToken) + self.onLogin(authToken) { [weak self] in + guard let config = self?.config else { + return + } + if config.enableAnonActivation { + if let userId = userId, userId != (self?.localStorage.userIdAnnon ?? "") { + let merge = identityResolution?.mergeOnAnonymousToKnown ?? config.identityResolution.mergeOnAnonymousToKnown + let replay = identityResolution?.replayOnVisitorToKnown ?? config.identityResolution.replayOnVisitorToKnown + self?.attemptAndProcessMerge( + merge: merge ?? true, + replay: replay ?? true, + destinationUser: userId, + isEmail: false, + failureHandler: failureHandler + ) + } + + if !isAnon { + self?.localStorage.userIdAnnon = nil + } + } + } + + self._successCallback = successHandler + self._failureCallback = failureHandler + self.storeIdentifierData() + } - + func logoutUser() { logoutPreviousUser() } + func attemptAndProcessMerge(merge: Bool, replay: Bool, destinationUser: String?, isEmail: Bool, failureHandler: OnFailureHandler? = nil) { + anonymousUserMerge.tryMergeUser(destinationUser: destinationUser, isEmail: isEmail, merge: merge) { mergeResult, error in + + if mergeResult == MergeResult.mergenotrequired || mergeResult == MergeResult.mergesuccessful { + if (replay) { + self.anonymousUserManager.syncEvents() + } + } else { + failureHandler?(error, nil) + } + self.anonymousUserManager.clearVisitorEventsAndUserData() + } + } + + func setVisitorUsageTracked(isVisitorUsageTracked: Bool) { + ITBInfo("CONSENT CHANGED - local events cleared") + self.localStorage.anonymousUsageTrack = isVisitorUsageTracked + self.localStorage.anonymousUserEvents = nil + self.localStorage.anonymousSessions = nil + self.localStorage.anonymousUserUpdate = nil + self.localStorage.userIdAnnon = nil + + if isVisitorUsageTracked && config.enableAnonActivation { + ITBInfo("CONSENT GIVEN and ANON TRACKING ENABLED - Criteria fetched") + self.anonymousUserManager.getAnonCriteria() + self.anonymousUserManager.updateAnonSession() + } + } + + func getVisitorUsageTracked() -> Bool { + return self.localStorage.anonymousUsageTrack + } + // MARK: - API Request Calls func register(token: String, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) { + guard let appName = pushIntegrationName else { let errorMessage = "Not registering device token - appName must not be nil" ITBError(errorMessage) @@ -186,6 +267,14 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { onFailure?(errorMessage, nil) return } + + if !isEitherUserIdOrEmailSet() && localStorage.userIdAnnon == nil { + if config.enableAnonActivation { + anonymousUserManager.trackAnonTokenRegistration(token: token) + } + onFailure?("Iterable SDK must be initialized with an API key and user email/userId before calling SDK methods", nil) + return + } hexToken = token @@ -252,7 +341,14 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { mergeNestedObjects: Bool, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { - requestHandler.updateUser(dataFields, mergeNestedObjects: mergeNestedObjects, onSuccess: onSuccess, onFailure: onFailure) + if !isEitherUserIdOrEmailSet() && localStorage.userIdAnnon == nil { + if config.enableAnonActivation { + ITBInfo("AUT ENABLED - anon update user") + anonymousUserManager.trackAnonUpdateUser(dataFields) + } + return rejectWithInitializationError(onFailure: onFailure) + } + return requestHandler.updateUser(dataFields, mergeNestedObjects: mergeNestedObjects, onSuccess: onSuccess, onFailure: onFailure) } @discardableResult @@ -277,7 +373,29 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { func updateCart(items: [CommerceItem], onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { - requestHandler.updateCart(items: items, onSuccess: onSuccess, onFailure: onFailure) + if !isEitherUserIdOrEmailSet() && localStorage.userIdAnnon == nil { + if config.enableAnonActivation { + ITBInfo("AUT ENABLED - anon update cart") + anonymousUserManager.trackAnonUpdateCart(items: items) + } + return rejectWithInitializationError(onFailure: onFailure) + } + return requestHandler.updateCart(items: items, onSuccess: onSuccess, onFailure: onFailure) + } + + @discardableResult + func updateCart(items: [CommerceItem], + createdAt: Int, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + return requestHandler.updateCart(items: items, createdAt: createdAt, onSuccess: onSuccess, onFailure: onFailure) + } + + private func rejectWithInitializationError(onFailure: OnFailureHandler? = nil) -> Pending { + let result = Fulfill() + result.reject(with: SendRequestError()) + onFailure?("Iterable SDK must be initialized with an API key and user email/userId before calling SDK methods", nil) + return result } @discardableResult @@ -288,7 +406,14 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { templateId: NSNumber? = nil, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { - requestHandler.trackPurchase(total, + if !isEitherUserIdOrEmailSet() { + if config.enableAnonActivation { + ITBInfo("AUT ENABLED - anon track purchase") + anonymousUserManager.trackAnonPurchaseEvent(total: total, items: items, dataFields: dataFields) + } + return rejectWithInitializationError(onFailure: onFailure) + } + return requestHandler.trackPurchase(total, items: items, dataFields: dataFields, campaignId: campaignId, @@ -296,6 +421,21 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { onSuccess: onSuccess, onFailure: onFailure) } + + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]? = nil, + createdAt: Int, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + return requestHandler.trackPurchase(total, + items: items, + dataFields: dataFields, + createdAt: createdAt, + onSuccess: onSuccess, + onFailure: onFailure) + } @discardableResult @@ -340,7 +480,22 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { dataFields: [AnyHashable: Any]? = nil, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { - requestHandler.track(event: eventName, dataFields: dataFields, onSuccess: onSuccess, onFailure: onFailure) + if !isEitherUserIdOrEmailSet() && localStorage.userIdAnnon == nil { + if config.enableAnonActivation { + ITBInfo("AUT ENABLED - anon track custom event") + anonymousUserManager.trackAnonEvent(name: eventName, dataFields: dataFields) + } + return rejectWithInitializationError(onFailure: onFailure) + } + return requestHandler.track(event: eventName, dataFields: dataFields, onSuccess: onSuccess, onFailure: onFailure) + } + + @discardableResult + func track(_ eventName: String, + withBody body: [AnyHashable: Any], + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + requestHandler.track(event: eventName, withBody: body, onSuccess: onSuccess, onFailure: onFailure) } @discardableResult @@ -432,7 +587,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { source: InAppDeleteSource? = nil, inboxSessionId: String? = nil, onSuccess: OnSuccessHandler? = nil, - onFailure: OnFailureHandler? = nil) -> Pending { + onFailure: OnFailureHandler? = nil) -> Pending { requestHandler.inAppConsume(message: message, location: location, source: source, @@ -559,7 +714,8 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } } - func isSDKInitialized() -> Bool { + + public func isSDKInitialized() -> Bool { let isInitialized = !apiKey.isEmpty && isEitherUserIdOrEmailSet() if !isInitialized { @@ -577,6 +733,10 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { IterableUtil.isNullOrEmpty(string: _email) && IterableUtil.isNullOrEmpty(string: _userId) } + public func isAnonUserSet() -> Bool { + IterableUtil.isNotNullOrEmpty(string: localStorage.userIdAnnon) + } + private func logoutPreviousUser() { ITBInfo() @@ -604,34 +764,36 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { localStorage.userId = _userId } - private func onLogin(_ authToken: String? = nil) { + private func onLogin(_ authToken: String? = nil, onloginSuccess onloginSuccessCallBack: (()->())? = nil) { + guard isSDKInitialized() else { return } + ITBInfo() guard isSDKInitialized() else { return } self.authManager.pauseAuthRetries(false) - if let authToken = authToken { + if let authToken { self.authManager.setNewToken(authToken) - completeUserLogin() + completeUserLogin(onloginSuccessCallBack: onloginSuccessCallBack) } else if isEitherUserIdOrEmailSet() && config.authDelegate != nil { - requestNewAuthToken() + requestNewAuthToken(onloginSuccessCallBack: onloginSuccessCallBack) } else { - completeUserLogin() + completeUserLogin(onloginSuccessCallBack: onloginSuccessCallBack) } } - private func requestNewAuthToken() { + private func requestNewAuthToken(onloginSuccessCallBack: (()->())? = nil) { ITBInfo() authManager.requestNewAuthToken(hasFailedPriorAuth: false, onSuccess: { [weak self] token in if token != nil { - self?.completeUserLogin() + self?.completeUserLogin(onloginSuccessCallBack: onloginSuccessCallBack) } }, shouldIgnoreRetryPolicy: true) } - private func completeUserLogin() { - ITBInfo() + private func completeUserLogin(onloginSuccessCallBack: (()->())? = nil) { + ITBInfo() guard isSDKInitialized() else { return } if config.autoPushRegistration { @@ -641,6 +803,9 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } _ = inAppManager.scheduleSync() + if onloginSuccessCallBack != nil { + onloginSuccessCallBack!() + } } private func retrieveIdentifierData() { @@ -662,7 +827,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } private func checkAndUpdateAuthToken(_ authToken: String? = nil) { - if config.authDelegate != nil && authToken != authManager.getAuthToken() { + if config.authDelegate != nil && authToken != authManager.getAuthToken() && authToken != nil { onLogin(authToken) } } @@ -688,6 +853,9 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { networkSession = dependencyContainer.networkSession notificationStateProvider = dependencyContainer.notificationStateProvider localStorage = dependencyContainer.localStorage + //localStorage.userIdAnnon = nil // remove this before pushing the code (only for testing) + //localStorage.userId = nil // remove this before pushing the code (only for testing) + //localStorage.email = nil // remove this before pushing the code (only for testing) inAppDisplayer = dependencyContainer.inAppDisplayer urlOpener = dependencyContainer.urlOpener notificationCenter = dependencyContainer.notificationCenter @@ -737,6 +905,11 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } @objc private func onAppDidBecomeActiveNotification(notification: Notification) { + handlePushNotificationState() + handleMatchingCriteriaState() + } + + private func handlePushNotificationState() { guard config.autoPushRegistration else { return } notificationStateProvider.isNotificationsEnabled { [weak self] systemEnabled in @@ -757,6 +930,24 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } } + private func handleMatchingCriteriaState() { + guard config.enableForegroundCriteriaFetch else { return } + + let currentTime = Date().timeIntervalSince1970 * 1000 // Convert to milliseconds + + // fetching anonymous user criteria on foregrounding + if noUserLoggedIn() + && !isAnonUserSet() + && config.enableAnonActivation + && getVisitorUsageTracked() + && (currentTime - anonymousUserManager.getLastCriteriaFetch() >= Const.criteriaFetchingCooldown) { + + anonymousUserManager.updateLastCriteriaFetch(currentTime: currentTime) + anonymousUserManager.getAnonCriteria() + ITBInfo("Fetching anonymous user criteria - Foreground") + } + } + private func handle(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { guard let launchOptions = launchOptions else { return @@ -825,6 +1016,17 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } } + func getCriteriaData(completion: @escaping (Data) -> Void) { + apiClient.getCriteria().onSuccess { data in + do { + let jsonData = try JSONSerialization.data(withJSONObject: data, options: []) + completion(jsonData) + } catch { + print("Error converting dictionary to data: \(error)") + } + } + } + private func createDefaultMobileFrameworkInfo() -> IterableAPIMobileFrameworkInfo { let frameworkType = IterableAPIMobileFrameworkDetector.frameworkType() return IterableAPIMobileFrameworkInfo( diff --git a/swift-sdk/Internal/IterableIdentityResolution.swift b/swift-sdk/Internal/IterableIdentityResolution.swift new file mode 100644 index 000000000..4d32ff3b1 --- /dev/null +++ b/swift-sdk/Internal/IterableIdentityResolution.swift @@ -0,0 +1,23 @@ +// +// Untitled.swift +// swift-sdk +// +// Created by Evan Greer on 9/19/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import Foundation +@objc public class IterableIdentityResolution: NSObject { + + /// userId or email of the signed-in user + public var replayOnVisitorToKnown: Bool? + + /// the authToken which caused the failure + public let mergeOnAnonymousToKnown: Bool? + + public init(replayOnVisitorToKnown: Bool?, + mergeOnAnonymousToKnown: Bool?) { + self.replayOnVisitorToKnown = replayOnVisitorToKnown + self.mergeOnAnonymousToKnown = mergeOnAnonymousToKnown + } +} diff --git a/swift-sdk/Internal/IterableUserDefaults.swift b/swift-sdk/Internal/IterableUserDefaults.swift index 5c11ec791..e11052cc9 100644 --- a/swift-sdk/Internal/IterableUserDefaults.swift +++ b/swift-sdk/Internal/IterableUserDefaults.swift @@ -9,7 +9,7 @@ class IterableUserDefaults { init(userDefaults: UserDefaults = UserDefaults.standard) { self.userDefaults = userDefaults } - + // migrated to IterableKeychain var userId: String? { get { @@ -18,7 +18,7 @@ class IterableUserDefaults { save(string: newValue, withKey: .userId) } } - + // migrated to IterableKeychain var email: String? { get { @@ -27,7 +27,7 @@ class IterableUserDefaults { save(string: newValue, withKey: .email) } } - + // migrated to IterableKeychain var authToken: String? { get { @@ -36,7 +36,7 @@ class IterableUserDefaults { save(string: newValue, withKey: .authToken) } } - + // deprecated, not in use anymore var ddlChecked: Bool { get { @@ -45,7 +45,7 @@ class IterableUserDefaults { save(bool: newValue, withKey: .ddlChecked) } } - + var deviceId: String? { get { string(withKey: .deviceId) @@ -53,7 +53,7 @@ class IterableUserDefaults { save(string: newValue, withKey: .deviceId) } } - + var sdkVersion: String? { get { string(withKey: .sdkVersion) @@ -61,7 +61,7 @@ class IterableUserDefaults { save(string: newValue, withKey: .sdkVersion) } } - + var offlineMode: Bool { get { bool(withKey: .offlineMode) @@ -69,6 +69,82 @@ class IterableUserDefaults { save(bool: newValue, withKey: .offlineMode) } } + + var anonymousUsageTrack: Bool { + get { + return bool(withKey: .anonymousUsageTrack) + } set { + save(bool: newValue, withKey: .anonymousUsageTrack) + } + } + + var anonymousUserEvents: [[AnyHashable: Any]]? { + get { + return eventData(withKey: .anonymousUserEvents) + } set { + saveEventData(anonymousUserEvents: newValue, withKey: .anonymousUserEvents) + } + } + + var anonymousUserUpdate: [AnyHashable: Any]? { + get { + return userUpdateData(withKey: .anonymousUserUpdate) + } set { + saveUserUpdate(newValue, withKey: .anonymousUserUpdate) + } + } + + var criteriaData: Data? { + get { + return getCriteriaData(withKey: .criteriaData) + } set { + saveCriteriaData(data: newValue, withKey: .criteriaData) + } + } + + var anonymousSessions: IterableAnonSessionsWrapper? { + get { + return anonSessionsData(withKey: .anonymousSessions) + } set { + saveAnonSessionsData(data: newValue, withKey: .anonymousSessions) + } + } + + var body = [AnyHashable: Any]() + + private func anonSessionsData(withKey key: UserDefaultsKey) -> IterableAnonSessionsWrapper? { + if let savedData = UserDefaults.standard.data(forKey: key.value) { + let decodedData = try? JSONDecoder().decode(IterableAnonSessionsWrapper.self, from: savedData) + return decodedData + } + return nil + } + + private func saveAnonSessionsData(data: IterableAnonSessionsWrapper?, withKey key: UserDefaultsKey) { + if let encodedData = try? JSONEncoder().encode(data) { + userDefaults.set(encodedData, forKey: key.value) + } + } + + private func criteriaData(withKey key: UserDefaultsKey) -> [Criteria]? { + if let savedData = UserDefaults.standard.data(forKey: key.value) { + let decodedData = try? JSONDecoder().decode([Criteria].self, from: savedData) + return decodedData + } + return nil + } + + private func saveCriteriaData(data: Data?, withKey key: UserDefaultsKey) { + userDefaults.set(data, forKey: key.value) + } + + private func saveEventData(anonymousUserEvents: [[AnyHashable: Any]]?, withKey key: UserDefaultsKey) { + userDefaults.set(anonymousUserEvents, forKey: key.value) + } + + private func saveUserUpdate(_ update: [AnyHashable: Any]?, withKey key: UserDefaultsKey) { + userDefaults.set(update, forKey: key.value) + } var isNotificationsEnabled: Bool { get { @@ -120,6 +196,16 @@ class IterableUserDefaults { } } + private func dict(withKey key: UserDefaultsKey) throws -> [AnyHashable: Any]? { + guard let encodedEnvelope = userDefaults.value(forKey: key.value) as? Data else { + return nil + } + + let envelope = try JSONDecoder().decode(EnvelopeNoExpiration.self, from: encodedEnvelope) + let decoded = try JSONSerialization.jsonObject(with: envelope.payload, options: []) as? [AnyHashable: Any] + return decoded + } + private func codable(withKey key: UserDefaultsKey, currentDate: Date) throws -> T? { guard let encodedEnvelope = userDefaults.value(forKey: key.value) as? Data else { return nil @@ -144,6 +230,18 @@ class IterableUserDefaults { userDefaults.bool(forKey: key.value) } + private func eventData(withKey key: UserDefaultsKey) -> [[AnyHashable: Any]]? { + userDefaults.array(forKey: key.value) as? [[AnyHashable: Any]] + } + + private func userUpdateData(withKey key: UserDefaultsKey) -> [AnyHashable: Any]? { + userDefaults.object(forKey: key.value) as? [AnyHashable: Any] + } + + private func getCriteriaData(withKey key: UserDefaultsKey) -> Data? { + userDefaults.object(forKey: key.value) as? Data + } + private static func isExpired(expiration: Date?, currentDate: Date) -> Bool { if let expiration = expiration { if expiration.timeIntervalSinceReferenceDate > currentDate.timeIntervalSinceReferenceDate { @@ -198,6 +296,17 @@ class IterableUserDefaults { userDefaults.set(encodedEnvelope, forKey: key.value) } + private func save(data: Data?, withKey key: UserDefaultsKey) throws { + guard let data = data else { + userDefaults.removeObject(forKey: key.value) + return + } + + let envelope = EnvelopeNoExpiration(payload: data) + let encodedEnvelope = try JSONEncoder().encode(envelope) + userDefaults.set(encodedEnvelope, forKey: key.value) + } + private struct UserDefaultsKey { let value: String @@ -212,12 +321,21 @@ class IterableUserDefaults { static let deviceId = UserDefaultsKey(value: Const.UserDefault.deviceId) static let sdkVersion = UserDefaultsKey(value: Const.UserDefault.sdkVersion) static let offlineMode = UserDefaultsKey(value: Const.UserDefault.offlineMode) + static let anonymousUserEvents = UserDefaultsKey(value: Const.UserDefault.anonymousUserEvents) + static let anonymousUserUpdate = UserDefaultsKey(value: Const.UserDefault.anonymousUserUpdate) + static let criteriaData = UserDefaultsKey(value: Const.UserDefault.criteriaData) + static let anonymousSessions = UserDefaultsKey(value: Const.UserDefault.anonymousSessions) + static let anonymousUsageTrack = UserDefaultsKey(value: Const.UserDefault.anonymousUsageTrack) + static let isNotificationsEnabled = UserDefaultsKey(value: Const.UserDefault.isNotificationsEnabled) static let hasStoredNotificationSetting = UserDefaultsKey(value: Const.UserDefault.hasStoredNotificationSetting) } - private struct Envelope: Codable { let payload: Data let expiration: Date? } + + private struct EnvelopeNoExpiration: Codable { + let payload: Data + } } diff --git a/swift-sdk/Internal/Models.swift b/swift-sdk/Internal/Models.swift index 9e36ea3fa..8f77d09f6 100644 --- a/swift-sdk/Internal/Models.swift +++ b/swift-sdk/Internal/Models.swift @@ -8,3 +8,26 @@ import Foundation struct RemoteConfiguration: Codable, Equatable { let offlineMode: Bool } + +struct Criteria: Codable { + let criteriaId: String + let criteriaList: [CriteriaItem] +} + +struct CriteriaItem: Codable { + let criteriaType: String + let comparator: String? + let name: String? + let aggregateCount: Int? + let total: Int? +} + +struct IterableAnonSessions: Codable { + var totalAnonSessionCount: Int + var lastAnonSession: Int + var firstAnonSession: Int +} + +struct IterableAnonSessionsWrapper: Codable { + var itbl_anon_sessions: IterableAnonSessions +} diff --git a/swift-sdk/Internal/RequestHandlerProtocol.swift b/swift-sdk/Internal/RequestHandlerProtocol.swift index a904bf3be..4c51e1b43 100644 --- a/swift-sdk/Internal/RequestHandlerProtocol.swift +++ b/swift-sdk/Internal/RequestHandlerProtocol.swift @@ -43,6 +43,12 @@ protocol RequestHandlerProtocol: AnyObject { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func updateCart(items: [CommerceItem], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -52,6 +58,14 @@ protocol RequestHandlerProtocol: AnyObject { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -67,6 +81,12 @@ protocol RequestHandlerProtocol: AnyObject { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]?, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func updateSubscriptions(info: UpdateSubscriptionsInfo, onSuccess: OnSuccessHandler?, diff --git a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift index f288e3170..f544cc603 100644 --- a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift +++ b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift @@ -131,6 +131,13 @@ extension DependencyContainerProtocol { func createRedirectNetworkSession(delegate: RedirectNetworkSessionDelegate) -> NetworkSessionProtocol { RedirectNetworkSession(delegate: delegate) } + + func createAnonymousUserManager(config: IterableConfig) -> AnonymousUserManagerProtocol { + AnonymousUserManager(config:config, + localStorage: localStorage, + dateProvider: dateProvider, + notificationStateProvider: notificationStateProvider) + } private func createTaskScheduler(persistenceContextProvider: IterablePersistenceContextProvider, healthMonitor: HealthMonitor) -> IterableTaskScheduler { @@ -148,4 +155,8 @@ extension DependencyContainerProtocol { notificationCenter: notificationCenter, connectivityManager: NetworkConnectivityManager()) } + + func createAnonymousUserMerge(apiClient: ApiClient, anonymousUserManager: AnonymousUserManagerProtocol, localStorage: LocalStorageProtocol) -> AnonymousUserMergeProtocol { + AnonymousUserMerge(apiClient: apiClient, anonymousUserManager: anonymousUserManager, localStorage: localStorage) + } } diff --git a/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift b/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift index 2737a3a73..b2f350f87 100644 --- a/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift +++ b/swift-sdk/Internal/Utilities/Keychain/IterableKeychain.swift @@ -45,6 +45,24 @@ class IterableKeychain { } } + var userIdAnnon: String? { + get { + let data = wrapper.data(forKey: Const.Keychain.Key.userIdAnnon) + + return data.flatMap { String(data: $0, encoding: .utf8) } + } + + set { + guard let token = newValue, + let data = token.data(using: .utf8) else { + wrapper.removeValue(forKey: Const.Keychain.Key.userIdAnnon) + return + } + + wrapper.set(data, forKey: Const.Keychain.Key.userIdAnnon) + } + } + var authToken: String? { get { let data = wrapper.data(forKey: Const.Keychain.Key.authToken) diff --git a/swift-sdk/Internal/Utilities/LocalStorage.swift b/swift-sdk/Internal/Utilities/LocalStorage.swift index a6e049ec6..7aef295a8 100644 --- a/swift-sdk/Internal/Utilities/LocalStorage.swift +++ b/swift-sdk/Internal/Utilities/LocalStorage.swift @@ -5,6 +5,7 @@ import Foundation struct LocalStorage: LocalStorageProtocol { + init(userDefaults: UserDefaults = UserDefaults.standard, keychain: IterableKeychain = IterableKeychain()) { iterableUserDefaults = IterableUserDefaults(userDefaults: userDefaults) @@ -19,6 +20,14 @@ struct LocalStorage: LocalStorageProtocol { } } + var userIdAnnon: String? { + get { + keychain.userIdAnnon + } set { + keychain.userIdAnnon = newValue + } + } + var email: String? { get { keychain.email @@ -67,6 +76,46 @@ struct LocalStorage: LocalStorageProtocol { } } + var anonymousUserEvents: [[AnyHashable: Any]]? { + get { + iterableUserDefaults.anonymousUserEvents + } set { + iterableUserDefaults.anonymousUserEvents = newValue + } + } + + var anonymousUserUpdate: [AnyHashable: Any]? { + get { + iterableUserDefaults.anonymousUserUpdate + } set { + iterableUserDefaults.anonymousUserUpdate = newValue + } + } + + var anonymousSessions: IterableAnonSessionsWrapper? { + get { + iterableUserDefaults.anonymousSessions + } set { + iterableUserDefaults.anonymousSessions = newValue + } + } + + var criteriaData: Data? { + get { + iterableUserDefaults.criteriaData + } set { + iterableUserDefaults.criteriaData = newValue + } + } + + var anonymousUsageTrack: Bool { + get { + iterableUserDefaults.anonymousUsageTrack + } set { + iterableUserDefaults.anonymousUsageTrack = newValue + } + } + var isNotificationsEnabled: Bool { get { iterableUserDefaults.isNotificationsEnabled diff --git a/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift b/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift index 420d076db..566089020 100644 --- a/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift +++ b/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift @@ -7,6 +7,8 @@ import Foundation protocol LocalStorageProtocol { var userId: String? { get set } + var userIdAnnon: String? { get set } + var email: String? { get set } var authToken: String? { get set } @@ -18,6 +20,16 @@ protocol LocalStorageProtocol { var sdkVersion: String? { get set } var offlineMode: Bool { get set } + + var anonymousUsageTrack: Bool { get set } + + var anonymousUserEvents: [[AnyHashable: Any]]? { get set } + + var anonymousUserUpdate: [AnyHashable: Any]? { get set } + + var criteriaData: Data? { get set } + + var anonymousSessions: IterableAnonSessionsWrapper? { get set } var isNotificationsEnabled: Bool { get set } diff --git a/swift-sdk/Internal/api-client/ApiClient.swift b/swift-sdk/Internal/api-client/ApiClient.swift index 26aebf1d9..f49c5d30c 100644 --- a/swift-sdk/Internal/api-client/ApiClient.swift +++ b/swift-sdk/Internal/api-client/ApiClient.swift @@ -41,6 +41,20 @@ class ApiClient { return apiCallRequest.convertToURLRequest(sentAt: currentDate) } + func convertToURLRequestWithoutCreatedAt(iterableRequest: IterableRequest) -> URLRequest? { + guard let authProvider = authProvider else { + return nil + } + + let currentDate = dateProvider.currentDate + let apiCallRequest = IterableAPICallRequest(apiKey: apiKey, + endpoint: endpoint, + authToken: authProvider.auth.authToken, + deviceMetadata: deviceMetadata, + iterableRequest: iterableRequest) + return apiCallRequest.convertToURLRequest(sentAt: currentDate) + } + func send(iterableRequestResult result: Result) -> Pending { switch result { case let .success(iterableRequest): @@ -50,6 +64,15 @@ class ApiClient { } } + func sendWithoutCreatedAt(iterableRequestResult result: Result) -> Pending { + switch result { + case let .success(iterableRequest): + return sendWithoutCreatedAt(iterableRequest: iterableRequest) + case let .failure(iterableError): + return SendRequestError.createErroredFuture(reason: iterableError.localizedDescription) + } + } + func send(iterableRequestResult result: Result) -> Pending where T: Decodable { switch result { case let .success(iterableRequest): @@ -67,6 +90,14 @@ class ApiClient { return RequestSender.sendRequest(urlRequest, usingSession: networkSession) } + func sendWithoutCreatedAt(iterableRequest: IterableRequest) -> Pending { + guard let urlRequest = convertToURLRequestWithoutCreatedAt(iterableRequest: iterableRequest) else { + return SendRequestError.createErroredFuture() + } + + return RequestSender.sendRequest(urlRequest, usingSession: networkSession) + } + func send(iterableRequest: IterableRequest) -> Pending where T: Decodable { guard let urlRequest = convertToURLRequest(iterableRequest: iterableRequest) else { return SendRequestError.createErroredFuture() @@ -96,6 +127,7 @@ class ApiClient { // MARK: - API REQUEST CALLS extension ApiClient: ApiClientProtocol { + func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool) -> Pending { let result = createRequestCreator().flatMap { $0.createRegisterTokenRequest(registerTokenInfo: registerTokenInfo, notificationsEnabled: notificationsEnabled) } @@ -130,6 +162,12 @@ extension ApiClient: ApiClientProtocol { return send(iterableRequestResult: result) } + func updateCart(items: [CommerceItem], createdAt: Int) -> Pending { + let result = createRequestCreator().flatMap { $0.createUpdateCartRequest(items: items, createdAt: createdAt) } + + return sendWithoutCreatedAt(iterableRequestResult: result) + } + func track(purchase total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?, @@ -143,6 +181,17 @@ extension ApiClient: ApiClientProtocol { return send(iterableRequestResult: result) } + func track(purchase total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + createdAt: Int) -> Pending { + let result = createRequestCreator().flatMap { $0.createTrackPurchaseRequest(total, + items: items, + dataFields: dataFields, + createdAt: createdAt) } + return send(iterableRequestResult: result) + } + func track(pushOpen campaignId: NSNumber, templateId: NSNumber?, messageId: String, appAlreadyRunning: Bool, dataFields: [AnyHashable: Any]?) -> Pending { let result = createRequestCreator().flatMap { $0.createTrackPushOpenRequest(campaignId, templateId: templateId, @@ -158,6 +207,18 @@ extension ApiClient: ApiClientProtocol { return send(iterableRequestResult: result) } + func track(event eventName: String, withBody body: [AnyHashable: Any]?) -> Pending { + let result = createRequestCreator().flatMap { $0.createTrackEventRequest(eventName, + withBody: body) } + return sendWithoutCreatedAt(iterableRequestResult: result) + } + + func track(event eventName: String, body: [AnyHashable: Any]?, dataFields: [AnyHashable: Any]?) -> Pending { + let result = createRequestCreator().flatMap { $0.createTrackEventRequest(eventName, + dataFields: dataFields) } + return send(iterableRequestResult: result) + } + func updateSubscriptions(_ emailListIds: [NSNumber]? = nil, unsubscribedChannelIds: [NSNumber]? = nil, unsubscribedMessageTypeIds: [NSNumber]? = nil, @@ -216,7 +277,21 @@ extension ApiClient: ApiClientProtocol { let result = createRequestCreator().flatMap { $0.createGetRemoteConfigurationRequest() } return send(iterableRequestResult: result) } + + func mergeUser(sourceEmail: String?, sourceUserId: String?, destinationEmail: String?, destinationUserId: String?) -> Pending { + let result = createRequestCreator().flatMap { $0.createMergeUserRequest(sourceEmail, sourceUserId, destinationEmail, destinationUserId) } + return send(iterableRequestResult: result) + } + func getCriteria() -> Pending { + let result = createRequestCreator().flatMap { $0.createGetCriteriaRequest() } + return send(iterableRequestResult: result) + } + + func trackAnonSession(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable: Any]?, requestJson: [AnyHashable: Any]) -> Pending { + let result = createRequestCreator().flatMap { $0.createTrackAnonSessionRequest(createdAt: createdAt, withUserId: userId, dataFields: dataFields, requestJson: requestJson) } + return send(iterableRequestResult: result) + } // MARK: - Embedded Messaging func getEmbeddedMessages() -> Pending { diff --git a/swift-sdk/Internal/api-client/ApiClientProtocol.swift b/swift-sdk/Internal/api-client/ApiClientProtocol.swift index ce3f377a6..a09fdeb53 100644 --- a/swift-sdk/Internal/api-client/ApiClientProtocol.swift +++ b/swift-sdk/Internal/api-client/ApiClientProtocol.swift @@ -13,12 +13,18 @@ protocol ApiClientProtocol: AnyObject { func updateCart(items: [CommerceItem]) -> Pending + func updateCart(items: [CommerceItem], createdAt: Int) -> Pending + func track(purchase total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?, campaignId: NSNumber?, templateId: NSNumber?) -> Pending + func track(purchase total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?, createdAt: Int) -> Pending + func track(pushOpen campaignId: NSNumber, templateId: NSNumber?, messageId: String, appAlreadyRunning: Bool, dataFields: [AnyHashable: Any]?) -> Pending func track(event eventName: String, dataFields: [AnyHashable: Any]?) -> Pending + func track(event eventName: String, withBody body: [AnyHashable: Any]?) -> Pending + func updateSubscriptions(_ emailListIds: [NSNumber]?, unsubscribedChannelIds: [NSNumber]?, unsubscribedMessageTypeIds: [NSNumber]?, @@ -45,7 +51,12 @@ protocol ApiClientProtocol: AnyObject { func disableDevice(forAllUsers allUsers: Bool, hexToken: String) -> Pending func getRemoteConfiguration() -> Pending + + func mergeUser(sourceEmail: String?, sourceUserId: String?, destinationEmail: String?, destinationUserId: String?) -> Pending + func getCriteria() -> Pending + + func trackAnonSession(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable: Any]?, requestJson: [AnyHashable: Any]) -> Pending func getEmbeddedMessages() -> Pending @discardableResult func track(embeddedMessageReceived message: IterableEmbeddedMessage) -> Pending diff --git a/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift b/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift index 60abce12e..70e47e364 100644 --- a/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift +++ b/swift-sdk/Internal/api-client/Request/OfflineRequestProcessor.swift @@ -49,6 +49,21 @@ struct OfflineRequestProcessor: RequestProcessorProtocol { identifier: #function) } + @discardableResult + func updateCart(items: [CommerceItem], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + let requestGenerator = { (requestCreator: RequestCreator) in + requestCreator.createUpdateCartRequest(items: items, createdAt: createdAt) + } + + return sendIterableRequest(requestGenerator: requestGenerator, + successHandler: onSuccess, + failureHandler: onFailure, + identifier: #function) + } + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -71,6 +86,26 @@ struct OfflineRequestProcessor: RequestProcessorProtocol { identifier: #function) } + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + let requestGenerator = { (requestCreator: RequestCreator) in + requestCreator.createTrackPurchaseRequest(total, + items: items, + dataFields: dataFields, + createdAt: createdAt) + } + + return sendIterableRequest(requestGenerator: requestGenerator, + successHandler: onSuccess, + failureHandler: onFailure, + identifier: #function) + } + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -110,6 +145,23 @@ struct OfflineRequestProcessor: RequestProcessorProtocol { identifier: #function) } + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]?, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + ITBInfo() + let requestGenerator = { (requestCreator: RequestCreator) in + requestCreator.createTrackEventRequest(event, + withBody: body) + } + + return sendIterableRequest(requestGenerator: requestGenerator, + successHandler: onSuccess, + failureHandler: onFailure, + identifier: #function) + } + @discardableResult func trackInAppOpen(_ message: IterableInAppMessage, location: InAppLocation, diff --git a/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift b/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift index af0a1d8c7..c332a46f7 100644 --- a/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift +++ b/swift-sdk/Internal/api-client/Request/OnlineRequestProcessor.swift @@ -85,6 +85,17 @@ struct OnlineRequestProcessor: RequestProcessorProtocol { requestIdentifier: "updateCart") } + @discardableResult + func updateCart(items: [CommerceItem], + createdAt: Int, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + sendRequest(requestProvider: { apiClient.updateCart(items: items, createdAt: createdAt) }, + successHandler: onSuccess, + failureHandler: onFailure, + requestIdentifier: "updateCart") + } + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -103,6 +114,16 @@ struct OnlineRequestProcessor: RequestProcessorProtocol { requestIdentifier: "trackPurchase") } + func trackPurchase(_ total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable : Any]?, createdAt: Int, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending { + sendRequest(requestProvider: { apiClient.track(purchase: total, + items: items, + dataFields: dataFields, + createdAt: createdAt)}, + successHandler: onSuccess, + failureHandler: onFailure, + requestIdentifier: "trackPurchase") + } + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -132,6 +153,17 @@ struct OnlineRequestProcessor: RequestProcessorProtocol { requestIdentifier: "trackEvent") } + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]? = nil, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + sendRequest(requestProvider: { apiClient.track(event: event, withBody: body) }, + successHandler: onSuccess, + failureHandler: onFailure, + requestIdentifier: "trackEvent") + } + @discardableResult func updateSubscriptions(info: UpdateSubscriptionsInfo, onSuccess: OnSuccessHandler? = nil, diff --git a/swift-sdk/Internal/api-client/Request/RequestCreator.swift b/swift-sdk/Internal/api-client/Request/RequestCreator.swift index 9a14fba24..18917507c 100644 --- a/swift-sdk/Internal/api-client/Request/RequestCreator.swift +++ b/swift-sdk/Internal/api-client/Request/RequestCreator.swift @@ -62,7 +62,7 @@ struct RequestCreator { setCurrentUser(inDict: &body) - if auth.email == nil, auth.userId != nil { + if auth.email == nil, (auth.userId != nil || auth.userIdAnon != nil) { body[JsonKey.preferUserId] = true } @@ -79,7 +79,7 @@ struct RequestCreator { setCurrentUser(inDict: &body) - if auth.email == nil, auth.userId != nil { + if auth.email == nil, (auth.userId != nil || auth.userIdAnon != nil) { body[JsonKey.preferUserId] = true } @@ -102,9 +102,33 @@ struct RequestCreator { let itemsToSerialize = items.map { $0.toDictionary() } - let body: [String: Any] = [JsonKey.Commerce.user: apiUserDict, + var body: [String: Any] = [JsonKey.Commerce.user: apiUserDict, + JsonKey.Commerce.items: itemsToSerialize] + + if auth.email == nil, (auth.userId != nil || auth.userIdAnon != nil) { + body[JsonKey.preferUserId] = true + } + return .success(.post(createPostRequest(path: Const.Path.updateCart, body: body))) + } + + func createUpdateCartRequest(items: [CommerceItem], createdAt: Int) -> Result { + if case .none = auth.emailOrUserId { + ITBError(Self.authMissingMessage) + return .failure(IterableError.general(description: Self.authMissingMessage)) + } + var apiUserDict = [AnyHashable: Any]() + + setCurrentUser(inDict: &apiUserDict) + let itemsToSerialize = items.map { $0.toDictionary() } + + var body: [String: Any] = [JsonKey.Commerce.user: apiUserDict, + JsonKey.Body.createdAt: createdAt, JsonKey.Commerce.items: itemsToSerialize] + if auth.email == nil, (auth.userId != nil || auth.userIdAnon != nil) { + body[JsonKey.preferUserId] = true + } + return .success(.post(createPostRequest(path: Const.Path.updateCart, body: body))) } @@ -128,6 +152,10 @@ struct RequestCreator { JsonKey.Commerce.items: itemsToSerialize, JsonKey.Commerce.total: total] + if auth.email == nil, (auth.userId != nil || auth.userIdAnon != nil) { + body[JsonKey.preferUserId] = true + } + if let dataFields = dataFields { body[JsonKey.dataFields] = dataFields } @@ -142,6 +170,37 @@ struct RequestCreator { return .success(.post(createPostRequest(path: Const.Path.trackPurchase, body: body))) } + func createTrackPurchaseRequest(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + createdAt: Int) -> Result { + if case .none = auth.emailOrUserId { + ITBError(Self.authMissingMessage) + return .failure(IterableError.general(description: Self.authMissingMessage)) + } + + var apiUserDict = [AnyHashable: Any]() + + setCurrentUser(inDict: &apiUserDict) + + let itemsToSerialize = items.map { $0.toDictionary() } + + var body: [String: Any] = [JsonKey.Commerce.user: apiUserDict, + JsonKey.Body.createdAt: createdAt, + JsonKey.Commerce.items: itemsToSerialize, + JsonKey.Commerce.total: total] + + if auth.email == nil, (auth.userId != nil || auth.userIdAnon != nil) { + body[JsonKey.preferUserId] = true + } + + if let dataFields = dataFields { + body[JsonKey.dataFields] = dataFields + } + + return .success(.post(createPostRequest(path: Const.Path.trackPurchase, body: body))) + } + func createTrackPushOpenRequest(_ campaignId: NSNumber, templateId: NSNumber?, messageId: String, appAlreadyRunning: Bool, dataFields: [AnyHashable: Any]?) -> Result { if case .none = auth.emailOrUserId { ITBError(Self.authMissingMessage) @@ -192,6 +251,24 @@ struct RequestCreator { return .success(.post(createPostRequest(path: Const.Path.trackEvent, body: body))) } + func createTrackEventRequest(_ eventName: String, withBody body: [AnyHashable: Any]?) -> Result { + if case .none = auth.emailOrUserId { + ITBError(Self.authMissingMessage) + return .failure(IterableError.general(description: Self.authMissingMessage)) + } + + var postBody = [AnyHashable: Any]() + if let _body = body { + postBody = _body + } + + setCurrentUser(inDict: &postBody) + + postBody.setValue(for: JsonKey.eventName, value: eventName) + + return .success(.post(createPostRequest(path: Const.Path.trackEvent, body: postBody))) + } + func createUpdateSubscriptionsRequest(_ emailListIds: [NSNumber]? = nil, unsubscribedChannelIds: [NSNumber]? = nil, unsubscribedMessageTypeIds: [NSNumber]? = nil, @@ -580,6 +657,51 @@ struct RequestCreator { return .success(.get(createGetRequest(forPath: Const.Path.getRemoteConfiguration, withArgs: args as! [String: String]))) } + func createMergeUserRequest(_ sourceEmail: String?, _ sourceUserId: String?, _ destinationEmail: String?, _ destinationUserId: String?) -> Result { + var body = [AnyHashable: Any]() + + if IterableUtil.isNotNullOrEmpty(string: sourceEmail) { + body.setValue(for: JsonKey.sourceEmail, value: sourceEmail) + } + + if IterableUtil.isNotNullOrEmpty(string: sourceUserId) { + body.setValue(for: JsonKey.sourceUserId, value: sourceUserId) + } + + if IterableUtil.isNotNullOrEmpty(string: destinationEmail) { + body.setValue(for: JsonKey.destinationEmail, value: destinationEmail) + } + + if IterableUtil.isNotNullOrEmpty(string: destinationUserId) { + body.setValue(for: JsonKey.destinationUserId, value: destinationUserId) + } + return .success(.post(createPostRequest(path: Const.Path.mergeUser, body: body))) + } + + func createGetCriteriaRequest() -> Result { + let body: [AnyHashable: Any] = [:] + return .success(.get(createGetRequest(forPath: Const.Path.getCriteria, withArgs: body as! [String: String]))) + } + + func createTrackAnonSessionRequest(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable: Any]?, requestJson: [AnyHashable: Any]) -> Result { + var body = [AnyHashable: Any]() + + var userDict = [AnyHashable: Any]() + userDict[JsonKey.userId] = userId + userDict[JsonKey.preferUserId] = true + userDict[JsonKey.mergeNestedObjects] = true + userDict[JsonKey.createNewFields] = true + if let dataFields = dataFields { + userDict[JsonKey.dataFields] = dataFields + } + + body.setValue(for: JsonKey.Commerce.user, value: userDict) + body.setValue(for: JsonKey.Body.createdAt, value: createdAt) + body.setValue(for: JsonKey.deviceInfo, value: deviceMetadata.asDictionary()) + body.setValue(for: JsonKey.anonSessionContext, value: requestJson) + return .success(.post(createPostRequest(path: Const.Path.trackAnonSession, body: body))) + } + // MARK: - PRIVATE private static let authMissingMessage = "Both email and userId are nil" @@ -612,6 +734,8 @@ struct RequestCreator { dict.setValue(for: JsonKey.email, value: email) case let .userId(userId): dict.setValue(for: JsonKey.userId, value: userId) + case let .userIdAnon(userId): + dict.setValue(for: JsonKey.userId, value: userId) case .none: ITBInfo("Current user is unavailable") } @@ -623,6 +747,8 @@ struct RequestCreator { dict.setValue(for: JsonKey.userKey, value: email) case let .userId(userId): dict.setValue(for: JsonKey.userKey, value: userId) + case let .userIdAnon(userId): + dict.setValue(for: JsonKey.userKey, value: userId) case .none: ITBInfo("Current user is unavailable") } diff --git a/swift-sdk/Internal/api-client/Request/RequestHandler.swift b/swift-sdk/Internal/api-client/Request/RequestHandler.swift index 913844cef..d414ab9c4 100644 --- a/swift-sdk/Internal/api-client/Request/RequestHandler.swift +++ b/swift-sdk/Internal/api-client/Request/RequestHandler.swift @@ -96,6 +96,19 @@ class RequestHandler: RequestHandlerProtocol { } } + @discardableResult + func updateCart(items: [CommerceItem], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + sendUsingRequestProcessor { processor in + processor.updateCart(items: items, + createdAt: createdAt, + onSuccess: onSuccess, + onFailure: onFailure) + } + } + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -115,6 +128,23 @@ class RequestHandler: RequestHandlerProtocol { } } + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + sendUsingRequestProcessor { processor in + processor.trackPurchase(total, + items: items, + dataFields: dataFields, + createdAt: createdAt, + onSuccess: onSuccess, + onFailure: onFailure) + } + } + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -147,6 +177,19 @@ class RequestHandler: RequestHandlerProtocol { } } + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]?, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + sendUsingRequestProcessor { processor in + processor.track(event: event, + withBody: body, + onSuccess: onSuccess, + onFailure: onFailure) + } + } + @discardableResult func updateSubscriptions(info: UpdateSubscriptionsInfo, onSuccess: OnSuccessHandler?, diff --git a/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift b/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift index 1243605b1..809fbc1d1 100644 --- a/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift +++ b/swift-sdk/Internal/api-client/Request/RequestProcessorProtocol.swift @@ -32,6 +32,12 @@ protocol RequestProcessorProtocol { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func updateCart(items: [CommerceItem], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -41,6 +47,14 @@ protocol RequestProcessorProtocol { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -56,6 +70,12 @@ protocol RequestProcessorProtocol { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]?, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackInAppOpen(_ message: IterableInAppMessage, location: InAppLocation, diff --git a/swift-sdk/IterableTokenGenerator.swift b/swift-sdk/IterableTokenGenerator.swift new file mode 100644 index 000000000..62c3040d1 --- /dev/null +++ b/swift-sdk/IterableTokenGenerator.swift @@ -0,0 +1,87 @@ +// +// IterableTokenGenerator.swift +// swift-sdk +// +// Created by Apple on 22/10/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import UIKit +import CryptoKit + +@objcMembers public final class IterableTokenGenerator: NSObject { + + public static func generateJwtForEial(secret: String, iat:Int, exp: Int, email:String) -> String { + struct Header: Encodable { + let alg = "HS256" + let typ = "JWT" + } + + struct Payload: Encodable { + var email = "" + var iat = Int(Date().timeIntervalSince1970) + var exp = Int(Date().timeIntervalSince1970) + 60 + + } + let headerJsonData = try! JSONEncoder().encode(Header()) + let headerBase64 = headerJsonData.urlEncodedBase64() + + let payloadJsonData = try! JSONEncoder().encode(Payload(email: email, iat: iat, exp: exp)) + let payloadBase64 = payloadJsonData.urlEncodedBase64() + + let toSign = Data((headerBase64 + "." + payloadBase64).utf8) + + if #available(iOS 13.0, *) { + let privateKey = SymmetricKey(data: Data(secret.utf8)) + let signature = HMAC.authenticationCode(for: toSign, using: privateKey) + let signatureBase64 = Data(signature).urlEncodedBase64() + + let token = [headerBase64, payloadBase64, signatureBase64].joined(separator: ".") + + return token + } + return "" + } + + public static func generateJwtForUserId(secret: String, iat:Int, exp: Int, userId:String) -> String { + struct Header: Encodable { + let alg = "HS256" + let typ = "JWT" + } + + struct Payload: Encodable { + var userId = "" + var iat = Int(Date().timeIntervalSince1970) + var exp = Int(Date().timeIntervalSince1970) + 60 + + } + let headerJsonData = try! JSONEncoder().encode(Header()) + let headerBase64 = headerJsonData.urlEncodedBase64() + + let payloadJsonData = try! JSONEncoder().encode(Payload(userId: userId, iat: iat, exp: exp)) + let payloadBase64 = payloadJsonData.urlEncodedBase64() + + let toSign = Data((headerBase64 + "." + payloadBase64).utf8) + + if #available(iOS 13.0, *) { + let privateKey = SymmetricKey(data: Data(secret.utf8)) + let signature = HMAC.authenticationCode(for: toSign, using: privateKey) + let signatureBase64 = Data(signature).urlEncodedBase64() + + let token = [headerBase64, payloadBase64, signatureBase64].joined(separator: ".") + + return token + } + return "" + } + +} + +extension Data { + func urlEncodedBase64() -> String { + return base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/swift-sdk/Resources/anoncriteria_response.json b/swift-sdk/Resources/anoncriteria_response.json new file mode 100644 index 000000000..67907a8a7 --- /dev/null +++ b/swift-sdk/Resources/anoncriteria_response.json @@ -0,0 +1,130 @@ +{ + "count":2, + "criteriaList":[ + { + "criteriaId":12345, + "searchQuery":{ + "combinator":"And", + "searchQueries":[ + { + "combinator":"And", + "searchQueries":[ + { + "dataType":"purchase", + "searchCombo":{ + "combinator":"And", + "searchQueries":[ + { + "field":"shoppingCartItems.price", + "fieldType":"double", + "comparatorType":"Equals", + "dataType":"purchase", + "id":2, + "value":"4.67" + }, + { + "field":"shoppingCartItems.quantity", + "fieldType":"long", + "comparatorType":"GreaterThan", + "dataType":"purchase", + "id":3, + "valueLong":2, + "value":"2" + }, + { + "field":"total", + "fieldType":"long", + "comparatorType":"GreaterThanOrEqualTo", + "dataType":"purchase", + "id":4, + "valueLong":10, + "value":"10" + } + ] + } + } + ] + }, + { + "combinator":"And", + "searchQueries":[ + { + "dataType":"customEvent", + "searchCombo":{ + "combinator":"Or", + "searchQueries":[ + { + "field":"eventName", + "fieldType":"string", + "comparatorType":"Equals", + "dataType":"customEvent", + "id":9, + "value":"processing_cancelled" + } + ] + } + } + ] + } + ] + } + }, + { + "criteriaId":5678, + "searchQuery":{ + "combinator":"Or", + "searchQueries":[ + { + "combinator":"Or", + "searchQueries":[ + { + "dataType":"user", + "searchCombo":{ + "combinator":"And", + "searchQueries":[ + { + "field":"itblInternal.emailDomain", + "fieldType":"string", + "comparatorType":"Equals", + "dataType":"user", + "id":6, + "value":"gmail.com" + } + ] + } + }, + { + "dataType":"customEvent", + "searchCombo":{ + "combinator":"And", + "searchQueries":[ + { + "field":"eventName", + "fieldType":"string", + "comparatorType":"Equals", + "dataType":"customEvent", + "id":9, + "value":"processing_cancelled" + }, + { + "field":"createdAt", + "fieldType":"date", + "comparatorType":"GreaterThan", + "dataType":"customEvent", + "id":10, + "dateRange":{ + + }, + "isRelativeDate":false, + "value":"1731513963000" + } + ] + } + } + ] + } + ] + } + } + ] +} diff --git a/swift-sdk/SDK/IterableAPI.swift b/swift-sdk/SDK/IterableAPI.swift index e5e655dcc..6f0f1d08f 100644 --- a/swift-sdk/SDK/IterableAPI.swift +++ b/swift-sdk/SDK/IterableAPI.swift @@ -7,7 +7,7 @@ import UIKit @objcMembers public final class IterableAPI: NSObject { /// The current SDK version - public static let sdkVersion = "6.5.11" + public static let sdkVersion = "6.6.0-beta3" /// The email of the logged in user that this IterableAPI is using public static var email: String? { @@ -125,16 +125,31 @@ import UIKit }.onError { _ in callback?(false) } + + if let implementation, config.enableAnonActivation, !implementation.isSDKInitialized(), implementation.getVisitorUsageTracked() { + ITBInfo("AUT ENABLED AND CONSENT GIVEN - Criteria fetched") + implementation.anonymousUserManager.getAnonCriteria() + implementation.anonymousUserManager.updateAnonSession() + } + } + + public static func setVisitorUsageTracked(isVisitorUsageTracked: Bool) { + if let _implementation = implementation { + _implementation.setVisitorUsageTracked(isVisitorUsageTracked: isVisitorUsageTracked) + } } + public static func getVisitorUsageTracked() -> Bool { + return implementation?.getVisitorUsageTracked() ?? false + } // MARK: - SDK - public static func setEmail(_ email: String?, _ authToken: String? = nil, _ successHandler: OnSuccessHandler? = nil, _ failureHandler: OnFailureHandler? = nil) { - implementation?.setEmail(email, authToken: authToken, successHandler: successHandler, failureHandler: failureHandler) + public static func setEmail(_ email: String?, _ authToken: String? = nil, _ identityResolution: IterableIdentityResolution? = nil, _ successHandler: OnSuccessHandler? = nil, _ failureHandler: OnFailureHandler? = nil) { + implementation?.setEmail(email, authToken: authToken, successHandler: successHandler, failureHandler: failureHandler, identityResolution: identityResolution) } - public static func setUserId(_ userId: String?, _ authToken: String? = nil, _ successHandler: OnSuccessHandler? = nil, _ failureHandler: OnFailureHandler? = nil) { - implementation?.setUserId(userId, authToken: authToken, successHandler: successHandler, failureHandler: failureHandler) + public static func setUserId(_ userId: String?, _ authToken: String? = nil, _ identityResolution: IterableIdentityResolution? = nil, _ successHandler: OnSuccessHandler? = nil, _ failureHandler: OnFailureHandler? = nil) { + implementation?.setUserId(userId, authToken: authToken, successHandler: successHandler, failureHandler: failureHandler, identityResolution: identityResolution) } /// Handle a Universal Link @@ -235,7 +250,7 @@ import UIKit /// - SeeAlso: IterableConfig @objc(registerToken:) public static func register(token: Data) { - implementation?.register(token: token) + register(token: token, onSuccess: nil, onFailure: nil) } /// Register this device's token with Iterable @@ -253,7 +268,8 @@ import UIKit /// - SeeAlso: IterableConfig, OnSuccessHandler, OnFailureHandler @objc(registerToken:onSuccess:OnFailure:) public static func register(token: Data, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) { - implementation?.register(token: token, onSuccess: onSuccess, onFailure: onFailure) + guard let implementation, implementation.isSDKInitialized() else { return } + implementation.register(token: token, onSuccess: onSuccess, onFailure: onFailure) } @objc(pauseAuthRetries:) @@ -272,12 +288,12 @@ import UIKit /// /// - SeeAlso: IterableConfig public static func disableDeviceForCurrentUser() { - implementation?.disableDeviceForCurrentUser() + disableDeviceForCurrentUser(withOnSuccess: nil, onFailure: nil) } /// Disable this device's token in Iterable, for all users on this device. public static func disableDeviceForAllUsers() { - implementation?.disableDeviceForAllUsers() + disableDeviceForAllUsers(withOnSuccess: nil, onFailure: nil) } /// Disable this device's token in Iterable, for the current user, with custom completion blocks @@ -288,7 +304,9 @@ import UIKit /// /// - SeeAlso: OnSuccessHandler, OnFailureHandler public static func disableDeviceForCurrentUser(withOnSuccess onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - implementation?.disableDeviceForCurrentUser(withOnSuccess: onSuccess, onFailure: onFailure) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.disableDeviceForCurrentUser(withOnSuccess: onSuccess, onFailure: onFailure) } /// Disable this device's token in Iterable, for all users of this device, with custom completion blocks. @@ -299,7 +317,9 @@ import UIKit /// /// - SeeAlso: OnSuccessHandler, OnFailureHandler public static func disableDeviceForAllUsers(withOnSuccess onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - implementation?.disableDeviceForAllUsers(withOnSuccess: onSuccess, onFailure: onFailure) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.disableDeviceForAllUsers(withOnSuccess: onSuccess, onFailure: onFailure) } /// Updates the available user fields @@ -316,10 +336,11 @@ import UIKit mergeNestedObjects: Bool, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) { + implementation?.updateUser(dataFields, - mergeNestedObjects: mergeNestedObjects, - onSuccess: onSuccess, - onFailure: onFailure) + mergeNestedObjects: mergeNestedObjects, + onSuccess: onSuccess, + onFailure: onFailure) } /// Updates the current user's email @@ -333,8 +354,17 @@ import UIKit /// /// - SeeAlso: OnSuccessHandler, OnFailureHandler @objc(updateEmail:onSuccess:onFailure:) - public static func updateEmail(_ newEmail: String, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - implementation?.updateEmail(newEmail, onSuccess: onSuccess, onFailure: onFailure) + public static func updateEmail( + _ newEmail: String, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler? + ) { + updateEmail( + newEmail, + withToken: nil, + onSuccess: onSuccess, + onFailure: onFailure + ) } /// Updates the current user's email, and set the new authentication token @@ -350,10 +380,17 @@ import UIKit /// - SeeAlso: OnSuccessHandler, OnFailureHandler @objc(updateEmail:withToken:onSuccess:onFailure:) public static func updateEmail(_ newEmail: String, - withToken token: String, + withToken token: String? = nil, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - implementation?.updateEmail(newEmail, withToken: token, onSuccess: onSuccess, onFailure: onFailure) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.updateEmail( + newEmail, + withToken: token, + onSuccess: onSuccess, + onFailure: onFailure + ) } /// Tracks what's in the shopping cart (or equivalent) at this point in time @@ -364,7 +401,7 @@ import UIKit /// - SeeAlso: CommerceItem @objc(updateCart:) public static func updateCart(items: [CommerceItem]) { - implementation?.updateCart(items: items) + updateCart(items: items, onSuccess: nil, onFailure: nil) } /// Tracks what's in the shopping cart (or equivalent) at this point in time @@ -379,6 +416,7 @@ import UIKit public static func updateCart(items: [CommerceItem], onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { + implementation?.updateCart(items: items, onSuccess: onSuccess, onFailure: onFailure) } @@ -391,7 +429,15 @@ import UIKit /// - SeeAlso: CommerceItem @objc(trackPurchase:items:) public static func track(purchase withTotal: NSNumber, items: [CommerceItem]) { - implementation?.trackPurchase(withTotal, items: items) + track( + purchase: withTotal, + items: items, + dataFields: nil, + campaignId: nil, + templateId: nil, + onSuccess: nil, + onFailure: nil + ) } /// Tracks a purchase with additional data @@ -404,7 +450,15 @@ import UIKit /// - SeeAlso: CommerceItem @objc(trackPurchase:items:dataFields:) public static func track(purchase withTotal: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?) { - implementation?.trackPurchase(withTotal, items: items, dataFields: dataFields) + track( + purchase: withTotal, + items: items, + dataFields: dataFields, + campaignId: nil, + templateId: nil, + onSuccess: nil, + onFailure: nil + ) } /// Tracks a purchase with additional data and custom completion blocks. @@ -423,11 +477,15 @@ import UIKit dataFields: [AnyHashable: Any]?, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - implementation?.trackPurchase(withTotal, - items: items, - dataFields: dataFields, - onSuccess: onSuccess, - onFailure: onFailure) + track( + purchase: withTotal, + items: items, + dataFields: dataFields, + campaignId: nil, + templateId: nil, + onSuccess: onSuccess, + onFailure: onFailure + ) } /// Tracks a purchase with additional data and custom completion blocks. @@ -450,13 +508,14 @@ import UIKit templateId: NSNumber?, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { + implementation?.trackPurchase(withTotal, - items: items, - dataFields: dataFields, - campaignId: campaignId, - templateId: templateId, - onSuccess: onSuccess, - onFailure: onFailure) + items: items, + dataFields: dataFields, + campaignId: campaignId, + templateId: templateId, + onSuccess: onSuccess, + onFailure: onFailure) } @@ -466,7 +525,12 @@ import UIKit /// - userInfo: the `userInfo` parameter from the push notification payload @objc(trackPushOpen:) public static func track(pushOpen userInfo: [AnyHashable: Any]) { - implementation?.trackPushOpen(userInfo) + track( + pushOpen: userInfo, + dataFields: nil, + onSuccess: nil, + onFailure: nil + ) } /// Tracks a `pushOpen` event with a push notification and optional additional data @@ -476,7 +540,12 @@ import UIKit /// - dataFields: A `Dictionary` containing any additional information to save along with the event @objc(trackPushOpen:dataFields:) public static func track(pushOpen userInfo: [AnyHashable: Any], dataFields: [AnyHashable: Any]?) { - implementation?.trackPushOpen(userInfo, dataFields: dataFields) + track( + pushOpen: userInfo, + dataFields: dataFields, + onSuccess: nil, + onFailure: nil + ) } /// Tracks a `pushOpen` event with a push notification, optional additional data, and custom completion blocks @@ -493,7 +562,9 @@ import UIKit dataFields: [AnyHashable: Any]?, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - implementation?.trackPushOpen(userInfo, + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.trackPushOpen(userInfo, dataFields: dataFields, onSuccess: onSuccess, onFailure: onFailure) @@ -517,11 +588,15 @@ import UIKit messageId: String, appAlreadyRunning: Bool, dataFields: [AnyHashable: Any]?) { - implementation?.trackPushOpen(campaignId, - templateId: templateId, - messageId: messageId, - appAlreadyRunning: appAlreadyRunning, - dataFields: dataFields) + track( + pushOpen: campaignId, + templateId: templateId, + messageId: messageId, + appAlreadyRunning: appAlreadyRunning, + dataFields: dataFields, + onSuccess: nil, + onFailure: nil + ) } /// Tracks a `pushOpen` event for the specified campaign and template IDs, whether the app was already @@ -546,7 +621,9 @@ import UIKit dataFields: [AnyHashable: Any]?, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) { - implementation?.trackPushOpen(campaignId, + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.trackPushOpen(campaignId, templateId: templateId, messageId: messageId, appAlreadyRunning: appAlreadyRunning, @@ -563,7 +640,12 @@ import UIKit /// - Remark: Pass in the custom event data. @objc(track:) public static func track(event eventName: String) { - implementation?.track(eventName) + track( + event: eventName, + dataFields: nil, + onSuccess: nil, + onFailure: nil + ) } /// Tracks a custom event @@ -575,7 +657,12 @@ import UIKit /// - Remark: Pass in the custom event data. @objc(track:dataFields:) public static func track(event eventName: String, dataFields: [AnyHashable: Any]?) { - implementation?.track(eventName, dataFields: dataFields) + track( + event: eventName, + dataFields: dataFields, + onSuccess: nil, + onFailure: nil + ) } /// Tracks a custom event @@ -613,7 +700,9 @@ import UIKit subscribedMessageTypeIds: [NSNumber]?, campaignId: NSNumber?, templateId: NSNumber?) { - implementation?.updateSubscriptions(emailListIds, + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.updateSubscriptions(emailListIds, unsubscribedChannelIds: unsubscribedChannelIds, unsubscribedMessageTypeIds: unsubscribedMessageTypeIds, subscribedMessageTypeIds: subscribedMessageTypeIds, @@ -630,17 +719,23 @@ import UIKit /// - embeddedSession: the embedded session data type to track @objc(embeddedSession:) public static func track(embeddedSession: IterableEmbeddedSession) { - implementation?.track(embeddedSession: embeddedSession) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.track(embeddedSession: embeddedSession) } @objc(embeddedMessageClick:buttonIdentifier:clickedUrl:) public static func track(embeddedMessageClick: IterableEmbeddedMessage, buttonIdentifier: String?, clickedUrl: String) { - implementation?.track(embeddedMessageClick: embeddedMessageClick, buttonIdentifier: buttonIdentifier, clickedUrl: clickedUrl) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.track(embeddedMessageClick: embeddedMessageClick, buttonIdentifier: buttonIdentifier, clickedUrl: clickedUrl) } @objc(embeddedMessageReceived:) public static func track(embeddedMessageReceived: IterableEmbeddedMessage) { - implementation?.track(embeddedMessageReceived: embeddedMessageReceived) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.track(embeddedMessageReceived: embeddedMessageReceived) } // MARK: In-App Notifications @@ -657,7 +752,9 @@ import UIKit /// - SeeAlso: IterableInAppDelegate @objc(trackInAppOpen:location:) public static func track(inAppOpen message: IterableInAppMessage, location: InAppLocation = .inApp) { - implementation?.trackInAppOpen(message, location: location) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.trackInAppOpen(message, location: location) } /// Tracks an `InAppClick` event @@ -671,7 +768,9 @@ import UIKit /// - clickedUrl: The URL of the button or link that was clicked @objc(trackInAppClick:location:clickedUrl:) public static func track(inAppClick message: IterableInAppMessage, location: InAppLocation = .inApp, clickedUrl: String) { - implementation?.trackInAppClick(message, location: location, clickedUrl: clickedUrl) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.trackInAppClick(message, location: location, clickedUrl: clickedUrl) } /// Tracks an `InAppClose` event @@ -681,7 +780,9 @@ import UIKit /// - clickedUrl: The url that was clicked to close the in-app. It will be `nil` when the message is closed by clicking `back`. @objc(trackInAppClose:clickedUrl:) public static func track(inAppClose message: IterableInAppMessage, clickedUrl: String?) { - implementation?.trackInAppClose(message, clickedUrl: clickedUrl) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.trackInAppClose(message, clickedUrl: clickedUrl) } /// Tracks an `InAppClose` event @@ -692,7 +793,9 @@ import UIKit /// - clickedUrl: The URL that was clicked to close the in-app. It will be `nil` when the message is closed by clicking `back`. @objc(trackInAppClose:location:clickedUrl:) public static func track(inAppClose message: IterableInAppMessage, location: InAppLocation, clickedUrl: String?) { - implementation?.trackInAppClose(message, location: location, clickedUrl: clickedUrl) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.trackInAppClose(message, location: location, clickedUrl: clickedUrl) } /// Tracks an `InAppClose` event @@ -704,7 +807,9 @@ import UIKit /// - clickedUrl: The url that was clicked to close the in-app. It will be `nil` when the message is closed by clicking `back`. @objc(trackInAppClose:location:source:clickedUrl:) public static func track(inAppClose message: IterableInAppMessage, location: InAppLocation, source: InAppCloseSource, clickedUrl: String?) { - implementation?.trackInAppClose(message, location: location, source: source, clickedUrl: clickedUrl) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.trackInAppClose(message, location: location, source: source, clickedUrl: clickedUrl) } /// Consumes the notification and removes it from the list of in-app messages @@ -714,7 +819,9 @@ import UIKit /// - location: The location from where this message was shown. `inbox` or `inApp`. @objc(inAppConsume:location:) public static func inAppConsume(message: IterableInAppMessage, location: InAppLocation = .inApp) { - implementation?.inAppConsume(message: message, location: location) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.inAppConsume(message: message, location: location) } /// Consumes the notification and removes it from the list of in-app messages @@ -725,7 +832,9 @@ import UIKit /// - source: The source of deletion `inboxSwipe` or `deleteButton`. @objc(inAppConsume:location:source:) public static func inAppConsume(message: IterableInAppMessage, location: InAppLocation = .inApp, source: InAppDeleteSource) { - implementation?.inAppConsume(message: message, location: location, source: source) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.inAppConsume(message: message, location: location, source: source) } /// Tracks analytics data from a session of using an inbox UI @@ -735,7 +844,9 @@ import UIKit /// - inboxSession: the inbox session data type to track @objc(trackInboxSession:) public static func track(inboxSession: IterableInboxSession) { - implementation?.track(inboxSession: inboxSession) + guard let implementation, implementation.isSDKInitialized() else { return } + + implementation.track(inboxSession: inboxSession) } // MARK: - Private/Internal diff --git a/swift-sdk/SDK/IterableConfig.swift b/swift-sdk/SDK/IterableConfig.swift index 62f89c14e..f2f5fdd7a 100644 --- a/swift-sdk/SDK/IterableConfig.swift +++ b/swift-sdk/SDK/IterableConfig.swift @@ -74,6 +74,11 @@ public struct IterableAPIMobileFrameworkInfo: Codable { @objc func onAuthFailure(_ authFailure: AuthFailure) } +/// The delegate for getting the UserId once annon session tracked +@objc public protocol IterableAnonUserDelegate: AnyObject { + @objc func onAnonUserCreated(userId: String) +} + /// Iterable Configuration Object. Use this when initializing the API. @objcMembers public class IterableConfig: NSObject { @@ -100,7 +105,11 @@ public class IterableConfig: NSObject { /// Implement this protocol to enable token-based authentication with the Iterable SDK public weak var authDelegate: IterableAuthDelegate? - + + /// Implement this protocol to get userId once the userId set for AnonUser + public weak var anonUserDelegate: IterableAnonUserDelegate? + + /// When set to `true`, IterableSDK will automatically register and deregister /// notification tokens. public var autoPushRegistration = true @@ -147,8 +156,20 @@ public class IterableConfig: NSObject { /// Sets data region which determines data center and endpoints used by the SDK public var dataRegion: String = IterableDataRegion.US + /// When set to `true`, IterableSDK will track all events when users are not logged into the application. + public var enableAnonActivation = true + + /// Enables fetching of anonymous user criteria on foreground when set to `true` + /// By default, the SDK will fetch anonymous user criteria on foreground. + public var enableForegroundCriteriaFetch = true + /// Allows for fetching embedded messages. public var enableEmbeddedMessaging = false + + // How many events can be stored in the local storage. By default limt is 100. + public var eventThresholdLimit: Int = 100 + + public var identityResolution: IterableIdentityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnAnonymousToKnown: true) /// The type of mobile framework we are using. public var mobileFrameworkInfo: IterableAPIMobileFrameworkInfo? diff --git a/tests/common/MockLocalStorage.swift b/tests/common/MockLocalStorage.swift index 56aaed4f0..6a9ef2455 100644 --- a/tests/common/MockLocalStorage.swift +++ b/tests/common/MockLocalStorage.swift @@ -7,6 +7,15 @@ import Foundation @testable import IterableSDK class MockLocalStorage: LocalStorageProtocol { + + var userIdAnnon: String? + + var anonymousUserEvents: [[AnyHashable : Any]]? + + var criteriaData: Data? + + var anonymousSessions: IterableSDK.IterableAnonSessionsWrapper? + var userId: String? = nil var email: String? = nil @@ -20,7 +29,11 @@ class MockLocalStorage: LocalStorageProtocol { var sdkVersion: String? = nil var offlineMode: Bool = false + + var anonymousUsageTrack: Bool = true + var anonymousUserUpdate: [AnyHashable : Any]? + var isNotificationsEnabled: Bool = false var hasStoredNotificationSetting: Bool = false diff --git a/tests/offline-events-tests/RequestHandlerTests.swift b/tests/offline-events-tests/RequestHandlerTests.swift index c32e1f51a..23d7e6a46 100644 --- a/tests/offline-events-tests/RequestHandlerTests.swift +++ b/tests/offline-events-tests/RequestHandlerTests.swift @@ -1178,7 +1178,7 @@ class RequestHandlerTests: XCTestCase { extension RequestHandlerTests: AuthProvider { var auth: Auth { - Auth(userId: nil, email: "user@example.com", authToken: nil) + Auth(userId: nil, email: "user@example.com", authToken: nil, userIdAnon: nil) } } diff --git a/tests/offline-events-tests/TaskProcessorTests.swift b/tests/offline-events-tests/TaskProcessorTests.swift index 4b3195886..6af222cb9 100644 --- a/tests/offline-events-tests/TaskProcessorTests.swift +++ b/tests/offline-events-tests/TaskProcessorTests.swift @@ -14,7 +14,7 @@ class TaskProcessorTests: XCTestCase { let dataFields = ["var1": "val1", "var2": "val2"] let expectation1 = expectation(description: #function) - let auth = Auth(userId: nil, email: email, authToken: nil) + let auth = Auth(userId: nil, email: email, authToken: nil, userIdAnon: nil) let config = IterableConfig() let networkSession = MockNetworkSession() let internalAPI = InternalIterableAPI.initializeForTesting(apiKey: apiKey, config: config, networkSession: networkSession) @@ -221,7 +221,7 @@ class TaskProcessorTests: XCTestCase { let eventName = "CustomEvent1" let dataFields = ["var1": "val1", "var2": "val2"] - let auth = Auth(userId: nil, email: email, authToken: nil) + let auth = Auth(userId: nil, email: email, authToken: nil, userIdAnon: nil) let requestCreator = RequestCreator(auth: auth, deviceMetadata: deviceMetadata) guard case let Result.success(trackEventRequest) = requestCreator.createTrackEventRequest(eventName, dataFields: dataFields) else { diff --git a/tests/offline-events-tests/TaskRunnerTests.swift b/tests/offline-events-tests/TaskRunnerTests.swift index 7ac298dd4..ecab14cde 100644 --- a/tests/offline-events-tests/TaskRunnerTests.swift +++ b/tests/offline-events-tests/TaskRunnerTests.swift @@ -418,6 +418,6 @@ class TaskRunnerTests: XCTestCase { extension TaskRunnerTests: AuthProvider { var auth: Auth { - Auth(userId: nil, email: "user@example.com", authToken: nil) + Auth(userId: nil, email: "user@example.com", authToken: nil, userIdAnon: nil) } } diff --git a/tests/offline-events-tests/TaskSchedulerTests.swift b/tests/offline-events-tests/TaskSchedulerTests.swift index 59dd0a0a4..74003cebe 100644 --- a/tests/offline-events-tests/TaskSchedulerTests.swift +++ b/tests/offline-events-tests/TaskSchedulerTests.swift @@ -124,6 +124,6 @@ class TaskSchedulerTests: XCTestCase { extension TaskSchedulerTests: AuthProvider { var auth: Auth { - Auth(userId: nil, email: "user@example.com", authToken: nil) + Auth(userId: nil, email: "user@example.com", authToken: nil, userIdAnon: nil) } } diff --git a/tests/unit-tests/AnonymousUserComplexCriteriaMatchTests.swift b/tests/unit-tests/AnonymousUserComplexCriteriaMatchTests.swift new file mode 100644 index 000000000..e6a2b6bbc --- /dev/null +++ b/tests/unit-tests/AnonymousUserComplexCriteriaMatchTests.swift @@ -0,0 +1,603 @@ +// +// AnonymousUserComplexCriteriaMatchTests.swift +// unit-tests +// +// Created by vishwa on 26/06/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest + +@testable import IterableSDK + +class AnonymousUserComplexCriteriaMatchTests: XCTestCase { + + private let mockDataForCriteria1 = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "49", + "name": "updateCart", + "createdAt": 1716561779683, + "updatedAt": 1717423966940, + "searchQuery": { + "combinator": "Or", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "eventName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 23, + "value": "button-clicked" + }, + { + "field": "button-clicked.animal", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 25, + "value": "giraffe" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.price", + "fieldType": "double", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "customEvent", + "id": 28, + "value": "120" + }, + { + "field": "updateCart.updatedShoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "customEvent", + "id": 29, + "valueLong": 100, + "value": "100" + } + ] + } + } + ] + }, + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 31, + "value": "monitor" + }, + { + "field": "shoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "purchase", + "id": 32, + "valueLong": 5, + "value": "5" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "country", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 34, + "value": "Japan" + }, + { + "field": "preferred_car_models", + "fieldType": "string", + "comparatorType": "Contains", + "dataType": "user", + "id": 36, + "value": "Honda" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + private let mockDataForCriteria2 = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "51", + "name": "Contact Property", + "createdAt": 1716561944428, + "updatedAt": 1716561944428, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "eventName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 2, + "value": "button-clicked" + }, + { + "field": "button-clicked.lastPageViewed", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 4, + "value": "welcome page" + } + ] + } + }, + { + "dataType": "customEvent", + "minMatch": 2, + "maxMatch": 3, + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.price", + "fieldType": "double", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "customEvent", + "id": 6, + "value": "85" + }, + { + "field": "updateCart.updatedShoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "customEvent", + "id": 7, + "valueLong": 50, + "value": "50" + } + ] + } + } + ] + }, + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 16, + "isFiltering": false, + "value": "coffee" + }, + { + "field": "shoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "purchase", + "id": 17, + "valueLong": 2, + "value": "2" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "country", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 19, + "value": "USA" + }, + { + "field": "preferred_car_models", + "fieldType": "string", + "comparatorType": "Contains", + "dataType": "user", + "id": 21, + "value": "Subaru" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + private let mockDataForCriteria3 = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "50", + "name": "purchase", + "createdAt": 1716561874633, + "updatedAt": 1716561874633, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "eventName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 2, + "value": "button-clicked" + }, + { + "field": "button-clicked.lastPageViewed", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 4, + "value": "welcome page" + } + ] + } + }, + { + "dataType": "customEvent", + "minMatch": 2, + "maxMatch": 3, + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.price", + "fieldType": "double", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "customEvent", + "id": 6, + "value": "85" + }, + { + "field": "updateCart.updatedShoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "customEvent", + "id": 7, + "valueLong": 50, + "value": "50" + } + ] + } + }, + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 9, + "value": "coffee" + }, + { + "field": "shoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "purchase", + "id": 10, + "valueLong": 2, + "value": "2" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "country", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 12, + "value": "USA" + }, + { + "field": "preferred_car_models", + "fieldType": "string", + "comparatorType": "Contains", + "dataType": "user", + "id": 14, + "value": "Subaru" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + private let mockDataForCriteria4 = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "48", + "name": "Custom event", + "createdAt": 1716561634904, + "updatedAt": 1716561634904, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 1, + "value": "sneakers" + }, + { + "field": "shoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "LessThanOrEqualTo", + "dataType": "purchase", + "id": 2, + "valueLong": 3, + "value": "3" + } + ] + } + } + ] + }, + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 4, + "value": "slippers" + }, + { + "field": "shoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "purchase", + "id": 5, + "valueLong": 3, + "value": "3" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareDataWithCriteria1Success() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["animal": "giraffe"] + ], [ + "items": [["id": "12", "name": "keyboard", "price": 130, "quantity": 110]], + "createdAt": 1699246745093, + "dataType": "updateCart", + ]] + let expectedCriteriaId = "49" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForCriteria1)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithCriteria1Failure() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["animal": "giraffe22"] + ], [ + "items": [["id": "12", "name": "keyboard", "price": 130, "quantity": 110]], + "createdAt": 1699246745093, + "dataType": "updateCart", + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForCriteria1)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCriteria2Success() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["lastPageViewed": "welcome page"] + ], ["dataType": "user", "createdAt": 1699246745093, "phone_number": "999999", "country": "USA", "dataFields": ["preferred_car_models": "Subaru"] + ]] + let expectedCriteriaId = "51" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForCriteria2)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithCriteria2Failure() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["lastPageViewed": "welcome page"] + ], ["dataType": "user", "createdAt": 1699246745093, "phone_number": "999999", "country": "USA", "dataFields": ["preferred_car_models": "Mazda"] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForCriteria2)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCriteria3Success() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "items": [["id": "12", "name": "keyboard", "price": 90, "quantity": 60]], + "createdAt": 1699246745093, + "dataType": "updateCart" + ], [ + "items": [["id": "121", "name": "keyboard2", "price": 100, "quantity": 80]], + "createdAt": 1699246745093, + "dataType": "updateCart" + ], [ + "items": [["id": "12", "name": "coffee", "price": 4.67, "quantity": 3]], + "total": 11.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ], [ + "dataType": "user", "createdAt": 1699246745093, "dataFields": [ "phone_number": "999999", "country": "USA", "preferred_car_models": "Subaru"] + ], [ + "dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["lastPageViewed": "welcome page"] + ], ] + + let expectedCriteriaId = "50" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForCriteria3)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + + } + + func testCompareDataWithCriteria3Failure() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked2", "dataFields": ["lastPageViewed": "welcome page"] + ], [ + "items": [["id": "12", "name": "keyboard", "price": 90, "quantity": 60]], + "createdAt": 1699246745093, + "dataType": "updateCart" + ], [ + "items": [["id": "121", "name": "keyboard2", "price": 100, "quantity": 80]], + "createdAt": 1699246745093, + "dataType": "updateCart" + ], [ + "items": [["id": "12", "name": "coffee", "price": 4.67, "quantity": 3]], + "total": 11.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ], [ + "dataType": "user", "createdAt": 1699246745093, "phone_number": "999999", "country": "US", "dataFields": ["preferred_car_models": "Subaru"] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForCriteria3)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCriteria4Success() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "slippers", "price": 4.67, "quantity": 5]], + "total": 11.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let expectedCriteriaId = "48" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForCriteria4)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithCriteria4Failure() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "sneakers", "price": 4.67, "quantity": 2]], + "total": 11.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForCriteria4)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + +} + diff --git a/tests/unit-tests/AnonymousUserCriteriaIsSetTests.swift b/tests/unit-tests/AnonymousUserCriteriaIsSetTests.swift new file mode 100644 index 000000000..f68a242f9 --- /dev/null +++ b/tests/unit-tests/AnonymousUserCriteriaIsSetTests.swift @@ -0,0 +1,353 @@ +// +// File.swift +// +// +// Created by vishwa on 27/06/24. +// + +import XCTest + +@testable import IterableSDK + +class AnonymousUserCriteriaIsSetTests: XCTestCase { + + private let mockDataUserProperty = """ + { + "count": 1, + "criteriaSets": [ + + { + "criteriaId": "1", + "name": "Custom event", + "createdAt": 1716561634904, + "updatedAt": 1716561634904, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "country", + "fieldType": "string", + "comparatorType": "IsSet", + "dataType": "user", + "id": 25, + "value": "" + }, + { + "field": "eventTimeStamp", + "fieldType": "long", + "comparatorType": "IsSet", + "dataType": "user", + "id": 26, + "valueLong": null, + "value": "" + }, + { + "field": "phoneNumberDetails", + "fieldType": "object", + "comparatorType": "IsSet", + "dataType": "user", + "id": 28, + "value": "" + }, + { + "field": "shoppingCartItems.price", + "fieldType": "double", + "comparatorType": "IsSet", + "dataType": "user", + "id": 30, + "value": "" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + private let mockDataCustomEvent = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "1", + "name": "updateCart", + "createdAt": 1716561779683, + "updatedAt": 1717423966940, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "button-clicked", + "fieldType": "object", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 2, + "value": "" + }, + { + "field": "button-clicked.animal", + "fieldType": "string", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 4, + "value": "" + }, + { + "field": "button-clicked.clickCount", + "fieldType": "long", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 5, + "valueLong": null, + "value": "" + }, + { + "field": "total", + "fieldType": "double", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 9, + "value": "" + } + ] + } + } + ] + } + ] + } + }, + + ] + } + """ + + private let mockDataPurchase = """ + { + "count": 1, + "criteriaSets": [ + + { + "criteriaId": "1", + "name": "purchase", + "createdAt": 1716561874633, + "updatedAt": 1716561874633, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems", + "fieldType": "object", + "comparatorType": "IsSet", + "dataType": "purchase", + "id": 1, + "value": "" + }, + { + "field": "shoppingCartItems.price", + "fieldType": "double", + "comparatorType": "IsSet", + "dataType": "purchase", + "id": 3, + "value": "" + }, + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "IsSet", + "dataType": "purchase", + "id": 5, + "value": "" + }, + { + "field": "total", + "fieldType": "double", + "comparatorType": "IsSet", + "dataType": "purchase", + "id": 7, + "value": "" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + private let mockDataUpdateCart = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "1", + "name": "Contact Property", + "createdAt": 1716561944428, + "updatedAt": 1716561944428, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart", + "fieldType": "object", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 9, + "value": "" + }, + { + "field": "updateCart.updatedShoppingCartItems.name", + "fieldType": "string", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 13, + "value": "" + }, + { + "field": "updateCart.updatedShoppingCartItems.price", + "fieldType": "double", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 15, + "value": "" + }, + { + "field": "updateCart.updatedShoppingCartItems.quantity", + "fieldType": "long", + "comparatorType": "IsSet", + "dataType": "customEvent", + "id": 16, + "valueLong": null, + "value": "" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareDataIsSetUserPropertySuccess() { + let eventItems: [[AnyHashable: Any]] = [["dataType": "user", "createdAt": 1699246745093, "phoneNumberDetails": "999999", "country": "UK", "eventTimeStamp": "1234567890", "shoppingCartItems.price": "33"]] + let expectedCriteriaId = "1" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataUserProperty)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataIsSetUserPropertyFailure() { + let eventItems: [[AnyHashable: Any]] = [["dataType": "user", "createdAt": 1699246745093, "phoneNumberDetails": "999999", "country": "", "eventTimeStamp": "", "shoppingCartItems.price": "33"]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataUserProperty)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + func testCompareDataIsSetCustomEventSuccess() { + let eventItems: [[AnyHashable: Any]] = [["dataType": "customEvent", "eventName":"button-clicked", "dataFields": ["button-clicked":"cc", "animal": "aa", "clickCount": "1", "total": "10"]]] + let expectedCriteriaId = "1" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCustomEvent)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataIsSetCustomEventFailure() { + let eventItems: [[AnyHashable: Any]] = [["dataType": "customEvent", "eventName":"vvv", "dataFields": ["button-clicked":"", "animal": "", "clickCount": "1", "total": "10"]]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCustomEvent)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataIsSetPurchaseSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "coffee", "price": 4.67, "quantity": 3]], + "total": 11.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let expectedCriteriaId = "1" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataPurchase)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataIsSetPurchaseFailure() { + let eventItems: [[AnyHashable: Any]] = [ [ + "items": [["id": "12", "name": "coffee", "price": 4.67, "quantity": 3]], + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataPurchase)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataIsSetUpdateCartSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "keyboard", "price": 90, "quantity": 60]], + "createdAt": 1699246745093, + "dataType": "updateCart" + ]] + let expectedCriteriaId = "1" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataUpdateCart)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataIsSetUpdateCartFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "keyboard", "price": 90]], + "createdAt": 1699246745093, + "dataType": "updateCart" + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataUpdateCart)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } +} diff --git a/tests/unit-tests/AnonymousUserCriteriaMatchTests.swift b/tests/unit-tests/AnonymousUserCriteriaMatchTests.swift new file mode 100644 index 000000000..3925ee937 --- /dev/null +++ b/tests/unit-tests/AnonymousUserCriteriaMatchTests.swift @@ -0,0 +1,364 @@ +// +// File.swift +// +// +// Created by HARDIK MASHRU on 14/11/23. +// + +import XCTest + +@testable import IterableSDK + +class AnonymousUserCriteriaMatchTests: XCTestCase { + + private let mockData = """ + { + "count": 4, + "criteriaSets": [ + { + "criteriaId": "49", + "name": "updateCart", + "createdAt": 1716561779683, + "updatedAt": 1717423966940, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "Equals", + "value": "updateCart", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "updateCart.updatedShoppingCartItems.price", + "comparatorType": "Equals", + "value": "10.0", + "fieldType": "double" + } + ] + }, + "minMatch": 2, + "maxMatch": 3 + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "Equals", + "value": "updateCart", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "updateCart.updatedShoppingCartItems.quantity", + "comparatorType": "GreaterThanOrEqualTo", + "value": "50", + "fieldType": "long" + }, + { + "dataType": "customEvent", + "field": "updateCart.updatedShoppingCartItems.price", + "comparatorType": "GreaterThanOrEqualTo", + "value": "50", + "fieldType": "long" + } + ] + } + } + ] + } + ] + } + }, + { + "criteriaId": "51", + "name": "Contact Property", + "createdAt": 1716561944428, + "updatedAt": 1716561944428, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "country", + "comparatorType": "Equals", + "value": "UK", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "preferred_car_models", + "comparatorType": "Contains", + "value": "Mazda", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + }, + { + "criteriaId": "50", + "name": "purchase", + "createdAt": 1716561874633, + "updatedAt": 1716561874633, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "shoppingCartItems.name", + "comparatorType": "Equals", + "value": "keyboard", + "fieldType": "string" + }, + { + "field":"shoppingCartItems.price", + "fieldType":"double", + "comparatorType":"Equals", + "dataType":"purchase", + "id":2, + "value":"4.67" + } + ] + } + }, + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "shoppingCartItems.quantity", + "comparatorType": "GreaterThanOrEqualTo", + "value": "3", + "fieldType": "long" + } + ] + } + } + ] + } + ] + } + }, + { + "criteriaId": "48", + "name": "Custom event", + "createdAt": 1716561634904, + "updatedAt": 1716561634904, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "Equals", + "value": "button-clicked", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "button-clicked.lastPageViewed", + "comparatorType": "Equals", + "value": "signup page", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareDataWithUserCriteriaSuccess() { + let eventItems: [[AnyHashable: Any]] = [["dataType": "user", "createdAt": 1699246745093, "phone_number": "999999", "country": "UK"]] + let expectedCriteriaId = "51" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithUserCriteriaFailure() { + let eventItems: [[AnyHashable: Any]] = [["dataType": "user", "createdAt": 1699246745093, "phone_number": "999999", "country": "US"]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCustomEventCriteriaSuccess() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["lastPageViewed": "signup page"] + ]] + let expectedCriteriaId = "48" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithCustomEventCriteriaFailure() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button", "dataFields": ["lastPageViewed": "signup page"] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithUpdateCartCriteriaSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "keyboard", "price": 50, "quantity": 60]], + "createdAt": 1699246745093, + "dataType": "updateCart", + ]] + let expectedCriteriaId = "49" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + + } + + func testCompareDataWithUpdateCartCriteriaFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "keyboard", "price": 40, "quantity": 3]], + "createdAt": 1699246745093, + "dataType": "customEvent", + "dataFields": ["campaignId": "1234"] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithMinMatchCriteriaSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "keyboard", "price": 10.0, "quantity": 3]], + "createdAt": 1699246745093, + "dataType": "updateCart", + ],[ + "items": [["id": "13", "name": "keyboard2", "price": 10.0, "quantity": 4]], + "createdAt": 1699246745093, + "dataType": "updateCart"]] + let expectedCriteriaId = "49" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithMinMatchCriteriaFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "keyboard", "price": 10, "quantity": 3]], + "createdAt": 1699246745093, + "dataType": "customEvent", + "dataFields": ["campaignId": "1234"] + ],["dataType": "customEvent", "eventName": "processing_cancelled"]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithANDCombinatorSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "keyboard", "price": 4.67, "quantity": 3]], + "total": 11.0, + "createdAt": 1699246745093, + "dataType": "purchase", + "dataFields": ["campaignId": "1234"] + ]] + let expectedCriteriaId = "50" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + + } + + func testCompareDataWithANDCombinatorFail() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "Mocha", "price": 4.67, "quantity": 2]], + "total": 9.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + func testCompareDataWithORCombinatorSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "Mocha", "price": 5.9, "quantity": 4]], + "total": 9.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let expectedCriteriaId = "50" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithORCombinatorFail() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "Mocha", "price": 2.9, "quantity": 1]], + "total": 9.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } +} diff --git a/tests/unit-tests/BlankApiClient.swift b/tests/unit-tests/BlankApiClient.swift index 5ce30d227..f877a7017 100644 --- a/tests/unit-tests/BlankApiClient.swift +++ b/tests/unit-tests/BlankApiClient.swift @@ -7,6 +7,37 @@ import Foundation @testable import IterableSDK class BlankApiClient: ApiClientProtocol { + + func updateCart(items: [IterableSDK.CommerceItem], createdAt: Int) -> IterableSDK.Pending { + Pending() + } + + func track(purchase total: NSNumber, items: [IterableSDK.CommerceItem], dataFields: [AnyHashable : Any]?, createdAt: Int) -> IterableSDK.Pending { + Pending() + } + + func mergeUser(sourceEmail: String?, sourceUserId: String?, destinationEmail: String?, destinationUserId: String?) -> IterableSDK.Pending { + Pending() + } + + func trackAnonSession(createdAt: Int, withUserId userId: String, dataFields: [AnyHashable : Any]?, requestJson: [AnyHashable : Any]) -> IterableSDK.Pending { + Pending() + } + + func getCriteria() -> IterableSDK.Pending { + Pending() + } + + + func track(event eventName: String, dataFields: [AnyHashable : Any]?) -> IterableSDK.Pending { + Pending() + } + + func track(event eventName: String, withBody body: [AnyHashable : Any]?) -> IterableSDK.Pending { + Pending() + } + + func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool) -> Pending { Pending() } @@ -31,10 +62,6 @@ class BlankApiClient: ApiClientProtocol { Pending() } - func track(event eventName: String, dataFields: [AnyHashable : Any]?) -> Pending { - Pending() - } - func updateSubscriptions(_ emailListIds: [NSNumber]?, unsubscribedChannelIds: [NSNumber]?, unsubscribedMessageTypeIds: [NSNumber]?, subscribedMessageTypeIds: [NSNumber]?, campaignId: NSNumber?, templateId: NSNumber?) -> Pending { Pending() } @@ -79,6 +106,10 @@ class BlankApiClient: ApiClientProtocol { Pending() } + func mergeUser(sourceEmail: String, sourceUserId: String, destinationEmail: String, destinationUserId: String) -> IterableSDK.Pending { + Pending() + } + func getEmbeddedMessages() -> Pending { Pending() } diff --git a/tests/unit-tests/CombinationComplexCriteria.swift b/tests/unit-tests/CombinationComplexCriteria.swift new file mode 100644 index 000000000..57fb85fdf --- /dev/null +++ b/tests/unit-tests/CombinationComplexCriteria.swift @@ -0,0 +1,594 @@ +// +// CombinationComplexCriteria.swift +// unit-tests +// +// Created by Apple on 05/09/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class CombinationComplexCriteria: XCTestCase { + //MARK: Comparator test For End + private let mockDataComplexCriteria1 = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "290", + "name": "Complex Criteria Unit Test #1", + "createdAt": 1722532861551, + "updatedAt": 1722532861551, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "A", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "B", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "C", + "fieldType": "string" + } + ] + } + } + ] + }, + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "IsSet", + "value": "", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "saved_cars.color", + "comparatorType": "IsSet", + "value": "", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "IsSet", + "value": "", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "animal-found.vaccinated", + "comparatorType": "Equals", + "value": "true", + "fieldType": "boolean" + } + ] + } + } + ] + }, + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "total", + "comparatorType": "LessThanOrEqualTo", + "value": "100", + "fieldType": "double" + } + ] + } + }, + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "reason", + "comparatorType": "Equals", + "value": "testing", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testComplexCriteria1Success() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["firstName": "Alex"] + ], + ["dataType": "customEvent", + "eventName": "saved_cars", + "dataFields": ["color":"black"] + ], + ["dataType": "customEvent", + "eventName": "animal-found", + "dataFields": ["vaccinated":true] + ], + ["dataType": "purchase", + "dataFields": ["total": 30, + "reason":"testing"] + ] + ] + + + let expectedCriteriaId = "290" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataComplexCriteria1)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + + func testComplexCriteria1Failed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["firstName": "Alex"] + ], + ["dataType": "customEvent", + "eventName": "saved_cars", + "dataFields": ["color":""] + ], + ["dataType": "customEvent", + "eventName": "animal-found", + "dataFields": ["vaccinated":true] + ], + ["dataType": "purchase", + "dataFields": ["total": 30, + "reason":"testing"] + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataComplexCriteria1)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mockDataComplexCriteria2 = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "291", + "name": "Complex Criteria Unit Test #2", + "createdAt": 1722533473263, + "updatedAt": 1722533473263, + "searchQuery": { + "combinator": "Or", + "searchQueries": [ + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "A", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "B", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "C", + "fieldType": "string" + } + ] + } + } + ] + }, + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "IsSet", + "value": "", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "saved_cars.color", + "comparatorType": "IsSet", + "value": "", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "animal-found.vaccinated", + "comparatorType": "Equals", + "value": "true", + "fieldType": "boolean" + } + ] + } + } + ] + }, + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "total", + "comparatorType": "GreaterThanOrEqualTo", + "value": "100", + "fieldType": "double" + } + ] + } + }, + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "reason", + "comparatorType": "DoesNotEqual", + "value": "gift", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testComplexCriteria2Success() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["firstName": "xcode"] + ], + ["dataType": "customEvent", + "eventName": "saved_cars", + "dataFields": ["color":"black"] + ], + ["dataType": "customEvent", + "eventName": "animal-found", + "dataFields": ["vaccinated":true] + ], + ["dataType": "purchase", + "dataFields": ["total": 110, + "reason":"testing"] + ] + ] + + let expectedCriteriaId = "291" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataComplexCriteria2)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testComplexCriteria2Failed() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["firstName": "Alex"] + ], + ["dataType": "purchase", + "dataFields": ["total": 10, + "reason":"gift"] + ] + ] + + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataComplexCriteria2)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mockDataComplexCriteria3 = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "292", + "name": "Complex Criteria Unit Test #3", + "createdAt": 1722533789589, + "updatedAt": 1722533838989, + "searchQuery": { + "combinator": "Not", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "A", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "lastName", + "comparatorType": "StartsWith", + "value": "A", + "fieldType": "string" + } + ] + } + } + ] + }, + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "firstName", + "comparatorType": "StartsWith", + "value": "C", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "animal-found.vaccinated", + "comparatorType": "Equals", + "value": "false", + "fieldType": "boolean" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "animal-found.count", + "comparatorType": "LessThan", + "value": "5", + "fieldType": "long" + } + ] + } + } + ] + }, + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "total", + "comparatorType": "LessThanOrEqualTo", + "value": "10", + "fieldType": "double" + } + ] + } + }, + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "shoppingCartItems.quantity", + "comparatorType": "LessThanOrEqualTo", + "value": "34", + "fieldType": "long" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testComplexCriteria3Success() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType":"purchase", + "createdAt": 1699246745093, + "items": [["id": "12", "name": "coffee", "price": 100, "quantity": 2]] + ] + ] + + let expectedCriteriaId = "292" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataComplexCriteria3)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testComplexCriteria3Success2() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType":"purchase", + "createdAt": 1699246745067, + "items": [["id": "12", "name": "kittens", "price": 2, "quantity": 2]] + ] + ] + + let expectedCriteriaId = "292" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataComplexCriteria3)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testComplexCriteria3Fail() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType":"purchase", + "createdAt": 1699246745093, + "items": [["id": "12", "name": "coffee", "price": 100, "quantity": 2]] + ], + ["dataType":"user", + "dataFields": ["firstName": "Alex", "lastName":"Aris"] + ] + ] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataComplexCriteria3)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } +} diff --git a/tests/unit-tests/CombinationLogicEventTypeCriteria.swift b/tests/unit-tests/CombinationLogicEventTypeCriteria.swift new file mode 100644 index 000000000..12f3515ce --- /dev/null +++ b/tests/unit-tests/CombinationLogicEventTypeCriteria.swift @@ -0,0 +1,1160 @@ +// +// CombinationLogicEventTypeCriteria.swift +// unit-tests +// +// Created by Apple on 06/08/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class CombinationLogicEventTypeCriteria: XCTestCase { + + //MARK: Comparator test For End + private let mockDataCombinatUserAnd = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "firstName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 2, + "value": "David" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "total", + "fieldType": "double", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 6, + "value": "10" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareDataUserAndSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David" + ], + ["total": "10", + "dataType": "customEvent" + ] + ] + + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatUserAnd)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataUserAndFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David1" + ], + ["total": "10", + "dataType": "customEvent" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatUserAnd)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mockDataCombinatUserOr = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "firstName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 2, + "value": "David" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "total", + "fieldType": "double", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 6, + "value": "10" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataUserOrSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David" + ], + ["total": "12", + "dataType": "customEvent" + ] + ] + + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatUserOr)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataUserOrFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David1" + ], + ["total": "12", + "dataType": "customEvent" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatUserOr)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mockDataCombinatUserNot = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "firstName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 2, + "value": "David" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "total", + "fieldType": "double", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 6, + "value": "10" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + //First -> Wrong + //Secon -> Correct + + + func testCompareDataUserNotSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "Devidson" + ], + ["total": "13", + "dataType": "customEvent" + ] + ] + + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatUserNot)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataUserNotFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David" + ], + ["total": "10", + "dataType": "customEvent" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatUserNot)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For UpdateCart And + private let mockDataCombinatUpdateCartAnd = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 8, + "value": "fried" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "firstName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 10, + "value": "David" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataUpdateCartAndSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David" + ], + ["items": [["id": "12", + "name": "fried", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatUpdateCartAnd)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataUpdateCartAndFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David" + ], + ["items": [["id": "12", + "name": "frieded", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatUpdateCartAnd)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + //MARK: Comparator test For UpdateCart And + private let mockDataCombinatUpdateCartOr = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 8, + "value": "fried" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "firstName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 10, + "value": "David" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataUpdateCartOrSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "Davidson" + ], + ["items": [["id": "12", + "name": "fried", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatUpdateCartOr)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataUpdateCartOrFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "Davidjson" + ], + ["items": [["id": "12", + "name": "frieded", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatUpdateCartOr)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For UpdateCart And + private let mockDataCombinatUpdateCartNot = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 8, + "value": "fried" + } + ] + } + }, + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "firstName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 10, + "value": "David" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataUpdateCartNotSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "Davidson" + ], + ["items": [["id": "12", + "name": "friedddd", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatUpdateCartNot)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataUpdateCartNotFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "firstName": "David" + ], + ["items": [["id": "12", + "name": "fried", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatUpdateCartNot)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + + //MARK: Comparator test For Purchase And + private let mockDataCombinatPurchaseAnd = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 13, + "value": "chicken" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 14, + "value": "fried" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataPurchaseAndSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["items": [["id": "12", + "name": "fried", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatPurchaseAnd)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataPurchaseAndFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken1", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["items": [["id": "12", + "name": "fried1", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatPurchaseAnd)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + //MARK: Comparator test For Purchase Or + private let mockDataCombinatPurchaseOr = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 13, + "value": "chicken" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 14, + "value": "fried" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataPurchaseOrSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["items": [["id": "12", + "name": "fried1", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatPurchaseOr)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataPurchaseOrFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken1", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["items": [["id": "12", + "name": "fried1", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatPurchaseOr)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + //MARK: Comparator test For Purchase Not + private let mockDataCombinatPurchaseNot = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 13, + "value": "chicken" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "updateCart.updatedShoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 14, + "value": "fried" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataPurchaseNotSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken1", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["items": [["id": "12", + "name": "fried1", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatPurchaseNot)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataPurchaseNotFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["items": [["id": "12", + "name": "fried", + "price": 130, + "quantity": 110]], + "dataType":"updateCart" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatPurchaseNot)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For Purchase Not + private let mockDataCombinatPurchaseCustomEventAnd = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 13, + "value": "chicken" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "eventName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 16, + "value": "birthday" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataPurchaseCustomEventAndSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["dataType":"customEvent", + "eventName": "birthday" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatPurchaseCustomEventAnd)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataPurchaseCustomEventAndFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken1", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["dataType":"customEvent", + "eventName": "birthday1" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatPurchaseCustomEventAnd)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For Purchase Not + private let mockDataCombinatPurchaseCustomEventOr = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 13, + "value": "chicken" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "eventName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 16, + "value": "birthday" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataPurchaseCustomEventOrSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["dataType":"customEvent", + "eventName": "birthday1" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatPurchaseCustomEventOr)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataPurchaseCustomEventOrFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken1", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["dataType":"customEvent", + "eventName": "birthday1" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatPurchaseCustomEventOr)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For Purchase Not + private let mockDataCombinatPurchaseCustomEventNot = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Not", + "searchQueries": [ + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "shoppingCartItems.name", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "purchase", + "id": 13, + "value": "chicken" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "eventName", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "customEvent", + "id": 16, + "value": "birthday" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataPurchaseCustomEventNotSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "beef", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["dataType":"customEvent", + "eventName": "anniversary" + ] + ] + + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatPurchaseCustomEventNot)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataPurchaseCustomEventNotFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["items": [["id": "12", + "name": "chicken", + "price": 130, + "quantity": 110]], + "dataType":"purchase" + ], + ["dataType":"customEvent", + "eventName": "birthday" + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCombinatPurchaseCustomEventNot)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } +} + diff --git a/tests/unit-tests/ComparatorDataTypeWithArrayInput.swift b/tests/unit-tests/ComparatorDataTypeWithArrayInput.swift new file mode 100644 index 000000000..891ed4fd1 --- /dev/null +++ b/tests/unit-tests/ComparatorDataTypeWithArrayInput.swift @@ -0,0 +1,712 @@ +// +// ComparatorDataTypeWithArrayInput.swift +// unit-tests +// +// Created by Apple on 21/08/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class ComparatorDataTypeWithArrayInput: XCTestCase { + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + //MARK: Comparator Equal For MileStoneYear Array + private let mockDataMileStoneYearEqual = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "milestoneYears", + "fieldType": "string", + "comparatorType": "Equals", + "dataType": "user", + "id": 2, + "value": "1997" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMileStoneYearEqualSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1996, 1997, 2002, 2020, 2024] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataMileStoneYearEqual)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMileStoneYearEqualFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1996, 1998, 2002, 2020, 2024] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataMileStoneYearEqual)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator DoesNotEqual For MileStoneYear Array + private let mockDataMileStoneYearDoesNotEqual = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "milestoneYears", + "fieldType": "string", + "comparatorType": "DoesNotEqual", + "dataType": "user", + "id": 2, + "value": "1997" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMileStoneYearDoesNotEqualSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1996, 1998, 2002, 2020, 2024] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataMileStoneYearDoesNotEqual)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMileStoneYearDoesNotEqualFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1996, 1997, 2002, 2020, 2024] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataMileStoneYearDoesNotEqual)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + //MARK: Comparator GreaterThan For MileStoneYear Array + private let mockDataMileStoneYearGreaterThan = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "milestoneYears", + "fieldType": "string", + "comparatorType": "GreaterThan", + "dataType": "user", + "id": 2, + "value": "1997" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMileStoneYearGreaterThanSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1996, 1998, 2002, 2020, 2024] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataMileStoneYearGreaterThan)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMileStoneYearGreaterThanFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1990, 1992, 1994, 1997] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataMileStoneYearGreaterThan)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + //MARK: Comparator GreaterThanOrEqualTo For MileStoneYear Array + private let mockDataMileStoneYearGreaterThanOrEqualTo = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "milestoneYears", + "fieldType": "string", + "comparatorType": "GreaterThanOrEqualTo", + "dataType": "user", + "id": 2, + "value": "1997" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMileStoneYearGreaterThanOrEqualToSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1997, 1998, 2002, 2020, 2024] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataMileStoneYearGreaterThanOrEqualTo)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMileStoneYearGreaterThanOrEqualToFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1990, 1992, 1994, 1996] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataMileStoneYearGreaterThanOrEqualTo)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator LessThan For MileStoneYear Array + private let mockDataMileStoneYearLessThan = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "milestoneYears", + "fieldType": "string", + "comparatorType": "LessThan", + "dataType": "user", + "id": 2, + "value": "1997" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMileStoneYearLessThanSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1990, 1992, 1994, 1996, 1998] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataMileStoneYearLessThan)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMileStoneYearLessThanFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1997, 1999, 2002, 2004] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataMileStoneYearLessThan)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator LessThanOrEqualTo For MileStoneYear Array + private let mockDataMileStoneYearLessThanOrEquaTo = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "milestoneYears", + "fieldType": "string", + "comparatorType": "LessThanOrEqualTo", + "dataType": "user", + "id": 2, + "value": "1997" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMileStoneYearLessThanOrEqualToSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1990, 1992, 1994, 1996, 1998] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataMileStoneYearLessThanOrEquaTo)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMileStoneYearLessThanOrEqualFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1998, 1999, 2002, 2004] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataMileStoneYearLessThanOrEquaTo)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator Contain For String Array + private let mockDataForArrayContains = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "addresses", + "fieldType": "string", + "comparatorType": "Contains", + "dataType": "user", + "id": 2, + "value": "US" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMockDataForArrayContainsSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "addresses": ["US", "UK", "USA"] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForArrayContains)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMockDataForArrayContainsFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "addresses": ["UK", "USA"] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForArrayContains)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator Contain For String Array + private let mockDataForArrayStartWith = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "addresses", + "fieldType": "string", + "comparatorType": "StartsWith", + "dataType": "user", + "id": 2, + "value": "US" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMockDataForArrayStartWithSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "addresses": [ "US, New York", + "US, San Francisco", + "US, San Diego", + "US, Los Angeles", + "JP, Tokyo", + "DE, Berlin", + "GB, London"] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForArrayStartWith)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMockDataForArrayStartWithFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "addresses": [ "JP", + "Tokyo", + "DE, Berlin", + "GB", + "London"] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForArrayStartWith)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator Contain For String Array + private let mockDataForArrayMatchRegex = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "addresses", + "fieldType": "string", + "comparatorType": "MatchesRegex", + "dataType": "user", + "id": 2, + "value": "^(JP|DE|GB)" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataMockDataForArrayMatchRegexSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "addresses": [ "JP", + "Tokyo", + "DE, Berlin", + "GB", + "London"] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForArrayMatchRegex)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataMockDataForArrayMatchRegexFailure() { + let eventItems: [[AnyHashable: Any]] = [[ + "dataType": "user", + "createdAt": 1699246745093, + "addresses": [ "US, New York", + "US, San Francisco", + "US, San Diego", + "US, Los Angeles", + ] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForArrayMatchRegex)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator DoesNotEqual For MileStoneYear Array + private let mockDataStringArrayMixCriteArea = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "382", + "name": "comparison_for_Array_data_types_or", + "createdAt": 1724315593795, + "updatedAt": 1724315593795, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "Or", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "milestoneYears", + "comparatorType": "GreaterThan", + "value": "1997", + "fieldType": "long" + } + ] + } + }, + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "button-clicked.animal", + "comparatorType": "DoesNotEqual", + "value": "giraffe", + "fieldType": "string" + } + ] + } + }, + { + "dataType": "purchase", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "purchase", + "field": "total", + "comparatorType": "LessThanOrEqualTo", + "value": "200", + "fieldType": "double" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMockDataStringArrayDoesNotEqualSuccess() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1998, 1999, 2002, 2004] + ], + [ + "dataType": "customEvent", + "eventName": "button-clicked", + "dataFields": ["animal": ["zirraf", "horse"]] + ], + [ + "dataType": "purchase", + "total": [199.99, 210.0, 220.20, 250.10] + ] + ] + let expectedCriteriaId = "382" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataStringArrayMixCriteArea)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testMockDataStringArrayDoesNotEqualFailure() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "user", + "createdAt": 1699246745093, + "milestoneYears": [1990, 1992, 1996,1997] + ], + [ + "dataType": "customEvent", + "eventName": "button-clicked", + "dataFields": ["animal": ["zirraf", "horse", "giraffe"]] + + ], + [ + "dataType": "purchase", + "total": [210.0, 220.20, 250.10] + ] + ] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataStringArrayMixCriteArea)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } +} diff --git a/tests/unit-tests/ComparatorTypeDoesNotEqualMatchTest.swift b/tests/unit-tests/ComparatorTypeDoesNotEqualMatchTest.swift new file mode 100644 index 000000000..9821f4235 --- /dev/null +++ b/tests/unit-tests/ComparatorTypeDoesNotEqualMatchTest.swift @@ -0,0 +1,258 @@ +// +// DoesNotEqualCriteriaMatch.swift +// unit-tests +// +// Created by Apple on 01/08/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class ComparatorTypeDoesNotEqualMatchTest: XCTestCase { + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + private let mokeDataBool = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "194", + "name": "Contact: Phone Number != 57688559", + "createdAt": 1721337331194, + "updatedAt": 1722338525737, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "field": "subscribed", + "fieldType": "boolean", + "comparatorType": "DoesNotEqual", + "dataType": "user", + "id": 25, + "value": "true" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataSuccessForBool() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["subscribed": false + ]]] + let expectedCriteriaId = "194" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mokeDataBool)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataFailedForBool() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["subscribed": true, + ]]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mokeDataBool)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mokeDataString = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "195", + "name": "Contact: Phone Number != 57688559", + "createdAt": 1721337331194, + "updatedAt": 1722338525737, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "phoneNumber", + "comparatorType": "DoesNotEqual", + "value": "57688559", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataSuccessForString() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["phoneNumber": "123456" + ]]] + let expectedCriteriaId = "195" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mokeDataString)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataFailedForString() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["phoneNumber": "57688559" + ]]] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mokeDataString)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + private let mokeDataDouble = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "196", + "name": "Contact: Phone Number != 57688559", + "createdAt": 1721337331194, + "updatedAt": 1722338525737, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "savings", + "comparatorType": "DoesNotEqual", + "value": "19.99", + "fieldType": "double" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + func testCompareDataSuccessForDouble() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["savings": 9.99 + ]]] + let expectedCriteriaId = "196" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mokeDataDouble)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataFailedForDouble() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["savings": 19.99 + ]]] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mokeDataDouble)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mokeDataLong = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "197", + "name": "Contact: Phone Number != 57688559", + "createdAt": 1721337331194, + "updatedAt": 1722338525737, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "DoesNotEqual", + "value": "15", + "fieldType": "long" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataSuccessForLong() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["eventTimeStamp": 20 + ]]] + let expectedCriteriaId = "197" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mokeDataLong)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataFailedForLong() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":["eventTimeStamp": 15 + ]]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mokeDataLong)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + +} + diff --git a/tests/unit-tests/CustomEventUserUpdateTestCaseTests.swift b/tests/unit-tests/CustomEventUserUpdateTestCaseTests.swift new file mode 100644 index 000000000..d9bb0d4ec --- /dev/null +++ b/tests/unit-tests/CustomEventUserUpdateTestCaseTests.swift @@ -0,0 +1,268 @@ +// +// CustomEventUserUpdateTestCaseTests.swift +// unit-tests +// +// Created by Apple on 16/09/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class CustomEventUserUpdateTestCaseTests: XCTestCase { + + private let mockData = """ + { + "count": 48, + "criteriaSets": [ + { + "criteriaId": "48", + "name": "Custom event", + "createdAt": 1716561634904, + "updatedAt": 1716561634904, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "Equals", + "value": "button-clicked", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "button-clicked.lastPageViewed", + "comparatorType": "Equals", + "value": "signup page", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareDataWithCustomEventCriteriaFailed1() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["button-clicked.lastPageViewed": "signup page"] + ]] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCustomEventCriteriaFailed2() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["button-clicked.button-clicked.lastPageViewed": "signup page"] + ]] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCustomEventCriteriaFailed3() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["button-clicked": ["button-clicked.lastPageViewed": "signup page"]] + ]] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCustomEventCriteriaFailed4() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["button-clicked": ["lastPageViewed": "signup page"]] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataWithCustomEventCriteriaSuccessCase() { + let eventItems: [[AnyHashable: Any]] = [ + ["dataType": "customEvent", "createdAt": 1699246745093, "eventName": "button-clicked", "dataFields": ["lastPageViewed": "signup page"] + ]] + let expectedCriteriaId = "48" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + + private let mockDataForMultiLevelNested = """ + { + "count": 3, + "criteriaSets": [ + { + "criteriaId": "425", + "name": "Multi level Nested field criteria", + "createdAt": 1726811375306, + "updatedAt": 1726811375306, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "button-clicked.updateCart.updatedShoppingCartItems.quantity", + "comparatorType": "Equals", + "value": "10", + "fieldType": "long" + }, + { + "dataType": "customEvent", + "field": "button-clicked.browserVisit.website.domain", + "comparatorType": "Equals", + "value": "https://mybrand.com/socks", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testMultiLevelNestedFailed1() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", + "createdAt": 1699246745093, + "eventName": "button-clicked", + "dataFields": [ + "updateCart": [ + "updatedShoppingCartItems": [ + "quantity": 10 + ] + ], + "browserVisit": [ + "website.domain": "https://mybrand.com/socks" + ] + ] + ] + ] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForMultiLevelNested)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testMultiLevelNestedFailed2() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", + "createdAt": 1699246745093, + "eventName": "button-clicked", + "dataFields": [ + "updateCart": [ + "updatedShoppingCartItems.quantity": 10 + ], + "browserVisit": [ + "website.domain": "https://mybrand.com/socks" + ] + ] + ] + ] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForMultiLevelNested)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testMultiLevelNestedFailed3() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", + "createdAt": 1699246745093, + "eventName": "button-clicked", + "dataFields": [ + "button-clicked": [ + "updateCart": [ + "updatedShoppingCartItems": [ + "quantity": 10 + ] + ], + "browserVisit": [ + "website": [ + "domain": "https://mybrand.com/socks" + ] + ] + ] + ] + ] + ] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForMultiLevelNested)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testMultiLevelNestedFailed4() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", + "createdAt": 1699246745093, + "eventName": "button-clicked", + "dataFields": [ + "quantity": 10, + "domain": "https://mybrand.com/socks" + ] + ] + ] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForMultiLevelNested)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testMultiLevelNestedSuccessCase() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", + "createdAt": 1699246745093, + "eventName": "button-clicked", + "dataFields": [ + "updateCart": [ + "updatedShoppingCartItems": [ + "quantity": 10 + ] + ], + "browserVisit": [ + "website": [ + "domain": "https://mybrand.com/socks" + ] + ] + ] + ] + ] + let expectedCriteriaId = "425" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataForMultiLevelNested)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } +} + diff --git a/tests/unit-tests/DataTypeComparatorSearchQueryCriteria.swift b/tests/unit-tests/DataTypeComparatorSearchQueryCriteria.swift new file mode 100644 index 000000000..29940b70e --- /dev/null +++ b/tests/unit-tests/DataTypeComparatorSearchQueryCriteria.swift @@ -0,0 +1,634 @@ +// +// SavingComplexCriteriaMatch.swift +// unit-tests +// +// Created by Apple on 01/08/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class DataTypeComparatorSearchQueryCriteria: XCTestCase { + + //MARK: Comparator test For Equal + private let mockDataEqual = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "Equals", + "value": "3", + "fieldType": "long" + }, + { + "dataType": "user", + "field": "savings", + "comparatorType": "Equals", + "value": "19.99", + "fieldType": "double" + }, + { + "dataType": "user", + "field": "likes_boba", + "comparatorType": "Equals", + "value": "true", + "fieldType": "boolean" + }, + { + "dataType": "user", + "field": "country", + "comparatorType": "Equals", + "value": "Chaina", + "fieldType": "String" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareDataEqualSuccess() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 19.99, + "eventTimeStamp": 3, + "likes_boba": true, + "country":"Chaina"] + ]] + + let expectedCriteriaId = "285" + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataEqual)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataEqualFailed() { + + //let eventItems: [[AnyHashable: Any]] = [["dataType":"user","savings": 10.1]] + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 10.99, + "eventTimeStamp": 30, + "likes_boba": false, + "country":"Taiwan"] + ]] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataEqual)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For DoesNotEqual + private let mockDataDoesNotEquals = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "DoesNotEqual", + "value": "3", + "fieldType": "long" + }, + { + "dataType": "user", + "field": "savings", + "comparatorType": "DoesNotEqual", + "value": "19.99", + "fieldType": "double" + }, + { + "dataType": "user", + "field": "likes_boba", + "comparatorType": "DoesNotEqual", + "value": "true", + "fieldType": "boolean" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + + func testCompareDataDoesNotEqualSuccess() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 11.2, + "eventTimeStamp": 30, + "likes_boba": false] + ]] + let expectedCriteriaId = "285" + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataDoesNotEquals)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataDoesNotEqualFailed() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 19.99, + "eventTimeStamp": 30, + "likes_boba": true] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataDoesNotEquals)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For LessThan and LessThanOrEqual + private let mockDataLessThanOrEqual = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "289", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "LessThan", + "value": "15", + "fieldType": "long" + }, + { + "dataType": "user", + "field": "savings", + "comparatorType": "LessThan", + "value": "15", + "fieldType": "double" + } + ] + } + } + ] + } + ] + } + }, + { + "criteriaId": "290", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "LessThanOrEqualTo", + "value": "17", + "fieldType": "long" + }, + { + "dataType": "user", + "field": "savings", + "comparatorType": "LessThanOrEqualTo", + "value": "17", + "fieldType": "double" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + + func testCompareDataLessThanSuccess() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 10, + "eventTimeStamp": 14] + ]] + let expectedCriteriaId = "289" + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataLessThanOrEqual)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataLessThanFailed() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 18, + "eventTimeStamp": 18] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataLessThanOrEqual)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataLessThanOrEqualSuccess() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 17, + "eventTimeStamp": 14] + ]] + let expectedCriteriaId = "290" + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataLessThanOrEqual)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataLessThanOrEqualFailed() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 18, + "eventTimeStamp": 12] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataLessThanOrEqual)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For GreaterThan and GreaterThanOrEqual + private let mockDataGreaterThanOrEqual = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "290", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "GreaterThan", + "value": "50", + "fieldType": "long" + }, + { + "dataType": "user", + "field": "savings", + "comparatorType": "GreaterThan", + "value": "55", + "fieldType": "double" + } + ] + } + } + ] + } + ] + } + }, + { + "criteriaId": "291", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "GreaterThanOrEqualTo", + "value": "20", + "fieldType": "long" + }, + { + "dataType": "user", + "field": "savings", + "comparatorType": "GreaterThanOrEqualTo", + "value": "20", + "fieldType": "double" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + + func testCompareDataGreaterThanSuccess() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 56, + "eventTimeStamp": 51] + ]] + let expectedCriteriaId = "290" + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataGreaterThanOrEqual)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataGreaterThanFailed() { + + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 5, + "eventTimeStamp": 3] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataGreaterThanOrEqual)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataGreaterThanOrEqualSuccess() { + + let eventItems: [[AnyHashable: Any]] = [["dataType": "user", + "dataFields":[ + "savings": 20, + "eventTimeStamp": 30] + ]] + let expectedCriteriaId = "291" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataGreaterThanOrEqual)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataGreaterThanOrEqualFailed() { + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 18, + "eventTimeStamp":16] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataGreaterThanOrEqual)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + //MARK: Comparator test For IsSet + private let mockDataIsSet = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "285", + "name": "Criteria_EventTimeStamp_3_Long", + "createdAt": 1722497422151, + "updatedAt": 1722500235276, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "eventTimeStamp", + "comparatorType": "IsSet", + "value": "", + "fieldType": "long" + }, + { + "dataType": "user", + "field": "savings", + "comparatorType": "IsSet", + "value": "", + "fieldType": "double" + }, + { + "dataType": "user", + "field": "saved_cars", + "comparatorType": "IsSet", + "value": "", + "fieldType": "double" + }, + { + "dataType": "user", + "field": "country", + "comparatorType": "IsSet", + "value": "", + "fieldType": "double" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataIsSetySuccess() { + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": 10, + "eventTimeStamp":20, + "saved_cars":"10", + "country": "Taiwan"] + ]] + let expectedCriteriaId = "285" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataIsSet)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataIsSetFailure() { + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "dataFields":[ + "savings": "", + "eventTimeStamp":"", + "saved_cars":"", + "country": ""] + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataIsSet)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + //MARK: Comparator test For IsSet + private let mockDataContainRegexStartWith = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "288", + "name": "Criteria_Country_User", + "createdAt": 1722511481998, + "updatedAt": 1722511481998, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "country", + "comparatorType": "MatchesRegex", + "value": "^T.*iwa.*n$", + "fieldType": "string" + }, + { + "dataType": "user", + "field": "country", + "comparatorType": "StartsWith", + "value": "T", + "fieldType": "string" + }, + { + "dataType": "user", + "field": "country", + "comparatorType": "Contains", + "value": "wan", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareDataMatchesRegexSuccess() { + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "country":"Taiwan"]] + let expectedCriteriaId = "288" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataContainRegexStartWith)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataMatchesRegexFailure() { + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "country":"Chaina", + "phoneNumber": "1212567"]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataContainRegexStartWith)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataStartWithFailure() { + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "country":"Chaina"]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataContainRegexStartWith)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + func testCompareDataContainFailure() { + let eventItems: [[AnyHashable: Any]] = [["dataType":"user", + "country":"ina"]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataContainRegexStartWith)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + +} diff --git a/tests/unit-tests/IsOneOfInNotOneOfCriteareaTest.swift b/tests/unit-tests/IsOneOfInNotOneOfCriteareaTest.swift new file mode 100644 index 000000000..07ebd4260 --- /dev/null +++ b/tests/unit-tests/IsOneOfInNotOneOfCriteareaTest.swift @@ -0,0 +1,246 @@ +// +// IsOneOfInNonOfCriteareaTest.swift +// unit-tests +// +// Created by Apple on 02/09/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class IsOneOfInNotOneOfCriteareaTest: XCTestCase { + + //MARK: Comparator test For End + private let mockDataIsOneOf = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "299", + "name": "Criteria_IsNonOf_Is_One_of", + "createdAt": 1722851586508, + "updatedAt": 1725268680330, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "country", + "comparatorType": "Equals", + "values": [ + "China", + "Japan", + "Kenya" + ] + }, + { + "dataType": "user", + "field": "addresses", + "comparatorType": "Equals", + "values": [ + "JP", + "DE", + "GB" + ] + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareIsOneOfSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["country": "China", + "addresses": ["US", "UK", "JP", "DE", "GB"] + ] + ] + ] + + + let expectedCriteriaId = "299" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataIsOneOf)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareIsOneOfFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["country": "Korea", + "addresses": ["US", "UK"] + ] + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataIsOneOf)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + //MARK: Comparator test For End + private let mockDataIsNotOneOf = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "299", + "name": "Criteria_IsNonOf_Is_One_of", + "createdAt": 1722851586508, + "updatedAt": 1725268680330, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "country", + "comparatorType": "DoesNotEqual", + "values": [ + "China", + "Japan", + "Kenya" + ] + }, + { + "dataType": "user", + "field": "addresses", + "comparatorType": "DoesNotEqual", + "values": [ + "JP", + "DE", + "GB" + ] + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareIsNotOneOfSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["country": "Korea", + "addresses": ["US", "UK"] + ] + ] + ] + + + let expectedCriteriaId = "299" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataIsNotOneOf)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareIsNotOneOfFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"user", + "dataFields": ["country": "China", + "addresses": ["US", "UK", "JP", "DE", "GB"] + ] + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataIsNotOneOf)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + private let mockDataCrashTest = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "403", + "name": "button-clicked.animal isNotOneOf [cat,giraffe,hippo,horse]", + "createdAt": 1725471874865, + "updatedAt": 1725631049514, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "button-clicked.animal", + "comparatorType": "DoesNotEqual", + "values": [ + "cat", + "giraffe", + "hippo", + "horse" + ] + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + func testCompareMockDataCrashTest() { + + let eventItems: [[AnyHashable: Any]] = [ + ["dataType":"customEvent", + "dataFields": ["button-clicked": ["animal":"dog"]] + ] + ] + + + let expectedCriteriaId = "403" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataCrashTest)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + +} diff --git a/tests/unit-tests/IterableAPIResponseTests.swift b/tests/unit-tests/IterableAPIResponseTests.swift index 852451e40..ec136c36b 100644 --- a/tests/unit-tests/IterableAPIResponseTests.swift +++ b/tests/unit-tests/IterableAPIResponseTests.swift @@ -10,6 +10,7 @@ class IterableAPIResponseTests: XCTestCase { private let apiKey = "zee_api_key" private let email = "user@example.com" private let authToken = "asdf" + private let dateProvider = MockDateProvider() func testHeadersInGetRequest() { @@ -285,12 +286,12 @@ class IterableAPIResponseTests: XCTestCase { extension IterableAPIResponseTests: AuthProvider { var auth: Auth { - Auth(userId: nil, email: email, authToken: authToken) + Auth(userId: nil, email: email, authToken: authToken, userIdAnon: nil) } } class AuthProviderNoToken: AuthProvider { var auth: Auth { - Auth(userId: nil, email: "user@example.com", authToken: nil) + Auth(userId: nil, email: "user@example.com", authToken: nil, userIdAnon: nil) } } diff --git a/tests/unit-tests/IterableApiCriteriaFetchTests.swift b/tests/unit-tests/IterableApiCriteriaFetchTests.swift new file mode 100644 index 000000000..794ac9f03 --- /dev/null +++ b/tests/unit-tests/IterableApiCriteriaFetchTests.swift @@ -0,0 +1,159 @@ +// +// IterableApiCriteriaFetchTests.swift +// swift-sdk +// +// Created by Joao Dordio on 30/01/2025. +// Copyright © 2025 Iterable. All rights reserved. +// + +import XCTest + +@testable import IterableSDK + +class IterableApiCriteriaFetchTests: XCTestCase { + private var mockNetworkSession: MockNetworkSession! + private var mockDateProvider: MockDateProvider! + private var mockNotificationCenter: MockNotificationCenter! + private var internalApi: InternalIterableAPI! + private var mockApplicationStateProvider: MockApplicationStateProvider! + private static let apiKey = "zeeApiKey" + let localStorage = MockLocalStorage() + + override func setUp() { + super.setUp() + mockNetworkSession = MockNetworkSession() + mockDateProvider = MockDateProvider() + mockNotificationCenter = MockNotificationCenter() + mockApplicationStateProvider = MockApplicationStateProvider(applicationState: .active) + } + + override func tearDown() { + mockNetworkSession = nil + mockDateProvider = nil + mockNotificationCenter = nil + internalApi = nil + mockApplicationStateProvider = nil + super.tearDown() + } + + func testForegroundCriteriaFetchWhenConditionsMet() { + let expectation1 = expectation(description: "First criteria fetch") + expectation1.expectedFulfillmentCount = 2 + + mockNetworkSession.responseCallback = { urlRequest in + if urlRequest.absoluteString.contains(Const.Path.getCriteria) == true { + expectation1.fulfill() + } + return nil + } + + let config = IterableConfig() + config.enableAnonActivation = true + config.enableForegroundCriteriaFetch = true + + IterableAPI.initializeForTesting(apiKey: IterableApiCriteriaFetchTests.apiKey, + config: config, + networkSession: mockNetworkSession, + localStorage: localStorage) + + internalApi = InternalIterableAPI.initializeForTesting( + config: config, + dateProvider: mockDateProvider, + networkSession: mockNetworkSession, + applicationStateProvider: mockApplicationStateProvider, + notificationCenter: mockNotificationCenter + ) + + internalApi.setVisitorUsageTracked(isVisitorUsageTracked: true) + sleep(5) + // Simulate app coming to foreground + mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + wait(for: [expectation1], timeout: testExpectationTimeout) + } + + func testCriteriaFetchNotCalledWhenDisabled() { + let expectation1 = expectation(description: "No criteria fetch") + expectation1.isInverted = true + + mockNetworkSession.responseCallback = { urlRequest in + if urlRequest.absoluteString.contains(Const.Path.getCriteria) == true { + expectation1.fulfill() + } + return nil + } + + let config = IterableConfig() + config.enableAnonActivation = true + config.enableForegroundCriteriaFetch = false + + internalApi = InternalIterableAPI.initializeForTesting( + config: config, + dateProvider: mockDateProvider, + networkSession: mockNetworkSession, + applicationStateProvider: mockApplicationStateProvider, + notificationCenter: mockNotificationCenter + ) + internalApi.setVisitorUsageTracked(isVisitorUsageTracked: true) + + // Simulate app coming to foreground + mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + wait(for: [expectation1], timeout: testExpectationTimeout) + } + + func testForegroundCriteriaFetchWithCooldown() { + let expectation1 = expectation(description: "First criteria fetch") + let expectation2 = expectation(description: "Second criteria fetch") + let expectation3 = expectation(description: "No third fetch during cooldown") + expectation3.isInverted = true + + var fetchCount = 0 + mockNetworkSession.responseCallback = { urlRequest in + if urlRequest.absoluteString.contains(Const.Path.getCriteria) == true { + fetchCount += 1 + switch fetchCount { + case 1: expectation1.fulfill() + case 2: expectation2.fulfill() + case 3: expectation3.fulfill() + default: break + } + } + return nil + } + + let config = IterableConfig() + config.enableAnonActivation = true + config.enableForegroundCriteriaFetch = true + + IterableAPI.initializeForTesting(apiKey: IterableApiCriteriaFetchTests.apiKey, + config: config, + networkSession: mockNetworkSession, + localStorage: localStorage) + + internalApi = InternalIterableAPI.initializeForTesting( + config: config, + dateProvider: mockDateProvider, + networkSession: mockNetworkSession, + applicationStateProvider: mockApplicationStateProvider, + notificationCenter: mockNotificationCenter + ) + + internalApi.setVisitorUsageTracked(isVisitorUsageTracked: true) + + sleep(5) + + // First foreground + mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + // Second foreground after some time + mockDateProvider.currentDate = mockDateProvider.currentDate.addingTimeInterval(130) // After cooldown + mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + // Third foreground during cooldown + mockDateProvider.currentDate = mockDateProvider.currentDate.addingTimeInterval(10) // Within cooldown + mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + wait(for: [expectation1, expectation2, expectation3], timeout: testExpectationTimeout) + } +} diff --git a/tests/unit-tests/NestedFieldSupportForArrayData.swift b/tests/unit-tests/NestedFieldSupportForArrayData.swift new file mode 100644 index 000000000..ff350fb9f --- /dev/null +++ b/tests/unit-tests/NestedFieldSupportForArrayData.swift @@ -0,0 +1,326 @@ +// +// NestedFieldSupportForArrayData.swift +// unit-tests +// +// Created by Apple on 27/08/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class NestedFieldSupportForArrayData: XCTestCase { + //MARK: Comparator test For End + private let mockData = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "168", + "name": "nested testing", + "createdAt": 1721251169153, + "updatedAt": 1723488175352, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "furniture", + "comparatorType": "IsSet", + "value": "", + "fieldType": "nested" + }, + { + "dataType": "user", + "field": "furniture.furnitureColor", + "comparatorType": "Equals", + "value": "White", + "fieldType": "string" + }, + { + "dataType": "user", + "field": "furniture.furnitureType", + "comparatorType": "Equals", + "value": "Sofa", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testNestedFieldSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType":"user", + "email":"user@example.com", + "dataFields":[ + "furniture": [ + [ + "furnitureType": "Sofa", + "furnitureColor": "White", + "lengthInches": 40, + "widthInches": 60 + ], + [ + "furnitureType": "Table", + "furnitureColor": "Gray", + "lengthInches": 20, + "widthInches": 30 + ], + ] + ] + ] + ] + + + let expectedCriteriaId = "168" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testNestedFieldFailed() { + + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType":"user", + "email":"user@example.com", + "dataFields":[ + "furniture": [ + [ + "furnitureType": "Sofa", + "furnitureColor": "Gray", + "lengthInches": 40, + "widthInches": 60 + ], + [ + "furnitureType": "Table", + "furnitureColor": "White", + "lengthInches": 20, + "widthInches": 30 + ], + ] + ] + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockData)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mokeDataForUserArray = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "436", + "name": "Criteria 2.1 - 09252024 Bug Bash", + "createdAt": 1727286807360, + "updatedAt": 1727950464167, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "user", + "field": "furniture.material.type", + "comparatorType": "Contains", + "value": "table", + "fieldType": "string" + }, + { + "dataType": "user", + "field": "furniture.material.color", + "comparatorType": "Equals", + "values": [ + "black" + ] + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + func testNestedFieldArrayValueUserSuccess() { + + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "user", + "dataFields": [ + "furniture": [ + "material": [ + [ + "type": "table", + "color": "black", + "lengthInches": 40, + "widthInches": 60 + ], + [ + "type": "Sofa", + "color": "Gray", + "lengthInches": 20, + "widthInches": 30 + ] + ] + ] + ] + ] + ] + + + let expectedCriteriaId = "436" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mokeDataForUserArray)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testNestedFieldArrayUserValueFail() { + + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "user", + "dataFields": [ + "furniture": [ + "material": [ + [ + "type": "Chair", + "color": "black", + "lengthInches": 40, + "widthInches": 60 + ], + [ + "type": "Sofa", + "color": "black", + "lengthInches": 20, + "widthInches": 30 + ] + ] + ] + ] + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mokeDataForUserArray)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + private let mokeDataForEventArray = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "459", + "name": "event a.h.b=d && a.h.c=g", + "createdAt": 1727717997842, + "updatedAt": 1728024187962, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "TopLevelArrayObject.a.h.b", + "comparatorType": "Equals", + "value": "d", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "TopLevelArrayObject.a.h.c", + "comparatorType": "Equals", + "value": "g", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + func testNestedFieldArrayValueEventSuccess() { + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", + "eventName": "TopLevelArrayObject", + "dataFields": [ + "a": ["h": [["b": "e", + "c": "h"], + ["b": "d", + "c": "g"]]] + ] + ] + ] + + + let expectedCriteriaId = "459" + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mokeDataForEventArray)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testNestedFieldArrayEventValueFail() { + + let eventItems: [[AnyHashable: Any]] = [ + [ + "dataType": "customEvent", + "eventName": "TopLevelArrayObject", + "dataFields": [ + "a": ["h": [["b": "d", + "c": "h"], + ["b": "e", + "c": "g"]]] + ] + ] + ] + + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mokeDataForEventArray)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } +} diff --git a/tests/unit-tests/RequestCreatorTests.swift b/tests/unit-tests/RequestCreatorTests.swift index 45c2e2ac6..831f30e44 100644 --- a/tests/unit-tests/RequestCreatorTests.swift +++ b/tests/unit-tests/RequestCreatorTests.swift @@ -148,7 +148,7 @@ class RequestCreatorTests: XCTestCase { } func testGetInAppMessagesRequestFailure() { - let auth = Auth(userId: nil, email: nil, authToken: nil) + let auth = Auth(userId: nil, email: nil, authToken: nil, userIdAnon: nil) let requestCreator = RequestCreator(auth: auth, deviceMetadata: deviceMetadata) let failingRequest = requestCreator.createGetInAppMessagesRequest(1) @@ -377,9 +377,9 @@ class RequestCreatorTests: XCTestCase { private let locationKeyPath = "\(JsonKey.inAppMessageContext).\(JsonKey.inAppLocation)" - private let userlessAuth = Auth(userId: nil, email: nil, authToken: nil) + private let userlessAuth = Auth(userId: nil, email: nil, authToken: nil, userIdAnon: nil) - private let userIdAuth = Auth(userId: "ein", email: nil, authToken: nil) + private let userIdAuth = Auth(userId: "ein", email: nil, authToken: nil, userIdAnon: nil) private let deviceMetadata = DeviceMetadata(deviceId: IterableUtil.generateUUID(), platform: JsonValue.iOS, @@ -437,6 +437,6 @@ class RequestCreatorTests: XCTestCase { extension RequestCreatorTests: AuthProvider { var auth: Auth { - Auth(userId: nil, email: email, authToken: nil) + Auth(userId: nil, email: email, authToken: nil, userIdAnon: nil) } } diff --git a/tests/unit-tests/UserMergeScenariosTests.swift b/tests/unit-tests/UserMergeScenariosTests.swift new file mode 100644 index 000000000..64a38f51d --- /dev/null +++ b/tests/unit-tests/UserMergeScenariosTests.swift @@ -0,0 +1,1022 @@ +// +// UserMergeScenariosTests.swift +// unit-tests +// +// Created by vishwa on 04/07/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest + +@testable import IterableSDK + +class UserMergeScenariosTests: XCTestCase, AuthProvider { + private static let apiKey = "zeeApiKey" + private let authToken = "asdf" + private let dateProvider = MockDateProvider() + let mockSession = MockNetworkSession(statusCode: 200) + let localStorage = MockLocalStorage() + + var auth: Auth { + Auth(userId: nil, email: nil, authToken: authToken, userIdAnon: nil) + } + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + override func tearDown() { + // Clean up after each test + super.tearDown() + } + + let mockData = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "96", + "name": "Purchase: isSet Comparator", + "createdAt": 1719328487701, + "updatedAt": 1719328487701, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "Equals", + "value": "testEvent", + "fieldType": "string" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + + // Helper function to wait for a specified duration + private func waitForDuration(seconds: TimeInterval) { + let waitExpectation = expectation(description: "Waiting for \(seconds) seconds") + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: seconds + 1) + } + + func testCriteriaNotMetUserIdDefault() { // criteria not met with merge default with setUserId + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + IterableAPI.setUserId("testuser123") + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") + } else { + XCTFail("Expected userId but found nil") + } + waitForDuration(seconds: 5) + + if localStorage.anonymousUserEvents != nil { + XCTFail("Events are not replayed") + } else { + XCTAssertNil(localStorage.anonymousUserEvents, "Expected events to be nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaNotMetUserIdReplayTrueMergeFalse() { // criteria not met with merge false with setUserId + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnAnonymousToKnown: false) + IterableAPI.setUserId("testuser123", nil, identityResolution) + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") + } else { + XCTFail("Expected userId but found nil") + } + + if localStorage.anonymousUserEvents != nil { + XCTFail("Events are not replayed") + } else { + XCTAssertNil(localStorage.anonymousUserEvents, "Expected events to be nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaNotMetUserIdReplayFalseMergeFalse() { // criteria not met with merge true with setUserId + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnAnonymousToKnown: false) + IterableAPI.setUserId("testuser123", nil, identityResolution) + + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") + } else { + XCTFail("Expected userId but found nil") + } + waitForDuration(seconds: 5) + + let expectation1 = self.expectation(description: "Events properly cleared") + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + expectation1.fulfill() + } + + // Verify "merge user" API call is not made + let expectation2 = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation2.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaNotMetUserIdReplayFalseMergeTrue() { // criteria not met with merge true with setUserId + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnAnonymousToKnown: true) + IterableAPI.setUserId("testuser123", nil, identityResolution) + + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") + } else { + XCTFail("Expected userId but found nil") + } + waitForDuration(seconds: 5) + + let expectation1 = self.expectation(description: "Events properly cleared") + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + expectation1.fulfill() + } + + // Verify "merge user" API call is not made + let expectation2 = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation2.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaMetUserIdDefault() { // criteria met with merge default with setUserId + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + if let anonUser = localStorage.userIdAnnon { + XCTAssertFalse(anonUser.isEmpty, "Expected anon user nil") + } else { + XCTFail("Expected anon user nil but found") + } + + IterableAPI.setUserId("testuser123") + + // Verify "merge user" API call is made + let apiCallExpectation = self.expectation(description: "API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + apiCallExpectation.fulfill() + } else { + XCTFail("Expected merge user API call was not made") + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaMetUserIdMergeFalse() { // criteria met with merge false with setUserId + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + if let anonUser = localStorage.userIdAnnon { + XCTAssertFalse(anonUser.isEmpty, "Expected anon user to be found") + } else { + XCTFail("Expected anon user but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnAnonymousToKnown: false) + IterableAPI.setUserId("testuser123", nil, identityResolution) + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaMetUserIdMergeTrue() { // criteria met with merge true with setUserId + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + if let anonUser = localStorage.userIdAnnon { + XCTAssertFalse(anonUser.isEmpty, "Expected anon user nil") + } else { + XCTFail("Expected anon user nil but found") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnAnonymousToKnown: true) + IterableAPI.setUserId("testuser123", nil, identityResolution) + + waitForDuration(seconds: 3) + + // Verify "merge user" API call is made + let apiCallExpectation = self.expectation(description: "API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + apiCallExpectation.fulfill() + } else { + XCTFail("Expected merge user API call was not made") + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testIdentifiedUserIdDefault() { // current user identified with setUserId default + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.setUserId("testuser123") + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") + } else { + XCTFail("Expected userId but found nil") + } + + + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + + if localStorage.userIdAnnon != nil { + XCTFail("Expected anon user nil but found") + } else { + XCTAssertNil(localStorage.anonymousUserEvents, "Expected anon user to be nil") + } + + IterableAPI.setUserId("testuseranotheruser") + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuseranotheruser", "Expected userId to be 'testuseranotheruser'") + } else { + XCTFail("Expected userId but found nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + + func testIdentifiedUserIdMergeFalse() { // current user identified with setUserId merge false + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.setUserId("testuser123") + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") + } else { + XCTFail("Expected userId but found nil") + } + + + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + if localStorage.userIdAnnon != nil { + XCTFail("Expected anon user nil but found") + } else { + XCTAssertNil(localStorage.userIdAnnon, "Expected anon user to be nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnAnonymousToKnown: false) + IterableAPI.setUserId("testuseranotheruser", nil, identityResolution) + + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuseranotheruser", "Expected userId to be 'testuseranotheruser'") + } else { + XCTFail("Expected userId but found nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + + func testIdentifiedUserIdMergeTrue() { // current user identified with setUserId true + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.setUserId("testuser123") + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuser123", "Expected userId to be 'testuser123'") + } else { + XCTFail("Expected userId but found nil") + } + + + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + + if localStorage.userIdAnnon != nil { + XCTFail("Expected anon user nil but found") + } else { + XCTAssertNil(localStorage.anonymousUserEvents, "Expected anon user to be nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnAnonymousToKnown: true) + IterableAPI.setUserId("testuseranotheruser", nil, identityResolution) + waitForDuration(seconds: 3) + + if let userId = IterableAPI.userId { + XCTAssertEqual(userId, "testuseranotheruser", "Expected userId to be 'testuseranotheruser'") + } else { + XCTFail("Expected userId but found nil") + } + + // Verify "merge user" API call is not made + let apiCallExpectation = self.expectation(description: "API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + XCTFail("Expected merge user API call was made") + } else { + apiCallExpectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaNotMetEmailDefault() { // criteria not met with merge default with setEmail + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + IterableAPI.setEmail("testuser123@test.com") + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") + } else { + XCTFail("Expected email but found nil") + } + waitForDuration(seconds: 5) + + if localStorage.anonymousUserEvents != nil { + XCTFail("Events are not replayed") + } else { + XCTAssertNil(localStorage.anonymousUserEvents, "Expected events to be nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaNotMetEmailReplayTrueMergeFalse() { // criteria not met with merge false with setEmail + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnAnonymousToKnown: false) + IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") + } else { + XCTFail("Expected email but found nil") + } + + if localStorage.anonymousUserEvents != nil { + XCTFail("Events are not replayed") + } else { + XCTAssertNil(localStorage.anonymousUserEvents, "Expected events to be nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaNotMetEmailReplayFalseMergeFalse() { // criteria not met with merge true with setEmail + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnAnonymousToKnown: false) + IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") + } else { + XCTFail("Expected email but found nil") + } + waitForDuration(seconds: 5) + + let expectation1 = self.expectation(description: "Events properly cleared") + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + expectation1.fulfill() + } + + // Verify "merge user" API call is not made + let expectation2 = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation2.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaNotMetEmailReplayFalseMergeTrue() { // criteria not met with merge true with setEmail + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent123") + + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: false, mergeOnAnonymousToKnown: true) + IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") + } else { + XCTFail("Expected email but found nil") + } + waitForDuration(seconds: 5) + + let expectation1 = self.expectation(description: "Events properly cleared") + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + expectation1.fulfill() + } + + // Verify "merge user" API call is not made + let expectation2 = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation2.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaMetEmailDefault() { // criteria met with merge default with setEmail + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + if let anonUser = localStorage.userIdAnnon { + XCTAssertFalse(anonUser.isEmpty, "Expected anon user") + } else { + XCTFail("Expected anon user but found nil") + } + + IterableAPI.setEmail("testuser123@test.com") + + // Verify "merge user" API call is made + let apiCallExpectation = self.expectation(description: "API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + apiCallExpectation.fulfill() + } else { + XCTFail("Expected merge user API call was not made") + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaMetEmailMergeFalse() { // criteria met with merge false with setEmail + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + if let anonUser = localStorage.userIdAnnon { + XCTAssertFalse(anonUser.isEmpty, "Expected anon user") + } else { + XCTFail("Expected anon user but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnAnonymousToKnown: false) + IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaMetEmailMergeTrue() { // criteria met with merge true with setEmail + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + if let anonUser = localStorage.userIdAnnon { + XCTAssertFalse(anonUser.isEmpty, "Expected anon user") + } else { + XCTFail("Expected anon user but found nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnAnonymousToKnown: true) + IterableAPI.setEmail("testuser123@test.com", nil, identityResolution) + + // Verify "merge user" API call is made + let apiCallExpectation = self.expectation(description: "API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + apiCallExpectation.fulfill() + } else { + XCTFail("Expected merge user API call was not made") + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testIdentifiedEmailDefault() { // current user identified with setEmail default + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.setEmail("testuser123@test.com") + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") + } else { + XCTFail("Expected email but found nil") + } + + + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + + if localStorage.userIdAnnon != nil { + XCTFail("Expected anon user nil but found") + } else { + XCTAssertNil(localStorage.anonymousUserEvents, "Expected anon user to be nil") + } + + IterableAPI.setEmail("testuseranotheruser@test.com") + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuseranotheruser@test.com", "Expected email to be 'testuseranotheruser@test.com'") + } else { + XCTFail("Expected email but found nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testIdentifiedEmailMergeFalse() { // current user identified with setEmail merge false + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.setEmail("testuser123@test.com") + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") + } else { + XCTFail("Expected email but found nil") + } + + + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + + if localStorage.userIdAnnon != nil { + XCTFail("Expected anon user nil but found") + } else { + XCTAssertNil(localStorage.anonymousUserEvents, "Expected anon user to be nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnAnonymousToKnown: false) + IterableAPI.setEmail("testuseranotheruser@test.com", nil, identityResolution) + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuseranotheruser@test.com", "Expected email to be 'testuseranotheruser@test.com'") + } else { + XCTFail("Expected email but found nil") + } + + // Verify "merge user" API call is not made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + XCTFail("merge user API call was made unexpectedly") + } else { + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + + func testIdentifiedEmailMergeTrue() { // current user identified with setEmail true + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + IterableAPI.setEmail("testuser123@test.com") + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuser123@test.com", "Expected email to be 'testuser123@test.com'") + } else { + XCTFail("Expected email but found nil") + } + + + IterableAPI.track(event: "testEvent") + waitForDuration(seconds: 3) + + + if localStorage.userIdAnnon != nil { + XCTFail("Expected anon user nil but found") + } else { + XCTAssertNil(localStorage.anonymousUserEvents, "Expected anon user to be nil") + } + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnAnonymousToKnown: true) + IterableAPI.setEmail("testuseranotheruser@test.com", nil, identityResolution) + waitForDuration(seconds: 3) + + if let userId = IterableAPI.email { + XCTAssertEqual(userId, "testuseranotheruser@test.com", "Expected email to be 'testuseranotheruser@test.com'") + } else { + XCTFail("Expected email but found nil") + } + + // Verify "merge user" API call is made + let expectation = self.expectation(description: "No API call is made to merge user") + DispatchQueue.main.async { + if let _ = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + XCTFail("merge user API call was made unexpectedly") + } else { + // Pass the test if the API call was not made + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCriteriaMetTwice() { + let config = IterableConfig() + config.enableAnonActivation = true + + let mockSession = MockNetworkSession() + + IterableAPI.initializeForTesting(apiKey: UserMergeScenariosTests.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.logoutUser() + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + + IterableAPI.track(event: "testEvent") + IterableAPI.track(event: "testEvent") + + waitForDuration(seconds: 3) + + if let anonUser = localStorage.userIdAnnon { + XCTAssertFalse(anonUser.isEmpty, "Expected anon user nil") + } else { + XCTFail("Expected anon user nil but found") + } + + // Verify that anon session request was made exactly once + let anonSessionRequest = mockSession.getRequest(withEndPoint: Const.Path.trackAnonSession) + XCTAssertNotNil(anonSessionRequest, "Anonymous session request should not be nil") + + // Count total requests with anon session endpoint + let anonSessionRequests = mockSession.requests.filter { request in + request.url?.absoluteString.contains(Const.Path.trackAnonSession) == true + } + XCTAssertEqual(anonSessionRequests.count, 1, "Anonymous session should be called exactly once") + + // Verify track events were made + let trackRequests = mockSession.requests.filter { request in + request.url?.absoluteString.contains(Const.Path.trackEvent) == true + } + XCTAssertEqual(trackRequests.count, 2, "Track event should be called twice") + } +} + + diff --git a/tests/unit-tests/ValidateCustomEventUserUpdateAPITest.swift b/tests/unit-tests/ValidateCustomEventUserUpdateAPITest.swift new file mode 100644 index 000000000..0a270885a --- /dev/null +++ b/tests/unit-tests/ValidateCustomEventUserUpdateAPITest.swift @@ -0,0 +1,206 @@ +// +// ValidateCustomEventUserUpdateAPITest.swift +// unit-tests +// +// Created by Apple on 17/09/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class ValidateCustomEventUserUpdateAPITest: XCTestCase, AuthProvider { + private static let apiKey = "zeeApiKey" + private let authToken = "asdf" + private let dateProvider = MockDateProvider() + let mockSession = MockNetworkSession(statusCode: 200) + let localStorage = MockLocalStorage() + + var auth: Auth { + Auth(userId: nil, email: nil, authToken: authToken, userIdAnon: nil) + } + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + override func tearDown() { + // Clean up after each test + super.tearDown() + } + + + let mockData = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "6", + "name": "EventCriteria", + "createdAt": 1719328487701, + "updatedAt": 1719328487701, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "Equals", + "value": "animal-found", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "animal-found.type", + "comparatorType": "Equals", + "value": "cat", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "animal-found.count", + "comparatorType": "Equals", + "value": "6", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "animal-found.vaccinated", + "comparatorType": "Equals", + "value": "true", + "fieldType": "boolean" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + // Helper function to wait for a specified duration + private func waitForDuration(seconds: TimeInterval) { + let waitExpectation = expectation(description: "Waiting for \(seconds) seconds") + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: seconds + 1) + } + + func testCriteriaCustomEventCheck() { // criteria not met with merge false with setUserId + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: ValidateCustomEventUserUpdateAPITest.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.track(event: "button-clicked", dataFields: ["lastPageViewed":"signup page", "timestemp_createdAt": Int(Date().timeIntervalSince1970)]) + + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", + "count": 6, + "vaccinated": true]) + + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", + "count": 6, + "vaccinated": true]) + + let checker = CriteriaCompletionChecker(anonymousCriteria: jsonData, anonymousEvents:localStorage.anonymousUserEvents ?? []) + let matchedCriteriaId = checker.getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, "6") + + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", + "count": 6, + "vaccinated": true]) + waitForDuration(seconds: 3) + if let anonUser = localStorage.userIdAnnon { + XCTAssertFalse(anonUser.isEmpty, "Expected anon user") + } else { + XCTFail("Expected anon user but found nil") + } + + IterableAPI.logoutUser() + + waitForDuration(seconds: 3) + + + IterableAPI.setUserId("testuser123") + + + waitForDuration(seconds: 3) + + if localStorage.anonymousUserEvents != nil { + XCTFail("Expected local stored Event nil but found") + } else { + XCTAssertNil(localStorage.anonymousUserEvents, "Event found nil as user logout") + } + + + let dataFields = ["type": "cat", + "count": 6, + "vaccinated": true] as [String : Any] + IterableAPI.track(event: "animal-found", dataFields: dataFields) + + waitForDuration(seconds: 3) + if let request = self.mockSession.getRequest(withEndPoint: Const.Path.trackEvent) { + print(request) + TestUtils.validate(request: request, apiEndPoint: Endpoint.api, path: Const.Path.trackEvent) + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.eventName), value: "animal-found", inDictionary: request.bodyDict) + + + //Check direct key exist failure + TestUtils.validateNil(keyPath: KeyPath(keys: "count"), inDictionary: request.bodyDict) + TestUtils.validateNil(keyPath: KeyPath(keys: "type"), inDictionary: request.bodyDict) + TestUtils.validateNil(keyPath: KeyPath(keys: "vaccinated"), inDictionary: request.bodyDict) + + + //Check inside dataFields with nested key exist success + TestUtils.validateExists(keyPath: KeyPath(keys: JsonKey.dataFields, "count"), type: Int.self, inDictionary: request.bodyDict) + TestUtils.validateExists(keyPath: KeyPath(keys: JsonKey.dataFields, "type"), type: String.self, inDictionary: request.bodyDict) + TestUtils.validateExists(keyPath: KeyPath(keys: JsonKey.dataFields, "vaccinated"), type: Bool.self, inDictionary: request.bodyDict) + + + //Check inside dataFields with nested key success + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.dataFields, "type"), value: "cat", inDictionary: request.bodyDict) + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.dataFields, "count"), value: 6, inDictionary: request.bodyDict) + TestUtils.validateMatch(keyPath: KeyPath(keys: JsonKey.dataFields, "vaccinated"), value: true, inDictionary: request.bodyDict) + + //Check inside dataFields with nested key failure + TestUtils.validateNil(keyPath: KeyPath(keys: JsonKey.dataFields, "animal-found.count"), inDictionary: request.bodyDict) + TestUtils.validateNil(keyPath: KeyPath(keys: JsonKey.dataFields, "animal-found.type"), inDictionary: request.bodyDict) + TestUtils.validateNil(keyPath: KeyPath(keys: JsonKey.dataFields, "animal-found.vaccinated"), inDictionary: request.bodyDict) + + } else { + XCTFail("Expected track event API call was not made") + + } + + } + + +} diff --git a/tests/unit-tests/ValidateStoredEventCheckUnknownToKnownUserTest.swift b/tests/unit-tests/ValidateStoredEventCheckUnknownToKnownUserTest.swift new file mode 100644 index 000000000..608018d09 --- /dev/null +++ b/tests/unit-tests/ValidateStoredEventCheckUnknownToKnownUserTest.swift @@ -0,0 +1,78 @@ +// +// ValidateStoredEventCheckUnknownToKnownUserTest.swift +// unit-tests +// +// Created by Apple on 20/09/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class ValidateStoredEventCheckUnknownToKnownUserTest: XCTestCase, AuthProvider { + private static let apiKey = "zeeApiKey" + private let authToken = "asdf" + private let dateProvider = MockDateProvider() + let mockSession = MockNetworkSession(statusCode: 200) + let localStorage = MockLocalStorage() + + var auth: Auth { + Auth(userId: nil, email: nil, authToken: authToken, userIdAnon: nil) + } + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + override func tearDown() { + // Clean up after each test + super.tearDown() + } + + // Helper function to wait for a specified duration + private func waitForDuration(seconds: TimeInterval) { + let waitExpectation = expectation(description: "Waiting for \(seconds) seconds") + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: seconds + 1) + } + + func testCriteriaCustomEventCheck() { // criteria not met with merge false with setUserId + let config = IterableConfig() + config.enableAnonActivation = true + IterableAPI.initializeForTesting(apiKey: ValidateStoredEventCheckUnknownToKnownUserTest.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", "count": 16, "vaccinated": true]) + IterableAPI.track(purchase: 10.0, items: [CommerceItem(id: "mocha", name: "Mocha", price: 10.0, quantity: 17, dataFields: nil)]) + IterableAPI.updateCart(items: [CommerceItem(id: "fdsafds", name: "sneakers", price: 4, quantity: 3, dataFields: ["timestemp_createdAt": Int(Date().timeIntervalSince1970)])]) + IterableAPI.track(event: "button-clicked", dataFields: ["lastPageViewed":"signup page", "timestemp_createdAt": Int(Date().timeIntervalSince1970)]) + waitForDuration(seconds: 3) + + IterableAPI.setUserId("testuser123") + + if self.localStorage.anonymousUserEvents != nil { + XCTFail("Events are not replayed") + } else { + XCTAssertNil(localStorage.anonymousUserEvents, "Expected events to be nil") + } + + self.waitForDuration(seconds: 3) + + //Sync Completed + if self.localStorage.anonymousUserEvents != nil { + XCTFail("Expected local stored Event nil but found") + } else { + XCTAssertNil(self.localStorage.anonymousUserEvents, "Event found nil as event Sync Completed") + } + } + + +} diff --git a/tests/unit-tests/ValidateTokenForDestinationUserTest.swift b/tests/unit-tests/ValidateTokenForDestinationUserTest.swift new file mode 100644 index 000000000..d4cb11d23 --- /dev/null +++ b/tests/unit-tests/ValidateTokenForDestinationUserTest.swift @@ -0,0 +1,331 @@ +// +// ValidateTokenForDestinationUserTest.swift +// unit-tests +// +// Created by Apple on 22/10/24. +// Copyright © 2024 Iterable. All rights reserved. +// + +import XCTest +@testable import IterableSDK + +final class ValidateTokenForDestinationUserTest: XCTestCase { + + private static let apiKey = "zeeApiKey" + private static let email = "user@example.com" + private static let userId = "testUserId" + private static let userIdAnnonToken = "JWTAnnonToken" + private static let mergeUserIdToken = "mergeUserIdToken" + private static let mergeUserEmailToken = "mergeUserEmailToken" + private let dateProvider = MockDateProvider() + let mockSession = MockNetworkSession(statusCode: 200) + let localStorage = MockLocalStorage() + + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + override func tearDown() { + // Clean up after each test + super.tearDown() + } + + // Helper function to wait for a specified duration + private func waitForDuration(seconds: TimeInterval) { + let waitExpectation = expectation(description: "Waiting for \(seconds) seconds") + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: seconds + 1) + } + + let mockData = """ + { + "count": 1, + "criteriaSets": [ + { + "criteriaId": "6", + "name": "EventCriteria", + "createdAt": 1719328487701, + "updatedAt": 1719328487701, + "searchQuery": { + "combinator": "And", + "searchQueries": [ + { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "searchCombo": { + "combinator": "And", + "searchQueries": [ + { + "dataType": "customEvent", + "field": "eventName", + "comparatorType": "Equals", + "value": "animal-found", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "animal-found.type", + "comparatorType": "Equals", + "value": "cat", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "animal-found.count", + "comparatorType": "Equals", + "value": "6", + "fieldType": "string" + }, + { + "dataType": "customEvent", + "field": "animal-found.vaccinated", + "comparatorType": "Equals", + "value": "true", + "fieldType": "boolean" + } + ] + } + } + ] + } + ] + } + } + ] + } + """ + + class DefaultAuthDelegate: IterableAuthDelegate { + var authTokenGenerator: (() -> String?) + + init(_ authTokenGenerator: @escaping () -> String?) { + self.authTokenGenerator = authTokenGenerator + } + + func onAuthTokenRequested(completion: @escaping AuthTokenRetrievalHandler) { + completion(authTokenGenerator()) + } + + func onAuthFailure(_ authFailure: AuthFailure) { + + } + } + + private func createAuthDelegate(_ authTokenGenerator: @escaping () -> String?) -> IterableAuthDelegate { + return DefaultAuthDelegate(authTokenGenerator) + } + + func testCriteriaUserIdTokenCheck() { // criteria not met with merge false with setUserId + + let authDelegate = createAuthDelegate({ + if self.localStorage.userIdAnnon == IterableAPI.userId { + return ValidateTokenForDestinationUserTest.userIdAnnonToken + } else if IterableAPI.userId == ValidateTokenForDestinationUserTest.userId { + return ValidateTokenForDestinationUserTest.mergeUserIdToken + } else { + return nil + } + + }) + + let config = IterableConfig() + config.enableAnonActivation = true + config.authDelegate = authDelegate + IterableAPI.initializeForTesting(apiKey: ValidateTokenForDestinationUserTest.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.track(event: "button-clicked", dataFields: ["lastPageViewed":"signup page", "timestemp_createdAt": Int(Date().timeIntervalSince1970)]) + + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", + "count": 6, + "vaccinated": true]) + + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let expectation = XCTestExpectation(description: "testTrackEventWithCreateAnnonUser") + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", + "count": 6, + "vaccinated": true]) + + let checker = CriteriaCompletionChecker(anonymousCriteria: jsonData, anonymousEvents:localStorage.anonymousUserEvents ?? []) + let matchedCriteriaId = checker.getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, "6") + + waitForDuration(seconds: 5) + + let trackDataField = ["type": "cat", + "count": 6, + "vaccinated": true] as [String : Any] + IterableAPI.track(event: "animal-found", dataFields:trackDataField , onSuccess: { _ in + let request = self.mockSession.getRequest(withEndPoint: Const.Path.trackEvent)! + TestUtils.validate(request: request, requestType: .post, apiEndPoint: Endpoint.api, path: Const.Path.trackEvent, queryParams: []) + if let requestHeader = request.allHTTPHeaderFields, let token = requestHeader["Authorization"] { + XCTAssertEqual(token, "Bearer \(ValidateTokenForDestinationUserTest.userIdAnnonToken)") + } + expectation.fulfill() + }) { reason, _ in + expectation.fulfill() + if let reason = reason { + XCTFail("encountered error: \(reason)") + } else { + XCTFail("encountered error") + } + } + + wait(for: [expectation], timeout: testExpectationTimeout) + + if let anonUser = localStorage.userIdAnnon { + XCTAssertFalse(anonUser.isEmpty, "Expected anon user") + } else { + XCTFail("Expected anon user but found nil") + } + XCTAssertEqual(IterableAPI.userId, localStorage.userIdAnnon) + XCTAssertNil(IterableAPI.email) + XCTAssertEqual(IterableAPI.authToken, ValidateTokenForDestinationUserTest.userIdAnnonToken) + + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnAnonymousToKnown: true) + IterableAPI.setUserId(ValidateTokenForDestinationUserTest.userId, nil, identityResolution) + + // Verify "merge user" API call is made + let expectation1 = XCTestExpectation(description: "API call is made to merge user") + DispatchQueue.main.async { + if let request = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + TestUtils.validate(request: request, requestType: .post, apiEndPoint: Endpoint.api, path: Const.Path.mergeUser, queryParams: []) + if let requestHeader = request.allHTTPHeaderFields, let token = requestHeader["Authorization"] { + XCTAssertEqual(token, "Bearer \(ValidateTokenForDestinationUserTest.mergeUserIdToken)") + } + expectation1.fulfill() + } else { + expectation1.fulfill() + XCTFail("Expected merge user API call was not made") + } + } + wait(for: [expectation1], timeout: testExpectationTimeout) + XCTAssertEqual(IterableAPI.userId, ValidateTokenForDestinationUserTest.userId) + XCTAssertNil(IterableAPI.email) + XCTAssertEqual(IterableAPI.authToken, ValidateTokenForDestinationUserTest.mergeUserIdToken) + } + + func testCriteriaEmailTokenCheck() { // criteria not met with merge false with setUserId + + let authDelegate = createAuthDelegate({ + if self.localStorage.userIdAnnon == IterableAPI.userId { + return ValidateTokenForDestinationUserTest.userIdAnnonToken + } else if IterableAPI.userId == ValidateTokenForDestinationUserTest.userId { + return ValidateTokenForDestinationUserTest.mergeUserIdToken + } else if IterableAPI.email == ValidateTokenForDestinationUserTest.email { + return ValidateTokenForDestinationUserTest.mergeUserEmailToken + } else { + return nil + } + + }) + + let config = IterableConfig() + config.enableAnonActivation = true + config.authDelegate = authDelegate + IterableAPI.initializeForTesting(apiKey: ValidateTokenForDestinationUserTest.apiKey, + config: config, + networkSession: mockSession, + localStorage: localStorage) + + IterableAPI.track(event: "button-clicked", dataFields: ["lastPageViewed":"signup page", "timestemp_createdAt": Int(Date().timeIntervalSince1970)]) + + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", + "count": 6, + "vaccinated": true]) + + guard let jsonData = mockData.data(using: .utf8) else { return } + localStorage.criteriaData = jsonData + + if let events = localStorage.anonymousUserEvents { + XCTAssertFalse(events.isEmpty, "Expected events to be logged") + } else { + XCTFail("Expected events to be logged but found nil") + } + + let expectation = XCTestExpectation(description: "testTrackEventWithCreateAnnonUser") + IterableAPI.track(event: "animal-found", dataFields: ["type": "cat", + "count": 6, + "vaccinated": true]) + + let checker = CriteriaCompletionChecker(anonymousCriteria: jsonData, anonymousEvents:localStorage.anonymousUserEvents ?? []) + let matchedCriteriaId = checker.getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, "6") + + waitForDuration(seconds: 5) + + let trackDataField = ["type": "cat", + "count": 6, + "vaccinated": true] as [String : Any] + IterableAPI.track(event: "animal-found", dataFields:trackDataField , onSuccess: { _ in + let request = self.mockSession.getRequest(withEndPoint: Const.Path.trackEvent)! + TestUtils.validate(request: request, requestType: .post, apiEndPoint: Endpoint.api, path: Const.Path.trackEvent, queryParams: []) + if let requestHeader = request.allHTTPHeaderFields, let token = requestHeader["Authorization"] { + XCTAssertEqual(token, "Bearer \(ValidateTokenForDestinationUserTest.userIdAnnonToken)") + } + expectation.fulfill() + }) { reason, _ in + expectation.fulfill() + if let reason = reason { + XCTFail("encountered error: \(reason)") + } else { + XCTFail("encountered error") + } + } + + wait(for: [expectation], timeout: testExpectationTimeout) + + if let anonUser = localStorage.userIdAnnon { + XCTAssertFalse(anonUser.isEmpty, "Expected anon user") + } else { + XCTFail("Expected anon user but found nil") + } + XCTAssertEqual(IterableAPI.userId, localStorage.userIdAnnon) + XCTAssertNil(IterableAPI.email) + XCTAssertEqual(IterableAPI.authToken, ValidateTokenForDestinationUserTest.userIdAnnonToken) + + let identityResolution = IterableIdentityResolution(replayOnVisitorToKnown: true, mergeOnAnonymousToKnown: true) + IterableAPI.setEmail(ValidateTokenForDestinationUserTest.email, nil, identityResolution) + + // Verify "merge user" API call is made + let expectation1 = XCTestExpectation(description: "API call is made to merge user") + DispatchQueue.main.async { + if let request = self.mockSession.getRequest(withEndPoint: Const.Path.mergeUser) { + // Pass the test if the API call was made + TestUtils.validate(request: request, requestType: .post, apiEndPoint: Endpoint.api, path: Const.Path.mergeUser, queryParams: []) + if let requestHeader = request.allHTTPHeaderFields, let token = requestHeader["Authorization"] { + XCTAssertEqual(token, "Bearer \(ValidateTokenForDestinationUserTest.mergeUserEmailToken)") + } + expectation1.fulfill() + } else { + expectation1.fulfill() + XCTFail("Expected merge user API call was not made") + } + } + wait(for: [expectation1], timeout: testExpectationTimeout) + XCTAssertEqual(IterableAPI.email, ValidateTokenForDestinationUserTest.email) + XCTAssertNil(IterableAPI.userId) + XCTAssertEqual(IterableAPI.authToken, ValidateTokenForDestinationUserTest.mergeUserEmailToken) + } +}