From 6377fe6f90d83dd72d4e1d1763e0351901cab3be Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Mon, 15 Jan 2024 17:14:28 +0600 Subject: [PATCH 01/37] Add coccoapods support for privacy manifest (#542) --- OptimizelySwiftSDK.podspec | 1 + 1 file changed, 1 insertion(+) diff --git a/OptimizelySwiftSDK.podspec b/OptimizelySwiftSDK.podspec index 62b419d6..a8e45f2d 100644 --- a/OptimizelySwiftSDK.podspec +++ b/OptimizelySwiftSDK.podspec @@ -15,6 +15,7 @@ Pod::Spec.new do |s| :tag => "v"+s.version.to_s } s.source_files = "Sources/**/*.swift" + s.resource_bundles = { 'OptimizelySwiftSDK' => ['Sources/Supporting Files/PrivacyInfo.xcprivacy'] } s.swift_version = ["5.0", "5.1"] s.framework = "Foundation" s.requires_arc = true From f1de194a0365fa2ae44c833e3ffafeb154332026 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Mon, 15 Jan 2024 17:14:49 +0600 Subject: [PATCH 02/37] Privacy access api types value updated (#541) --- Sources/Supporting Files/PrivacyInfo.xcprivacy | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Supporting Files/PrivacyInfo.xcprivacy b/Sources/Supporting Files/PrivacyInfo.xcprivacy index 1b18d180..8042b209 100644 --- a/Sources/Supporting Files/PrivacyInfo.xcprivacy +++ b/Sources/Supporting Files/PrivacyInfo.xcprivacy @@ -20,12 +20,12 @@ NSPrivacyAccessedAPITypes + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults NSPrivacyAccessedAPITypeReasons - To store configuration and event data temporarily + CA92.1 - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryUserDefaults From 988338ea96417afa26fcb5644f5c58627e5526a5 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Thu, 18 Jan 2024 23:03:00 +0600 Subject: [PATCH 03/37] [FSSDK-9950] chore: SPM support added to process privacy manifest (#544) * SPM support added for privacy manifes file * Compiled language version added --- Package.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index ab02c530..22653f4e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,8 @@ -// swift-tools-version:5.0 +// swift-tools-version:5.3 +// The Swift tools version declares the version of the PackageDescription library, +// the minimum version of the Swift tools and Swift language compatibility version to process the manifest, +// and the minimum version of the Swift tools that are needed to use the Swift package. + import PackageDescription let package = Package( @@ -14,7 +18,11 @@ let package = Package( targets: ["Optimizely"]) ], targets: [ - .target(name: "Optimizely", path: "Sources") + .target( + name: "Optimizely", + path: "Sources", + resources: [.copy("Supporting Files/PrivacyInfo.xcprivacy")] + ) ], - swiftLanguageVersions: [.v5] + swiftLanguageVersions: [.v5, .version("5.9")] ) From efe87bf18be47ef48752ac6fbbd43b403f046fda Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Thu, 18 Jan 2024 23:33:29 +0600 Subject: [PATCH 04/37] [FSSDK-8553] chore: prepare for release 4.0.0 (#546) * Update README.md * Update CHANGELOG.md * Update swift.yml --- .github/workflows/swift.yml | 2 +- CHANGELOG.md | 57 +++++++++++++++++++++++++++++++++++++ README.md | 4 +-- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index dc913225..a56ea887 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -17,7 +17,7 @@ on: description: release env: - VERSION: 3.10.4 + VERSION: 4.0.0 jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index b82b4130..9387f9d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # Optimizely Swift SDK Changelog +## 4.0.0 +Jan 18, 2024 + +### New Features + +The 4.0.0 release introduces a new primary feature, [Advanced Audience Targeting]( https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) enabled through integration with [Optimizely Data Platform (ODP)](https://docs.developers.optimizely.com/optimizely-data-platform/docs) ([#455](https://github.com/optimizely/swift-sdk/pull/455), [#470](https://github.com/optimizely/swift-sdk/pull/470), [#471](https://github.com/optimizely/swift-sdk/pull/471), [#477](https://github.com/optimizely/swift-sdk/pull/477),[#512](https://github.com/optimizely/swift-sdk/pull/512)). + +You can use ODP, a high-performance [Customer Data Platform (CDP)]( https://www.optimizely.com/optimization-glossary/customer-data-platform/), to easily create complex real-time segments (RTS) using first-party and 50+ third-party data sources out of the box. You can create custom schemas that support the user attributes important for your business, and stitch together user behavior done on different devices to better understand and target your customers for personalized user experiences. ODP can be used as a single source of truth for these segments in any Optimizely or 3rd party tool. + +With ODP accounts integrated into Optimizely projects, you can build audiences using segments pre-defined in ODP. The SDK will fetch the segments for given users and make decisions using the segments. For access to ODP audience targeting in your Feature Experimentation account, please contact your Customer Success Manager. + +This version includes the following changes: + +* New API added to `OptimizelyUserContext`: + + - `fetchQualifiedSegments()`: this API will retrieve user segments from the ODP server. The fetched segments will be used for audience evaluation. The fetched data will be stored in the local cache to avoid repeated network delays. + + - When an `OptimizelyUserContext` is created, the SDK will automatically send an identify request to the ODP server to facilitate observing user activities. + +* New APIs added to `OptimizelyClient`: + + - `sendOdpEvent()`: customers can build/send arbitrary ODP events that will bind user identifiers and data to user profiles in ODP. + + - `createUserContext()` with anonymous user IDs: user-contexts can be created without a userId. The SDK will create and use a persistent `VUID` specific to a device when userId is not provided. + +For details, refer to our documentation pages: + +* [Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) + +* [Client SDK Support](https://docs.developers.optimizely.com/feature-experimentation/v1.0/docs/advanced-audience-targeting-for-client-side-sdks) + +* [Initialize Swift SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/initialize-sdk-swift) + +* [OptimizelyUserContext Swift SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizelyusercontext-swift) + +* [Advanced Audience Targeting segment qualification methods](https://docs.developers.optimizely.com/feature-experimentation/docs/advanced-audience-targeting-segment-qualification-methods-swift) + +* [Send Optimizely Data Platform data using Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/send-odp-data-using-advanced-audience-targeting-swift) + +### Breaking Changes + +* `ODPManager` in the SDK is enabled by default. Unless an ODP account is integrated into the Optimizely projects, most `ODPManager` functions will be ignored. If needed, `ODPManager` can be disabled when `OptimizelyClient` is instantiated. + +### Bug Fixes +* Adds a check to only save valid datafile in cache. ([#514](https://github.com/optimizely/swift-sdk/pull/514)) +* Remove redundant post request body in upload task. ([#521](https://github.com/optimizely/swift-sdk/pull/521/)). +* Privacy manifest file value for required reason API fixed. ([#541](https://github.com/optimizely/swift-sdk/pull/541)) +* Add coccoapods support for privacy manifest. ([#542](https://github.com/optimizely/swift-sdk/pull/542)) +* Add SPM support added to process privacy manifest. ([#544](https://github.com/optimizely/swift-sdk/pull/544)) + +### Functionality Enhancement +* Adds support to override sdkName and sdkVersion for events. ([#512](https://github.com/optimizely/swift-sdk/pull/512)) +* Swift async-await support. ([#513](https://github.com/optimizely/swift-sdk/pull/513)) +* Update Github Issue Templates. ([#516](https://github.com/optimizely/swift-sdk/pull/516)) +* Handle duplicate keys in experiment ([#523](https://github.com/optimizely/swift-sdk/pull/523/)). +* Add privacy manifest file ([#522](https://github.com/optimizely/swift-sdk/pull/522/)). + ## 3.10.4 December 8, 2023 diff --git a/README.md b/README.md index 2d387b05..aa8cc378 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ If you have a name conflict with other swift packages when you add the Optimizel #### CocoaPods 1. Add the following lines to the _Podfile_:
 ```use_frameworks!```
-```pod 'OptimizelySwiftSDK', '~> 3.10.4'```
+```pod 'OptimizelySwiftSDK', '~> 4.0.0'```
 
2. Run the following command:
``` pod install ```
@@ -47,7 +47,7 @@ If you have a name conflict with other swift packages when you add the Optimizel Further installation instructions for Cocoapods: https://guides.cocoapods.org/using/getting-started.html #### Carthage -1. Add the following lines to the _Cartfile_:
```github "optimizely/swift-sdk" ~> 3.10.4```
+1. Add the following lines to the _Cartfile_:
```github "optimizely/swift-sdk" ~> 4.0.0```
2. Run the following command:
```carthage update```
From 38520c801374511611a144e1f7fcd141e5856c14 Mon Sep 17 00:00:00 2001 From: optibot Date: Fri, 19 Jan 2024 02:02:00 +0100 Subject: [PATCH 05/37] ci(git-action): auto release prep for 4.0.0 (#547) Co-authored-by: optibot --- OptimizelySwiftSDK.podspec | 2 +- Sources/Utils/SDKVersion.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/OptimizelySwiftSDK.podspec b/OptimizelySwiftSDK.podspec index a8e45f2d..b874877c 100644 --- a/OptimizelySwiftSDK.podspec +++ b/OptimizelySwiftSDK.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "OptimizelySwiftSDK" s.module_name = "Optimizely" - s.version = "4.0.0-beta" + s.version = "4.0.0" s.summary = "Optimizely experiment framework for iOS/tvOS/watchOS" s.homepage = "https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs" s.license = { :type => "Apache License, Version 2.0", :file => "LICENSE" } diff --git a/Sources/Utils/SDKVersion.swift b/Sources/Utils/SDKVersion.swift index 833a533e..2c4f90a5 100644 --- a/Sources/Utils/SDKVersion.swift +++ b/Sources/Utils/SDKVersion.swift @@ -17,4 +17,4 @@ /// Do not edit this field. /// - It is auto updated (Scripts/updated_version.sh) to reflect the current version /// - Do not put underscores in the name (Swiftlint can modify unexpectedly) -let OPTIMIZELYSDKVERSION = "4.0.0-beta" +let OPTIMIZELYSDKVERSION = "4.0.0" From 2bcfcd959c07aae1c1a98193675179715bf938c6 Mon Sep 17 00:00:00 2001 From: Farhan Anjum Date: Fri, 27 Sep 2024 22:27:31 +0600 Subject: [PATCH 06/37] FSSDK-10665] fix: Github Actions YAML files vulnerable to script injections corrected (#555) --- .github/workflows/integration_tests.yml | 10 +++++++--- .github/workflows/unit_tests.yml | 8 ++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 181fcbf4..1064e691 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -20,14 +20,18 @@ jobs: path: 'home/runner/travisci-tools' ref: 'master' - name: set SDK Branch if PR + env: + HEAD_REF: ${{ github.head_ref }} if: ${{ github.event_name == 'pull_request' }} run: | - echo "SDK_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV + echo "SDK_BRANCH=$HEAD_REF" >> $GITHUB_ENV - name: set SDK Branch if not pull request + env: + REF_NAME: ${{ github.ref_name }} if: ${{ github.event_name != 'pull_request' }} run: | - echo "SDK_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV - echo "TRAVIS_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + echo "SDK_BRANCH=$REF_NAME" >> $GITHUB_ENV + echo "TRAVIS_BRANCH=$REF_NAME" >> $GITHUB_ENV - name: Trigger build env: SDK: swift diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 3fd6ddfd..dff3121d 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -54,13 +54,17 @@ jobs: # macos version and supported simulator_xcode_versions are all related to this xcode_version, so be careful when you upgrade this. xcode-version: 14.1 - name: set SDK Branch if PR + env: + BASE_REF: ${{ github.base_ref }} if: ${{ github.event_name == 'pull_request' }} run: | - echo "BRANCH=${{ github.base_ref }}" >> $GITHUB_ENV + echo "BRANCH=$BASE_REF" >> $GITHUB_ENV - name: set SDK Branch if not pull request + env: + REF_NAME: ${{ github.ref_name }} if: ${{ github.event_name != 'pull_request' }} run: | - echo "BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + echo "BRANCH=$REF_NAME" >> $GITHUB_ENV - id: unit_tests env: SCHEME: ${{ matrix.scheme }} From 548f37193e1cb6c828529090d061c5e6a1ace988 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Fri, 8 Nov 2024 18:06:03 +0600 Subject: [PATCH 07/37] [FSSDK-10771] Implement UPS request batching for decideForKeys (#559) UPS batch update --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 34 ++++ .../DefaultDecisionService.swift | 149 ++++++++++---- .../Implementation/UserProfileTracker.swift | 67 ++++++ .../OptimizelyClient+Decide.swift | 191 +++++++++++------- .../DecisionListenerTests.swift | 10 + .../DecisionServiceTests_Features.swift | 71 +++++++ 6 files changed, 409 insertions(+), 113 deletions(-) create mode 100644 Sources/Implementation/UserProfileTracker.swift diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index d5c85cdd..af0854c4 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -1984,6 +1984,22 @@ 984E2FDD2B27199C001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; 984E2FDE2B27199D001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; 984E2FDF2B27199D001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; + 984FE5112CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5122CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5132CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5142CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5152CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5162CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5172CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5182CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5192CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE51A2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE51B2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE51C2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE51D2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE51E2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE51F2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 984FE5202CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; BD1C3E8524E4399C0084B4DA /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B97DD93249D327F003DE606 /* SemanticVersion.swift */; }; BD64853C2491474500F30986 /* Optimizely.h in Headers */ = {isa = PBXBuildFile; fileRef = 6E75167A22C520D400B2B157 /* Optimizely.h */; settings = {ATTRIBUTES = (Public, ); }; }; BD64853E2491474500F30986 /* Audience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E75169822C520D400B2B157 /* Audience.swift */; }; @@ -2422,6 +2438,7 @@ 84F6BADC27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP_Decide.swift; sourceTree = ""; }; 98137C542A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_Init_Async_Await.swift; sourceTree = ""; }; 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP_Aync_Await.swift; sourceTree = ""; }; + 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileTracker.swift; sourceTree = ""; }; 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; BD6485812491474500F30986 /* Optimizely.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Optimizely.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyJSON.swift; sourceTree = ""; }; @@ -2762,6 +2779,7 @@ 6E75167E22C520D400B2B157 /* DefaultBucketer.swift */, 6E75167F22C520D400B2B157 /* DefaultNotificationCenter.swift */, 6E75168022C520D400B2B157 /* DefaultDecisionService.swift */, + 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */, 6EF8DE3024BF7D69008B9488 /* DecisionReasons.swift */, 6E994B3325A3E6EA00999262 /* DecisionResponse.swift */, 6E75168122C520D400B2B157 /* Datastore */, @@ -4129,6 +4147,7 @@ 6E14CDA22423F9C300010234 /* Array+Extension.swift in Sources */, 848617CF2863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, 6E14CD952423F9A700010234 /* Group.swift in Sources */, + 984FE5142CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 84E2E96828540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, 6E14CD9A2423F9C300010234 /* DataStoreQueueStack.swift in Sources */, 6E14CD732423F96F00010234 /* OptimizelyResult.swift in Sources */, @@ -4273,6 +4292,7 @@ 6E424D1126324B620081004A /* Variable.swift in Sources */, 6E424D1226324B620081004A /* Attribute.swift in Sources */, 6E424D1326324B620081004A /* BackgroundingCallbacks.swift in Sources */, + 984FE5112CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 845945C2287758A000D13E11 /* OdpConfig.swift in Sources */, 6E424D1426324B620081004A /* OPTNotificationCenter.swift in Sources */, 6E424D5026324C4D0081004A /* OptimizelyDecideOption.swift in Sources */, @@ -4343,6 +4363,7 @@ 8464087128130D3200CCF97D /* Integration.swift in Sources */, 6E623F03253F9045000617D0 /* DecisionInfo.swift in Sources */, 845945BD2877589E00D13E11 /* OdpConfig.swift in Sources */, + 984FE51C2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171322C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 6E75191922C520D500B2B157 /* OPTNotificationCenter.swift in Sources */, 6E7518A122C520D400B2B157 /* FeatureFlag.swift in Sources */, @@ -4433,6 +4454,7 @@ 6E75173222C520D400B2B157 /* Constants.swift in Sources */, 848617D42863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, 6E75184822C520D400B2B157 /* Event.swift in Sources */, + 984FE5172CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 84E2E96D28540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, 6E75170E22C520D400B2B157 /* OptimizelyClient.swift in Sources */, 6E75177A22C520D400B2B157 /* SDKVersion.swift in Sources */, @@ -4601,6 +4623,7 @@ 6E20050C26B4D28500278087 /* MockLogger.swift in Sources */, 6E75176A22C520D400B2B157 /* Utils.swift in Sources */, 6E75171622C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, + 984FE5152CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E7517F022C520D400B2B157 /* DataStoreMemory.swift in Sources */, 6E9B11D922C548A200C22D81 /* OptimizelyClientTests_Invalid.swift in Sources */, 848617D02863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, @@ -4702,6 +4725,7 @@ 6E7518EF22C520D400B2B157 /* ConditionHolder.swift in Sources */, 6E75182F22C520D400B2B157 /* BatchEvent.swift in Sources */, 6E75191F22C520D500B2B157 /* OPTNotificationCenter.swift in Sources */, + 984FE5202CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E7518B322C520D400B2B157 /* Group.swift in Sources */, 6E20050F26B4D28500278087 /* MockLogger.swift in Sources */, 6EC6DD3A24ABF6990017D296 /* OptimizelyClient+Decide.swift in Sources */, @@ -4870,6 +4894,7 @@ 6E20051126B4D28600278087 /* MockLogger.swift in Sources */, 6E7516DF22C520D400B2B157 /* OPTUserProfileService.swift in Sources */, 6EF8DE3C24BF7D69008B9488 /* DecisionReasons.swift in Sources */, + 984FE5182CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E7518B522C520D400B2B157 /* Group.swift in Sources */, 6E9B116B22C5487100C22D81 /* NotificationCenterTests.swift in Sources */, 6E7516F722C520D400B2B157 /* OptimizelyError.swift in Sources */, @@ -4972,6 +4997,7 @@ 84E2E96F28540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, 6E7517A022C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, 6E7517AC22C520D400B2B157 /* Array+Extension.swift in Sources */, + 984FE5132CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6EA425A52218E6AE00B074B5 /* (null) in Sources */, 6E8A3D522637408500DAEA13 /* MockDatafileHandler.swift in Sources */, 6E75180E22C520D400B2B157 /* DataStoreFile.swift in Sources */, @@ -5070,6 +5096,7 @@ 6E6522E3278E4F3800954EA1 /* OdpManager.swift in Sources */, 6EA2CC272345618E001E7531 /* OptimizelyConfig.swift in Sources */, 84861815286D0B8900B7F41B /* OdpVuidManagerTests.swift in Sources */, + 984FE51E2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, C78CAFA724486E0A009FE876 /* OptimizelyJSON+ObjC.swift in Sources */, 6E75185B22C520D400B2B157 /* FeatureVariable.swift in Sources */, 6E7516B522C520D400B2B157 /* DefaultUserProfileService.swift in Sources */, @@ -5239,6 +5266,7 @@ 84E2E96A28540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, 6E75179B22C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, 6E7517A722C520D400B2B157 /* Array+Extension.swift in Sources */, + 984FE5162CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6EA425962218E6AD00B074B5 /* (null) in Sources */, 6E8A3D4D2637408500DAEA13 /* MockDatafileHandler.swift in Sources */, 6E75180922C520D400B2B157 /* DataStoreFile.swift in Sources */, @@ -5307,6 +5335,7 @@ 84B4D75A27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */, 6E7517DA22C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E7517E622C520D400B2B157 /* DefaultDecisionService.swift in Sources */, + 984FE51F2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171822C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 6E75174822C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 84E2E94C2852A378001114AB /* OdpVuidManager.swift in Sources */, @@ -5408,6 +5437,7 @@ 84B4D75F27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */, 6E7517DF22C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E7517EB22C520D400B2B157 /* DefaultDecisionService.swift in Sources */, + 984FE51D2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171D22C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 6E75174D22C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 84E2E9512852A378001114AB /* OdpVuidManager.swift in Sources */, @@ -5493,6 +5523,7 @@ 8464087028130D3200CCF97D /* Integration.swift in Sources */, 6E623F02253F9045000617D0 /* DecisionInfo.swift in Sources */, 845945BC2877589D00D13E11 /* OdpConfig.swift in Sources */, + 984FE5192CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75184022C520D400B2B157 /* Event.swift in Sources */, 6E7516E222C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E7517D422C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, @@ -5583,6 +5614,7 @@ 6E75172C22C520D400B2B157 /* Constants.swift in Sources */, 848617CC2863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, 6E75184222C520D400B2B157 /* Event.swift in Sources */, + 984FE5122CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 84E2E96528540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, 6E75170822C520D400B2B157 /* OptimizelyClient.swift in Sources */, 6E75177422C520D400B2B157 /* SDKVersion.swift in Sources */, @@ -5727,6 +5759,7 @@ 75C71A2925E454460084187E /* ProjectConfig.swift in Sources */, 75C71A2A25E454460084187E /* FeatureVariable.swift in Sources */, 75C71A2B25E454460084187E /* Rollout.swift in Sources */, + 984FE51B2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E424BFF263228FD0081004A /* AtomicDictionary.swift in Sources */, 75C71A2C25E454460084187E /* Variation.swift in Sources */, 75C71A2D25E454460084187E /* TrafficAllocation.swift in Sources */, @@ -5782,6 +5815,7 @@ 8464087228130D3200CCF97D /* Integration.swift in Sources */, 6E623F04253F9045000617D0 /* DecisionInfo.swift in Sources */, 845945BE2877589E00D13E11 /* OdpConfig.swift in Sources */, + 984FE51A2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, BD6485462491474500F30986 /* Event.swift in Sources */, BD6485472491474500F30986 /* OPTEventDispatcher.swift in Sources */, BD6485482491474500F30986 /* DefaultNotificationCenter.swift in Sources */, diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 276d3a15..9267a4f6 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -22,31 +22,54 @@ struct FeatureDecision { let source: String } +typealias UserProfile = OPTUserProfileService.UPProfile + class DefaultDecisionService: OPTDecisionService { - let bucketer: OPTBucketer let userProfileService: OPTUserProfileService - // thread-safe lazy logger load (after HandlerRegisterService ready) private let threadSafeLogger = ThreadSafeLogger() - var logger: OPTLogger { - return threadSafeLogger.logger - } // user-profile-service read-modify-write lock for supporting multiple clients static let upsRMWLock = DispatchQueue(label: "ups-rmw") + var logger: OPTLogger { + return threadSafeLogger.logger + } + init(userProfileService: OPTUserProfileService) { self.bucketer = DefaultBucketer() self.userProfileService = userProfileService } + /// Public Method func getVariation(config: ProjectConfig, experiment: Experiment, user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { - let reasons = DecisionReasons(options: options) + let userId = user.userId + let ignoreUPS = (options ?? []).contains(.ignoreUserProfileService) + var profileTracker: UserProfileTracker? + if !ignoreUPS { + profileTracker = UserProfileTracker(userId: userId, userProfileService: self.userProfileService, logger: self.logger) + profileTracker?.loadUserProfile() + } + + let response = getVariation(config: config, experiment: experiment, user: user, userProfileTracker: profileTracker) + if (!ignoreUPS) { + profileTracker?.save() + } + + return response + } + + func getVariation(config: ProjectConfig, + experiment: Experiment, + user: OptimizelyUserContext, + options: [OptimizelyDecideOption]? = nil, + userProfileTracker: UserProfileTracker?) -> DecisionResponse { + let reasons = DecisionReasons(options: options) let userId = user.userId let attributes = user.attributes let experimentId = experiment.id @@ -64,7 +87,9 @@ class DefaultDecisionService: OPTDecisionService { // ---- check if the user is forced into a variation ---- let decisionResponse = config.getForcedVariation(experimentKey: experiment.key, userId: userId) + reasons.merge(decisionResponse.reasons) + if let variationId = decisionResponse.result?.id, let variation = experiment.getVariation(id: variationId) { return DecisionResponse(result: variation, reasons: reasons) @@ -85,11 +110,9 @@ class DefaultDecisionService: OPTDecisionService { reasons.addInfo(info) } - // ---- check if a valid variation is stored in the user profile ---- - let ignoreUPS = (options ?? []).contains(.ignoreUserProfileService) - - if !ignoreUPS, - let variationId = getVariationIdFromProfile(userId: userId, experimentId: experimentId), + /// Load variation from tracker + if let profile = userProfileTracker?.userProfile, + let variationId = getVariationIdFromProfile(profile: profile, experimentId: experimentId), let variation = experiment.getVariation(id: variationId) { let info = LogMessage.gotVariationFromUserProfile(variation.key, experiment.key, userId) @@ -104,22 +127,21 @@ class DefaultDecisionService: OPTDecisionService { experiment: experiment, user: user) reasons.merge(audienceResponse.reasons) + if audienceResponse.result ?? false { // bucket user into a variation let decisionResponse = bucketer.bucketExperiment(config: config, experiment: experiment, bucketingId: bucketingId) reasons.merge(decisionResponse.reasons) + bucketedVariation = decisionResponse.result if let variation = bucketedVariation { let info = LogMessage.userBucketedIntoVariationInExperiment(userId, experiment.key, variation.key) logger.i(info) reasons.addInfo(info) - // save to user profile - if !ignoreUPS { - self.saveProfile(userId: userId, experimentId: experimentId, variationId: variation.id) - } + userProfileTracker?.updateProfile(experiment: experiment, variation: variation) } else { let info = LogMessage.userNotBucketedIntoVariation(userId) logger.i(info) @@ -190,42 +212,72 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: result, reasons: reasons) } + /// Public Method func getVariationForFeature(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + + let response = getVariationForFeatureList(config: config, featureFlags: [featureFlag], user: user, options: options).first + + guard response?.result != nil else { + let reasons = response?.reasons ?? DecisionReasons(options: options) + return DecisionResponse(result: nil, reasons: reasons) + } + + return response! + } + + func getVariationForFeatureList(config: ProjectConfig, + featureFlags: [FeatureFlag], + user: OptimizelyUserContext, + options: [OptimizelyDecideOption]? = nil) -> [DecisionResponse] { + let reasons = DecisionReasons(options: options) + let userId = user.userId + let ignoreUPS = (options ?? []).contains(.ignoreUserProfileService) + var profileTracker: UserProfileTracker? + if !ignoreUPS { + profileTracker = UserProfileTracker(userId: userId, userProfileService: self.userProfileService, logger: self.logger) + profileTracker?.loadUserProfile() + } - // Evaluate in this order: + var decisions = [DecisionResponse]() - // 1. Attempt to bucket user into experiment using feature flag. - // Check if the feature flag is under an experiment and the the user is bucketed into one of these experiments - var decisionResponse = getVariationForFeatureExperiment(config: config, - featureFlag: featureFlag, - user: user, - options: options) - reasons.merge(decisionResponse.reasons) - if let decision = decisionResponse.result { - return DecisionResponse(result: decision, reasons: reasons) + for featureFlag in featureFlags { + var decisionResponse = getVariationForFeatureExperiment(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker) + + reasons.merge(decisionResponse.reasons) + + if let decision = decisionResponse.result { + decisions.append(DecisionResponse(result: decision, reasons: reasons)) + continue + } + + decisionResponse = getVariationForFeatureRollout(config: config, featureFlag: featureFlag, user: user) + + reasons.merge(decisionResponse.reasons) + + if let decision = decisionResponse.result { + decisions.append(DecisionResponse(result: decision, reasons: reasons)) + } else { + decisions.append(DecisionResponse(result: nil, reasons: reasons)) + } } - // 2. Attempt to bucket user into rollout using the feature flag. - // Check if the feature flag has rollout and the user is bucketed into one of it's rules - decisionResponse = getVariationForFeatureRollout(config: config, - featureFlag: featureFlag, - user: user, - options: options) - reasons.merge(decisionResponse.reasons) - if let decision = decisionResponse.result { - return DecisionResponse(result: decision, reasons: reasons) + // save profile + if !ignoreUPS { + profileTracker?.save() } - return DecisionResponse(result: nil, reasons: reasons) + return decisions } + func getVariationForFeatureExperiment(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, + userProfileTracker: UserProfileTracker? = nil, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) @@ -244,6 +296,7 @@ class DefaultDecisionService: OPTDecisionService { flagKey: featureFlag.key, rule: experiment, user: user, + userProfileTracker: userProfileTracker, options: options) reasons.merge(decisionResponse.reasons) if let variation = decisionResponse.result { @@ -314,11 +367,10 @@ class DefaultDecisionService: OPTDecisionService { flagKey: String, rule: Experiment, user: OptimizelyUserContext, + userProfileTracker: UserProfileTracker?, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) - // check forced-decision first - let forcedDecisionResponse = findValidatedForcedDecision(config: config, user: user, context: OptimizelyDecisionContext(flagKey: flagKey, ruleKey: rule.key)) @@ -328,18 +380,16 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: variation, reasons: reasons) } - // regular decision - let decisionResponse = getVariation(config: config, experiment: rule, user: user, - options: options) - reasons.merge(decisionResponse.reasons) + userProfileTracker: userProfileTracker) let variation = decisionResponse.result - + reasons.merge(decisionResponse.reasons) return DecisionResponse(result: variation, reasons: reasons) } + func getVariationFromDeliveryRule(config: ProjectConfig, flagKey: String, rules: [Experiment], @@ -424,6 +474,7 @@ class DefaultDecisionService: OPTDecisionService { return bucketingId } + /// Public Method func findValidatedForcedDecision(config: ProjectConfig, user: OptimizelyUserContext, context: OptimizelyDecisionContext) -> DecisionResponse { @@ -452,7 +503,6 @@ class DefaultDecisionService: OPTDecisionService { // MARK: - UserProfileService Helpers extension DefaultDecisionService { - func getVariationIdFromProfile(userId: String, experimentId: String) -> String? { if let profile = userProfileService.lookup(userId: userId), @@ -465,6 +515,18 @@ extension DefaultDecisionService { } } + func getVariationIdFromProfile(profile: UserProfile?, + experimentId: String) -> String? { + if let _profile = profile, + let bucketMap = _profile[UserProfileKeys.kBucketMap] as? OPTUserProfileService.UPBucketMap, + let experimentMap = bucketMap[experimentId], + let variationId = experimentMap[UserProfileKeys.kVariationId] { + return variationId + } else { + return nil + } + } + func saveProfile(userId: String, experimentId: String, variationId: String) { @@ -482,5 +544,4 @@ extension DefaultDecisionService { self.logger.i(.savedVariationInUserProfile(variationId, experimentId, userId)) } } - } diff --git a/Sources/Implementation/UserProfileTracker.swift b/Sources/Implementation/UserProfileTracker.swift new file mode 100644 index 00000000..e632418f --- /dev/null +++ b/Sources/Implementation/UserProfileTracker.swift @@ -0,0 +1,67 @@ +// +// Copyright 2024, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +class UserProfileTracker { + var userId: String + var profileUpdated: Bool = false + var userProfileService: OPTUserProfileService + var userProfile: UserProfile? + var logger: OPTLogger + + // user-profile-service read-modify-write lock for supporting multiple clients + static let upsRMWLock = DispatchQueue(label: "ups-rmw") + + init(userId: String, userProfileService: OPTUserProfileService, logger: OPTLogger) { + self.userId = userId + self.userProfileService = userProfileService + self.logger = logger + } + + func loadUserProfile() { + userProfile = userProfileService.lookup(userId: userId) ?? [String: Any]() + } + + func updateProfile(experiment: Experiment, variation: Variation) { + let experimentId = experiment.id + let variationId = variation.id + var bucketMap = userProfile?[UserProfileKeys.kBucketMap] as? OPTUserProfileService.UPBucketMap ?? OPTUserProfileService.UPBucketMap() + bucketMap[experimentId] = [UserProfileKeys.kVariationId: variationId] + userProfile?[UserProfileKeys.kBucketMap] = bucketMap + userProfile?[UserProfileKeys.kUserId] = userId + profileUpdated = true + logger.i("Update variation of experiment \(experimentId) for user \(userId)") + } + + func save() { + UserProfileTracker.upsRMWLock.sync { + guard profileUpdated else { + logger.w("Profile not updated for \(userId)") + return + } + + guard let profile = userProfile else { + logger.e("Failed to save user profile for \(userId)") + return + } + + userProfileService.save(userProfile: profile) + logger.i("Saved user profile for \(userId)") + } + + } +} diff --git a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift index da7e3c04..be4732a6 100644 --- a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift +++ b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift @@ -66,53 +66,131 @@ extension OptimizelyClient { return OptimizelyDecision.errorDecision(key: key, user: user, error: .sdkNotReady) } - guard let feature = config.getFeatureFlag(key: key) else { + guard let _ = config.getFeatureFlag(key: key) else { return OptimizelyDecision.errorDecision(key: key, user: user, error: .featureKeyInvalid(key)) } + + var allOptions = defaultDecideOptions + (options ?? []) + allOptions.removeAll(where: { $0 == .enabledFlagsOnly }) - let userId = user.userId - let attributes = user.attributes - let allOptions = defaultDecideOptions + (options ?? []) - let reasons = DecisionReasons(options: allOptions) - var decisionEventDispatched = false - var enabled = false + let decisionMap = decide(user: user, keys: [key], options: allOptions, ignoreDefaultOptions: true) + return decisionMap[key] ?? OptimizelyDecision.errorDecision(key: key, user: user, error: .generic) + } + + func decide(user: OptimizelyUserContext, + keys: [String], + options: [OptimizelyDecideOption]? = nil) -> [String: OptimizelyDecision] { + return decide(user: user, keys: keys, options: options, ignoreDefaultOptions: false) + } + + func decide(user: OptimizelyUserContext, + keys: [String], + options: [OptimizelyDecideOption]? = nil, + ignoreDefaultOptions: Bool) -> [String: OptimizelyDecision] { + guard let config = self.config else { + logger.e(OptimizelyError.sdkNotReady) + return [:] + } - var decision: FeatureDecision? + var decisionMap = [String : OptimizelyDecision]() - // check forced-decisions first + guard keys.count > 0 else { return decisionMap } - let forcedDecisionResponse = decisionService.findValidatedForcedDecision(config: config, - user: user, - context: OptimizelyDecisionContext(flagKey: key)) - reasons.merge(forcedDecisionResponse.reasons) + var validKeys = [String]() + var flagsWithoutForceDecision = [FeatureFlag]() + var flagDecisions = [String : FeatureDecision]() + var decisionReasonMap = [String : DecisionReasons]() - if let variation = forcedDecisionResponse.result { - decision = FeatureDecision(experiment: nil, variation: variation, source: Constants.DecisionSource.featureTest.rawValue) - } else { - // regular decision - - let decisionResponse = decisionService.getVariationForFeature(config: config, - featureFlag: feature, - user: user, - options: allOptions) - reasons.merge(decisionResponse.reasons) - decision = decisionResponse.result - } - - if let featureEnabled = decision?.variation.featureEnabled { - enabled = featureEnabled + let allOptions = ignoreDefaultOptions ? (options ?? []) : defaultDecideOptions + (options ?? []) + + for key in keys { + guard let flags = config.getFeatureFlag(key: key) else { + decisionMap[key] = OptimizelyDecision.errorDecision(key: key, user: user, error: .featureKeyInvalid(key)) + continue + } + + validKeys.append(key) + + // check forced-decisions first + let forcedDecisionResponse = decisionService.findValidatedForcedDecision(config: config, + user: user, + context: OptimizelyDecisionContext(flagKey: key)) + + let decisionReasons = DecisionReasons(options: allOptions) + decisionReasons.merge(forcedDecisionResponse.reasons) + decisionReasonMap[key] = decisionReasons + + if let variation = forcedDecisionResponse.result { + let featureDecision = FeatureDecision(experiment: nil, variation: variation, source: Constants.DecisionSource.featureTest.rawValue) + flagDecisions[key] = featureDecision + } else { + flagsWithoutForceDecision.append(flags) + } + } + + let decisionList = (decisionService as? DefaultDecisionService)?.getVariationForFeatureList(config: config, featureFlags: flagsWithoutForceDecision, user: user, options: allOptions) + + for index in 0.. OptimizelyDecision { + + guard let feature = config.getFeatureFlag(key: flagKey) else { + return OptimizelyDecision.errorDecision(key: flagKey, user: user, error: .featureKeyInvalid(flagKey)) + } + + let userId = user.userId + let attributes = user.attributes + let flagEnabled = flagDecision?.variation.featureEnabled ?? false + + logger.i("Feature \(flagKey) is enabled for user \(userId) \(flagEnabled)") + + var decisionEventDispatched = false + if !allOptions.contains(.disableDecisionEvent) { - let ruleType = decision?.source ?? Constants.DecisionSource.rollout.rawValue - if shouldSendDecisionEvent(source: ruleType, decision: decision) { - sendImpressionEvent(experiment: decision?.experiment, - variation: decision?.variation, + let ruleType = flagDecision?.source ?? Constants.DecisionSource.rollout.rawValue + if shouldSendDecisionEvent(source: ruleType, decision: flagDecision) { + sendImpressionEvent(experiment: flagDecision?.experiment, + variation: flagDecision?.variation, userId: userId, attributes: attributes, flagKey: feature.key, ruleType: ruleType, - enabled: enabled) + enabled: flagEnabled) decisionEventDispatched = true } } @@ -120,9 +198,9 @@ extension OptimizelyClient { var variableMap = [String: Any]() if !allOptions.contains(.excludeVariables) { let decisionResponse = getDecisionVariableMap(feature: feature, - variation: decision?.variation, - enabled: enabled) - reasons.merge(decisionResponse.reasons) + variation: flagDecision?.variation, + enabled: flagEnabled) + decisionReasons.merge(decisionResponse.reasons) variableMap = decisionResponse.result ?? [:] } @@ -130,27 +208,27 @@ extension OptimizelyClient { if let opt = OptimizelyJSON(map: variableMap) { optimizelyJSON = opt } else { - reasons.addError(OptimizelyError.invalidJSONVariable) + decisionReasons.addError(OptimizelyError.invalidJSONVariable) optimizelyJSON = OptimizelyJSON.createEmpty() } - let ruleKey = decision?.experiment?.key - let reasonsToReport = reasons.toReport() + let ruleKey = flagDecision?.experiment?.key + let reasonsToReport = decisionReasons.toReport() sendDecisionNotification(userId: userId, attributes: attributes, decisionInfo: DecisionInfo(decisionType: .flag, - experiment: decision?.experiment, - variation: decision?.variation, + experiment: flagDecision?.experiment, + variation: flagDecision?.variation, feature: feature, - featureEnabled: enabled, + featureEnabled: flagEnabled, variableValues: variableMap, ruleKey: ruleKey, reasons: reasonsToReport, decisionEventDispatched: decisionEventDispatched)) - return OptimizelyDecision(variationKey: decision?.variation.key, - enabled: enabled, + return OptimizelyDecision(variationKey: flagDecision?.variation.key, + enabled: flagEnabled, variables: optimizelyJSON, ruleKey: ruleKey, flagKey: feature.key, @@ -158,31 +236,6 @@ extension OptimizelyClient { reasons: reasonsToReport) } - func decide(user: OptimizelyUserContext, - keys: [String], - options: [OptimizelyDecideOption]? = nil) -> [String: OptimizelyDecision] { - guard config != nil else { - logger.e(OptimizelyError.sdkNotReady) - return [:] - } - - guard keys.count > 0 else { return [:] } - - let allOptions = defaultDecideOptions + (options ?? []) - - var decisions = [String: OptimizelyDecision]() - - let enabledFlagsOnly = allOptions.contains(.enabledFlagsOnly) - keys.forEach { key in - let decision = decide(user: user, key: key, options: options) - if !enabledFlagsOnly || decision.enabled { - decisions[key] = decision - } - } - - return decisions - } - func decideAll(user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil) -> [String: OptimizelyDecision] { guard let config = self.config else { diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift index d5871a5a..3837ff15 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift @@ -1262,6 +1262,16 @@ class FakeDecisionService: DefaultDecisionService { let featureDecision = FeatureDecision(experiment: experiment, variation: tmpVariation, source: source) return DecisionResponse.responseNoReasons(result: featureDecision) } + + override func getVariationForFeatureExperiment(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + guard let experiment = self.experiment, let tmpVariation = self.variation else { + return DecisionResponse.nilNoReasons() + } + + let featureDecision = FeatureDecision(experiment: experiment, variation: tmpVariation, source: source) + return DecisionResponse.responseNoReasons(result: featureDecision) + } + } fileprivate extension HandlerRegistryService { diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift index 2f243b02..4101578d 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift @@ -289,6 +289,61 @@ extension DecisionServiceTests_Features { } +// MARK: - Test getVariationForFeatureList() + +extension DecisionServiceTests_Features { + func testGetVariationForFeatureListBatchUPSLoadAndSave() { + let mockProfileService = MocProfileService() + + let ups_service = DefaultDecisionService(userProfileService: mockProfileService) + + let flag1: FeatureFlag = try! OTUtils.model( + from:[ + "id": "553339214", + "key": "house", + "experimentIds": [kExperimentId], + "rolloutId": "", + "variables": [] + ] + ) + + let flag2: FeatureFlag = try! OTUtils.model( + from:[ + "id": "553339215", + "key": "house", + "experimentIds": [kExperimentId], + "rolloutId": "", + "variables": [] + ] + ) + + let flag3: FeatureFlag = try! OTUtils.model( + from:[ + "id": "553339216", + "key": "house", + "experimentIds": [kExperimentId], + "rolloutId": "", + "variables": [] + ] + ) + + let pair = ups_service.getVariationForFeatureList( + config: config, + featureFlags: [flag1, flag2, flag3], + user: optimizely.createUserContext(userId: kUserId, + attributes: kAttributesCountryMatch) + ) + + + XCTAssertEqual(mockProfileService.lookupCount, 1) + XCTAssertEqual(mockProfileService.saveCount, 1) + XCTAssertEqual(pair.count, 3) + XCTAssert(pair[0].result?.experiment?.key == kExperimentKey) + XCTAssert(pair[0].result?.variation.key == kVariationKeyD) + XCTAssert(pair[0].result?.source == Constants.DecisionSource.featureTest.rawValue) + } +} + // MARK: - Test getVariationForFeatureRollout() extension DecisionServiceTests_Features { @@ -466,3 +521,19 @@ extension DecisionServiceTests_Features { } } + +class MocProfileService: DefaultUserProfileService { + var lookupCount = 0 + var saveCount = 0 + + override func lookup(userId: String) -> DefaultUserProfileService.UPProfile? { + lookupCount += 1 + return super.lookup(userId: userId) + } + + override func save(userProfile: DefaultUserProfileService.UPProfile) { + super.save(userProfile: userProfile) + saveCount += 1 + } + +} From fc27e77982053ec8f1545ee0bcee2a4738a74ba0 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Fri, 8 Nov 2024 22:08:57 +0600 Subject: [PATCH 08/37] chore: prepare for release ups batch update 4.1.0 (#560) --- .github/workflows/swift.yml | 2 +- CHANGELOG.md | 6 ++++++ README.md | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index a56ea887..4ebc6cc8 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -17,7 +17,7 @@ on: description: release env: - VERSION: 4.0.0 + VERSION: 4.1.0 jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9387f9d4..be38dbc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Optimizely Swift SDK Changelog +## 4.1.0 +November 8, 2024 + +### New Features +* Batch UPS lookup and save calls in decideAll and decideForKeys methods ([#559](https://github.com/optimizely/swift-sdk/pull/559/)). + ## 4.0.0 Jan 18, 2024 diff --git a/README.md b/README.md index aa8cc378..1a271bc5 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ If you have a name conflict with other swift packages when you add the Optimizel #### CocoaPods 1. Add the following lines to the _Podfile_:
 ```use_frameworks!```
-```pod 'OptimizelySwiftSDK', '~> 4.0.0'```
+```pod 'OptimizelySwiftSDK', '~> 4.1.0'```
 
2. Run the following command:
``` pod install ```
@@ -47,7 +47,7 @@ If you have a name conflict with other swift packages when you add the Optimizel Further installation instructions for Cocoapods: https://guides.cocoapods.org/using/getting-started.html #### Carthage -1. Add the following lines to the _Cartfile_:
```github "optimizely/swift-sdk" ~> 4.0.0```
+1. Add the following lines to the _Cartfile_:
```github "optimizely/swift-sdk" ~> 4.1.0```
2. Run the following command:
```carthage update```
From 5da09addf391c2e95b9052b9fe888c55994f8c41 Mon Sep 17 00:00:00 2001 From: optibot Date: Fri, 8 Nov 2024 18:27:39 +0100 Subject: [PATCH 09/37] ci(git-action): auto release prep for 4.1.0 (#561) Co-authored-by: optibot --- OptimizelySwiftSDK.podspec | 2 +- Sources/Utils/SDKVersion.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/OptimizelySwiftSDK.podspec b/OptimizelySwiftSDK.podspec index b874877c..562d6275 100644 --- a/OptimizelySwiftSDK.podspec +++ b/OptimizelySwiftSDK.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "OptimizelySwiftSDK" s.module_name = "Optimizely" - s.version = "4.0.0" + s.version = "4.1.0" s.summary = "Optimizely experiment framework for iOS/tvOS/watchOS" s.homepage = "https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs" s.license = { :type => "Apache License, Version 2.0", :file => "LICENSE" } diff --git a/Sources/Utils/SDKVersion.swift b/Sources/Utils/SDKVersion.swift index 2c4f90a5..1b49adde 100644 --- a/Sources/Utils/SDKVersion.swift +++ b/Sources/Utils/SDKVersion.swift @@ -17,4 +17,4 @@ /// Do not edit this field. /// - It is auto updated (Scripts/updated_version.sh) to reflect the current version /// - Do not put underscores in the name (Swiftlint can modify unexpectedly) -let OPTIMIZELYSDKVERSION = "4.0.0" +let OPTIMIZELYSDKVERSION = "4.1.0" From 3635373306bc5a84aead5ac407f9e44bbe4cb59a Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Sat, 16 Nov 2024 08:20:04 +0600 Subject: [PATCH 10/37] Update unit_tests.yml (#562) --- .github/workflows/unit_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index dff3121d..6ef910e8 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -93,7 +93,7 @@ jobs: Scripts/prepare_simulator.sh Scripts/run_unit_tests.sh - name: Check on failures (Archive Test Results) - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: steps.unit_tests.outcome != 'success' with: name: build-logs-${{ matrix.device }}-${{ matrix.os }} From 62ae49b93d743d7674030e95ea489066f1b40351 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Wed, 20 Nov 2024 23:02:06 +0600 Subject: [PATCH 11/37] [FSSDK-10761] feat: make vuid as opt-in (#556) VUID optln added --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 78 ++++++++--------- Sources/ODP/OdpEventManager.swift | 9 +- Sources/ODP/OdpManager.swift | 32 +++---- Sources/ODP/OptimizelySdkSettings.swift | 5 ++ .../OptimizelyClient+Decide.swift | 6 +- .../OptimizelyUserContext.swift | 1 - Sources/Optimizely/OptimizelyClient.swift | 23 ++++- .../VuidManager.swift} | 42 +++++++--- .../OptimizelyClientTests_Decide.swift | 14 ++-- .../OptimizelyClientTests_ODP.swift | 13 ++- .../OdpEventManagerTests.swift | 8 +- .../OdpManagerTests.swift | 53 +++++++----- .../OdpVuidManagerTests.swift | 55 ------------ ...izelyUserContextTests_Decide_Reasons.swift | 83 +++++++++---------- .../OptimizelyUserContextTests_ODP_2.swift | 29 +++++-- .../VuidManagerTests.swift | 78 +++++++++++++++++ 16 files changed, 310 insertions(+), 219 deletions(-) rename Sources/{ODP/OdpVuidManager.swift => Optimizely/VuidManager.swift} (68%) delete mode 100644 Tests/OptimizelyTests-Common/OdpVuidManagerTests.swift create mode 100644 Tests/OptimizelyTests-Common/VuidManagerTests.swift diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index af0854c4..3df0de83 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -1884,8 +1884,8 @@ 84861812286D0B8900B7F41B /* OdpSegmentManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8486180D286D0B8900B7F41B /* OdpSegmentManagerTests.swift */; }; 84861813286D0B8900B7F41B /* OdpManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8486180E286D0B8900B7F41B /* OdpManagerTests.swift */; }; 84861814286D0B8900B7F41B /* OdpManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8486180E286D0B8900B7F41B /* OdpManagerTests.swift */; }; - 84861815286D0B8900B7F41B /* OdpVuidManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8486180F286D0B8900B7F41B /* OdpVuidManagerTests.swift */; }; - 84861816286D0B8900B7F41B /* OdpVuidManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8486180F286D0B8900B7F41B /* OdpVuidManagerTests.swift */; }; + 84861815286D0B8900B7F41B /* VuidManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8486180F286D0B8900B7F41B /* VuidManagerTests.swift */; }; + 84861816286D0B8900B7F41B /* VuidManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8486180F286D0B8900B7F41B /* VuidManagerTests.swift */; }; 84861817286D0B8900B7F41B /* OdpEventManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84861810286D0B8900B7F41B /* OdpEventManagerTests.swift */; }; 84861818286D0B8900B7F41B /* OdpEventManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84861810286D0B8900B7F41B /* OdpEventManagerTests.swift */; }; 8486181B286D188B00B7F41B /* OdpSegmentApiManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84861819286D188B00B7F41B /* OdpSegmentApiManagerTests.swift */; }; @@ -1910,22 +1910,22 @@ 84B4D75D27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B4D74F27E2A7550078CDA4 /* OptimizelySegmentOption.swift */; }; 84B4D75E27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B4D74F27E2A7550078CDA4 /* OptimizelySegmentOption.swift */; }; 84B4D75F27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B4D74F27E2A7550078CDA4 /* OptimizelySegmentOption.swift */; }; - 84E2E9422852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E9432852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E9442852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E9452852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E9462852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E9472852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E9482852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E9492852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E94A2852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E94B2852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E94C2852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E94D2852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E94E2852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E94F2852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E9502852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; - 84E2E9512852A378001114AB /* OdpVuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* OdpVuidManager.swift */; }; + 84E2E9422852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E9432852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E9442852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E9452852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E9462852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E9472852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E9482852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E9492852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E94A2852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E94B2852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E94C2852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E94D2852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E94E2852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E94F2852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E9502852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; + 84E2E9512852A378001114AB /* VuidManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E9412852A378001114AB /* VuidManager.swift */; }; 84E2E96128540B5E001114AB /* OptimizelySdkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E96028540B5E001114AB /* OptimizelySdkSettings.swift */; }; 84E2E96228540B5E001114AB /* OptimizelySdkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E96028540B5E001114AB /* OptimizelySdkSettings.swift */; }; 84E2E96328540B5E001114AB /* OptimizelySdkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E2E96028540B5E001114AB /* OptimizelySdkSettings.swift */; }; @@ -2424,13 +2424,13 @@ 848617FA286CF33700B7F41B /* OdpEvent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OdpEvent.swift; sourceTree = ""; }; 8486180D286D0B8900B7F41B /* OdpSegmentManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OdpSegmentManagerTests.swift; sourceTree = ""; }; 8486180E286D0B8900B7F41B /* OdpManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OdpManagerTests.swift; sourceTree = ""; }; - 8486180F286D0B8900B7F41B /* OdpVuidManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OdpVuidManagerTests.swift; sourceTree = ""; }; + 8486180F286D0B8900B7F41B /* VuidManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VuidManagerTests.swift; sourceTree = ""; }; 84861810286D0B8900B7F41B /* OdpEventManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OdpEventManagerTests.swift; sourceTree = ""; }; 84861819286D188B00B7F41B /* OdpSegmentApiManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OdpSegmentApiManagerTests.swift; sourceTree = ""; }; 8486181A286D188B00B7F41B /* OdpEventApiManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OdpEventApiManagerTests.swift; sourceTree = ""; }; 84958C5D280F22FE008655C7 /* OptimizelyUserContextTests_Performance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Performance.swift; sourceTree = ""; }; 84B4D74F27E2A7550078CDA4 /* OptimizelySegmentOption.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OptimizelySegmentOption.swift; sourceTree = ""; }; - 84E2E9412852A378001114AB /* OdpVuidManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OdpVuidManager.swift; sourceTree = ""; }; + 84E2E9412852A378001114AB /* VuidManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VuidManager.swift; sourceTree = ""; }; 84E2E96028540B5E001114AB /* OptimizelySdkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OptimizelySdkSettings.swift; path = ../ODP/OptimizelySdkSettings.swift; sourceTree = ""; }; 84E2E9712855875E001114AB /* OdpEventManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OdpEventManager.swift; sourceTree = ""; }; 84E7ABBA27D2A1F100447CAE /* ThreadSafeLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThreadSafeLogger.swift; sourceTree = ""; }; @@ -2652,7 +2652,6 @@ isa = PBXGroup; children = ( 6E6522DD278E4F3800954EA1 /* OdpManager.swift */, - 84E2E9412852A378001114AB /* OdpVuidManager.swift */, 848617C72863DC2700B7F41B /* OdpSegmentManager.swift */, 84E2E9712855875E001114AB /* OdpEventManager.swift */, 848617D82863E21200B7F41B /* OdpSegmentApiManager.swift */, @@ -2727,6 +2726,7 @@ 6EA2CC232345618E001E7531 /* OptimizelyConfig.swift */, 6ECB60C9234D5D9C00016D41 /* OptimizelyConfig+ObjC.swift */, C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */, + 84E2E9412852A378001114AB /* VuidManager.swift */, C78CAFA324486E0A009FE876 /* OptimizelyJSON+ObjC.swift */, ); path = Optimizely; @@ -2964,7 +2964,7 @@ 84861810286D0B8900B7F41B /* OdpEventManagerTests.swift */, 8486180E286D0B8900B7F41B /* OdpManagerTests.swift */, 8486180D286D0B8900B7F41B /* OdpSegmentManagerTests.swift */, - 8486180F286D0B8900B7F41B /* OdpVuidManagerTests.swift */, + 8486180F286D0B8900B7F41B /* VuidManagerTests.swift */, 84861819286D188B00B7F41B /* OdpSegmentApiManagerTests.swift */, 8486181A286D188B00B7F41B /* OdpEventApiManagerTests.swift */, 6E27EC9A266EF11000B4A6D4 /* OptimizelyDecisionTests.swift */, @@ -4197,7 +4197,7 @@ 6E14CDA92423F9C300010234 /* Utils.swift in Sources */, 6EF8DE1F24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E14CD882423F9A100010234 /* AttributeValue.swift in Sources */, - 84E2E9492852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E9492852A378001114AB /* VuidManager.swift in Sources */, 6E14CD822423F9A100010234 /* DataStoreFile.swift in Sources */, 6E14CDA42423F9C300010234 /* Notifications.swift in Sources */, 6E20050B26B4D28500278087 /* MockLogger.swift in Sources */, @@ -4270,7 +4270,7 @@ 6E424D0126324B620081004A /* SemanticVersion.swift in Sources */, 6E424D0226324B620081004A /* Audience.swift in Sources */, 6E424D0326324B620081004A /* AttributeValue.swift in Sources */, - 84E2E9482852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E9482852A378001114AB /* VuidManager.swift in Sources */, 6E424D0426324B620081004A /* ConditionLeaf.swift in Sources */, 6E424D0526324B620081004A /* ConditionHolder.swift in Sources */, 6E424D5226324C4D0081004A /* OptimizelyClient+Decide.swift in Sources */, @@ -4380,7 +4380,7 @@ 6E75190122C520D500B2B157 /* Attribute.swift in Sources */, 848617DB2863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, 6E7516B322C520D400B2B157 /* DefaultUserProfileService.swift in Sources */, - 84E2E9432852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E9432852A378001114AB /* VuidManager.swift in Sources */, 6E75183522C520D400B2B157 /* EventForDispatch.swift in Sources */, 6E7517D522C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E75187122C520D400B2B157 /* Variation.swift in Sources */, @@ -4504,7 +4504,7 @@ 6E7518F022C520D500B2B157 /* ConditionHolder.swift in Sources */, 6EF8DE2424BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E75183022C520D400B2B157 /* BatchEvent.swift in Sources */, - 84E2E94E2852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E94E2852A378001114AB /* VuidManager.swift in Sources */, 6E75192022C520D500B2B157 /* OPTNotificationCenter.swift in Sources */, 6E9B117922C5487A00C22D81 /* tvOSOnlyTests.swift in Sources */, 6E20051026B4D28500278087 /* MockLogger.swift in Sources */, @@ -4577,7 +4577,7 @@ 6ECB60D7234E601A00016D41 /* OptimizelyClientTests_OptimizelyConfig_Objc.m in Sources */, 6E75195822C520D500B2B157 /* OPTBucketer.swift in Sources */, 6E424C03263228FD0081004A /* AtomicDictionary.swift in Sources */, - 84E2E94A2852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E94A2852A378001114AB /* VuidManager.swift in Sources */, 6E994B3A25A3E6EA00999262 /* DecisionResponse.swift in Sources */, 6E75170A22C520D400B2B157 /* OptimizelyClient.swift in Sources */, 6E9B11AC22C5489300C22D81 /* OTUtils.swift in Sources */, @@ -4701,7 +4701,7 @@ 6E7518BF22C520D400B2B157 /* Variable.swift in Sources */, 6E75181722C520D400B2B157 /* DataStoreQueueStackImpl.swift in Sources */, 6E75185322C520D400B2B157 /* ProjectConfig.swift in Sources */, - 84E2E94D2852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E94D2852A378001114AB /* VuidManager.swift in Sources */, 6E75173D22C520D400B2B157 /* MurmurHash3.swift in Sources */, 6E7516E922C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E7518A722C520D400B2B157 /* FeatureFlag.swift in Sources */, @@ -4809,7 +4809,7 @@ 6E9B11B522C5489600C22D81 /* MockUrlSession.swift in Sources */, 6EF8DE1724BD1BB2008B9488 /* OptimizelyDecision.swift in Sources */, 8428D3D12807337400D0FB0C /* LruCacheTests.swift in Sources */, - 84861816286D0B8900B7F41B /* OdpVuidManagerTests.swift in Sources */, + 84861816286D0B8900B7F41B /* VuidManagerTests.swift in Sources */, 6E7517C522C520D400B2B157 /* DefaultDatafileHandler.swift in Sources */, 6E75190922C520D500B2B157 /* Attribute.swift in Sources */, 6E75177B22C520D400B2B157 /* SDKVersion.swift in Sources */, @@ -4855,7 +4855,7 @@ 6E9B117822C5487100C22D81 /* DataStoreTests.swift in Sources */, 6E75171B22C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 6E75195122C520D500B2B157 /* OPTDatafileHandler.swift in Sources */, - 84E2E94F2852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E94F2852A378001114AB /* VuidManager.swift in Sources */, 6E75176322C520D400B2B157 /* AtomicProperty.swift in Sources */, 6E9B117722C5487100C22D81 /* BatchEventBuilderTests_EventTags.swift in Sources */, 6E7517DD22C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, @@ -4936,7 +4936,7 @@ 84861809286CF33700B7F41B /* OdpEvent.swift in Sources */, 6E75171022C520D400B2B157 /* OptimizelyClient.swift in Sources */, 6E7516C822C520D400B2B157 /* DefaultEventDispatcher.swift in Sources */, - 84E2E9502852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E9502852A378001114AB /* VuidManager.swift in Sources */, 6E75194622C520D500B2B157 /* OPTDecisionService.swift in Sources */, 6E75185622C520D400B2B157 /* ProjectConfig.swift in Sources */, 6E20051226B4D28600278087 /* MockLogger.swift in Sources */, @@ -5104,7 +5104,7 @@ 6E7517D722C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E75181F22C520D400B2B157 /* BatchEventBuilder.swift in Sources */, 84F6BADD27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift in Sources */, - 84E2E9472852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E9472852A378001114AB /* VuidManager.swift in Sources */, 6E623F06253F9045000617D0 /* DecisionInfo.swift in Sources */, 6E424BE2263228E90081004A /* AtomicArray.swift in Sources */, 6E9B115822C5486E00C22D81 /* EventDispatcherTests.swift in Sources */, @@ -5205,7 +5205,7 @@ 84861804286CF33700B7F41B /* OdpEvent.swift in Sources */, 6E75170B22C520D400B2B157 /* OptimizelyClient.swift in Sources */, 6E7516C322C520D400B2B157 /* DefaultEventDispatcher.swift in Sources */, - 84E2E94B2852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E94B2852A378001114AB /* VuidManager.swift in Sources */, 6E75194122C520D500B2B157 /* OPTDecisionService.swift in Sources */, 6E75185122C520D400B2B157 /* ProjectConfig.swift in Sources */, 6E20050D26B4D28500278087 /* MockLogger.swift in Sources */, @@ -5338,7 +5338,7 @@ 984FE51F2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171822C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 6E75174822C520D400B2B157 /* HandlerRegistryService.swift in Sources */, - 84E2E94C2852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E94C2852A378001114AB /* VuidManager.swift in Sources */, 6E7518FA22C520D500B2B157 /* UserAttribute.swift in Sources */, 6E7516E822C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E75191222C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, @@ -5440,7 +5440,7 @@ 984FE51D2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171D22C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 6E75174D22C520D400B2B157 /* HandlerRegistryService.swift in Sources */, - 84E2E9512852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E9512852A378001114AB /* VuidManager.swift in Sources */, 6E7518FF22C520D500B2B157 /* UserAttribute.swift in Sources */, 6E7516ED22C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E75191722C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, @@ -5540,7 +5540,7 @@ 6E75195422C520D500B2B157 /* OPTBucketer.swift in Sources */, 848617DA2863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, 6E75171E22C520D400B2B157 /* OptimizelyResult.swift in Sources */, - 84E2E9422852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E9422852A378001114AB /* VuidManager.swift in Sources */, 6E75172A22C520D400B2B157 /* Constants.swift in Sources */, 6E7516A622C520D400B2B157 /* DefaultLogger.swift in Sources */, 6E75189422C520D400B2B157 /* Experiment.swift in Sources */, @@ -5664,7 +5664,7 @@ 6E7518DE22C520D400B2B157 /* ConditionLeaf.swift in Sources */, 6EF8DE1D24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E7518EA22C520D400B2B157 /* ConditionHolder.swift in Sources */, - 84E2E9462852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E9462852A378001114AB /* VuidManager.swift in Sources */, 6E75182A22C520D400B2B157 /* BatchEvent.swift in Sources */, 6E75191A22C520D500B2B157 /* OPTNotificationCenter.swift in Sources */, 6E20050826B4D28500278087 /* MockLogger.swift in Sources */, @@ -5749,7 +5749,7 @@ 75C71A2125E454460084187E /* EventForDispatch.swift in Sources */, 75C71A2225E454460084187E /* SemanticVersion.swift in Sources */, 75C71A2325E454460084187E /* Audience.swift in Sources */, - 84E2E9452852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E9452852A378001114AB /* VuidManager.swift in Sources */, 6E6522E1278E4F3800954EA1 /* OdpManager.swift in Sources */, 75C71A2425E454460084187E /* AttributeValue.swift in Sources */, 75C71A2525E454460084187E /* ConditionLeaf.swift in Sources */, @@ -5832,7 +5832,7 @@ BD6485502491474500F30986 /* OPTBucketer.swift in Sources */, 848617DC2863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, BD6485512491474500F30986 /* OptimizelyResult.swift in Sources */, - 84E2E9442852A378001114AB /* OdpVuidManager.swift in Sources */, + 84E2E9442852A378001114AB /* VuidManager.swift in Sources */, BD6485522491474500F30986 /* Constants.swift in Sources */, BD6485532491474500F30986 /* DefaultLogger.swift in Sources */, BD6485542491474500F30986 /* Experiment.swift in Sources */, diff --git a/Sources/ODP/OdpEventManager.swift b/Sources/ODP/OdpEventManager.swift index 9ddec34a..9f22323d 100644 --- a/Sources/ODP/OdpEventManager.swift +++ b/Sources/ODP/OdpEventManager.swift @@ -49,7 +49,7 @@ open class OdpEventManager { // MARK: - events - func registerVUID(vuid: String) { + func sendInitializedEvent(vuid: String) { sendEvent(type: Constants.ODP.eventType, action: "client_initialized", identifiers: [ @@ -58,8 +58,11 @@ open class OdpEventManager { data: [:]) } - func identifyUser(vuid: String, userId: String?) { - var identifiers = [Constants.ODP.keyForVuid: vuid] + func identifyUser(vuid: String?, userId: String?) { + var identifiers = [String: String]() + if let _vuid = vuid { + identifiers[Constants.ODP.keyForVuid] = _vuid + } if let userId = userId { identifiers[Constants.ODP.keyForUserId] = userId } diff --git a/Sources/ODP/OdpManager.swift b/Sources/ODP/OdpManager.swift index 8553fdc1..40ba6ad3 100644 --- a/Sources/ODP/OdpManager.swift +++ b/Sources/ODP/OdpManager.swift @@ -18,17 +18,13 @@ import Foundation public class OdpManager { var enabled: Bool - var vuidManager: OdpVuidManager - var odpConfig: OdpConfig! var segmentManager: OdpSegmentManager! var eventManager: OdpEventManager! let logger = OPTLoggerFactory.getLogger() - var vuid: String { - return vuidManager.vuid - } + var vuid: String? /// OdpManager init /// - Parameters: @@ -42,6 +38,7 @@ public class OdpManager { /// - eventManager: ODPEventManager public init(sdkKey: String, disable: Bool, + vuid: String? = nil, cacheSize: Int, cacheTimeoutInSecs: Int, timeoutForSegmentFetchInSecs: Int? = nil, @@ -50,8 +47,7 @@ public class OdpManager { eventManager: OdpEventManager? = nil) { self.enabled = !disable - self.vuidManager = OdpVuidManager.shared - + self.vuid = vuid guard enabled else { logger.i(.odpNotEnabled) return @@ -65,8 +61,6 @@ public class OdpManager { self.odpConfig = OdpConfig() self.segmentManager.odpConfig = odpConfig self.eventManager.odpConfig = odpConfig - - self.eventManager.registerVUID(vuid: self.vuidManager.vuid) } func fetchQualifiedSegments(userId: String, @@ -77,7 +71,7 @@ public class OdpManager { return } - let userKey = OdpVuidManager.isVuid(userId) ? Constants.ODP.keyForVuid : Constants.ODP.keyForUserId + let userKey = VuidManager.isVuid(userId) ? Constants.ODP.keyForVuid : Constants.ODP.keyForUserId let userValue = userId segmentManager.fetchQualifiedSegments(userKey: userKey, @@ -97,15 +91,13 @@ public class OdpManager { return } - var vuid = vuidManager.vuid - var fsUserId: String? = userId - if OdpVuidManager.isVuid(userId) { + if VuidManager.isVuid(userId) { // overwrite if userId is vuid (when userContext is created with vuid) - vuid = userId - fsUserId = nil + eventManager.identifyUser(vuid: userId, userId: nil) + } else { + eventManager.identifyUser(vuid: self.vuid, userId: userId) } - eventManager.identifyUser(vuid: vuid, userId: fsUserId) } /// Send an event to the ODP server. @@ -125,15 +117,13 @@ public class OdpManager { let typeUpdated = (type ?? "").isEmpty ? Constants.ODP.eventType : type! - // add vuid to all events by default - var identifiersUpdated = identifiers - if identifiers[Constants.ODP.keyForVuid] == nil { - identifiersUpdated[Constants.ODP.keyForVuid] = vuidManager.vuid + + if identifiers[Constants.ODP.keyForVuid] == nil, let _vuid = vuid { + identifiersUpdated[Constants.ODP.keyForVuid] = _vuid } // replace aliases (fs-user-id, FS_USER_ID, FS-USER-ID) with "fs_user_id". - for (idKey, idValue) in identifiersUpdated { if idKey == Constants.ODP.keyForUserId { break } diff --git a/Sources/ODP/OptimizelySdkSettings.swift b/Sources/ODP/OptimizelySdkSettings.swift index 6a1d7122..fc3d4713 100644 --- a/Sources/ODP/OptimizelySdkSettings.swift +++ b/Sources/ODP/OptimizelySdkSettings.swift @@ -27,6 +27,8 @@ public struct OptimizelySdkSettings { let timeoutForOdpEventInSecs: Int /// ODP features are disabled if this is set to true. let disableOdp: Bool + /// VUID is enabled if this is set to true. + let enableVuid: Bool /// Optimizely SDK Settings /// @@ -36,6 +38,7 @@ public struct OptimizelySdkSettings { /// - timeoutForSegmentFetchInSecs: The timeout in seconds of odp segment fetch (optional. default = 10) - OS default timeout will be used if this is set to zero. /// - timeoutForOdpEventInSecs: The timeout in seconds of odp event dispatch (optional. default = 10) - OS default timeout will be used if this is set to zero. /// - disableOdp: Set this flag to true (default = false) to disable ODP features + /// - enableVuid: Set this flag to true (default = false) to enable vuid. /// - sdkName: Set this flag to override sdkName included in events /// - sdkVersion: Set this flag to override sdkVersion included in events public init(segmentsCacheSize: Int = 100, @@ -43,6 +46,7 @@ public struct OptimizelySdkSettings { timeoutForSegmentFetchInSecs: Int = 10, timeoutForOdpEventInSecs: Int = 10, disableOdp: Bool = false, + enableVuid: Bool = false, sdkName: String? = nil, sdkVersion: String? = nil) { self.segmentsCacheSize = segmentsCacheSize @@ -50,6 +54,7 @@ public struct OptimizelySdkSettings { self.timeoutForSegmentFetchInSecs = timeoutForSegmentFetchInSecs self.timeoutForOdpEventInSecs = timeoutForOdpEventInSecs self.disableOdp = disableOdp + self.enableVuid = enableVuid if let _sdkName = sdkName, _sdkName != "" { Utils.swiftSdkClientName = _sdkName } diff --git a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift index be4732a6..c49d8e40 100644 --- a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift +++ b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift @@ -35,7 +35,11 @@ extension OptimizelyClient { /// /// - Parameter attributes: A map of attribute names to current user attribute values. /// - Returns: An OptimizelyUserContext associated with this OptimizelyClient - public func createUserContext(attributes: [String: Any]? = nil) -> OptimizelyUserContext { + public func createUserContext(attributes: [String: Any]? = nil) -> OptimizelyUserContext? { + guard enableVuid, let vuid = self.vuid else { + logger.e("Vuid is not enabled or invalid VUID. User context not created.") + return nil + } return OptimizelyUserContext(optimizely: self, userId: vuid, attributes: attributes) } diff --git a/Sources/Optimizely+Decide/OptimizelyUserContext.swift b/Sources/Optimizely+Decide/OptimizelyUserContext.swift index 7f7cf48e..e21c16d6 100644 --- a/Sources/Optimizely+Decide/OptimizelyUserContext.swift +++ b/Sources/Optimizely+Decide/OptimizelyUserContext.swift @@ -84,7 +84,6 @@ public class OptimizelyUserContext { self.atomicAttributes = AtomicProperty(property: attributes, lock: lock) self.atomicForcedDecisions = AtomicProperty(property: nil, lock: lock) self.atomicQualifiedSegments = AtomicProperty(property: nil, lock: lock) - if identify { // async call so event building overhead is not blocking context creation lock.async { diff --git a/Sources/Optimizely/OptimizelyClient.swift b/Sources/Optimizely/OptimizelyClient.swift index 665bc0af..49953611 100644 --- a/Sources/Optimizely/OptimizelyClient.swift +++ b/Sources/Optimizely/OptimizelyClient.swift @@ -60,6 +60,7 @@ open class OptimizelyClient: NSObject { var decisionService: OPTDecisionService! public var notificationCenter: OPTNotificationCenter? public var odpManager: OdpManager! + private var vuidManager: VuidManager! let sdkSettings: OptimizelySdkSettings // MARK: - Public interfaces @@ -91,13 +92,15 @@ open class OptimizelyClient: NSObject { self.defaultDecideOptions = defaultDecideOptions ?? [] super.init() - + self.vuidManager = VuidManager.shared + self.vuidManager.configure(enable: self.sdkSettings.enableVuid) self.odpManager = odpManager ?? OdpManager(sdkKey: sdkKey, disable: sdkSettings.disableOdp, cacheSize: sdkSettings.segmentsCacheSize, cacheTimeoutInSecs: sdkSettings.segmentsCacheTimeoutInSecs, timeoutForSegmentFetchInSecs: sdkSettings.timeoutForSegmentFetchInSecs, timeoutForEventDispatchInSecs: sdkSettings.timeoutForOdpEventInSecs) + self.odpManager.vuid = vuidManager.vuid let userProfileService = userProfileService ?? DefaultUserProfileService() let logger = logger ?? DefaultLogger() type(of: logger).logLevel = defaultLogLevel ?? .info @@ -115,6 +118,16 @@ open class OptimizelyClient: NSObject { self.decisionService = HandlerRegistryService.shared.injectDecisionService(sdkKey: self.sdkKey) self.notificationCenter = HandlerRegistryService.shared.injectNotificationCenter(sdkKey: self.sdkKey) + if let _vuid = self.vuidManager.vuid { + try? sendOdpEvent(type: Constants.ODP.eventType, + action: "client_initialized", + identifiers: [ + Constants.ODP.keyForVuid: _vuid + ], + data: [:]) + } + + logger.d("SDK Version: \(version)") } @@ -971,8 +984,12 @@ extension OptimizelyClient { } /// the device vuid (read only) - public var vuid: String { - return odpManager.vuid + public var vuid: String? { + return self.vuidManager.vuid + } + + public var enableVuid: Bool { + return self.vuidManager.enable } func identifyUserToOdp(userId: String) { diff --git a/Sources/ODP/OdpVuidManager.swift b/Sources/Optimizely/VuidManager.swift similarity index 68% rename from Sources/ODP/OdpVuidManager.swift rename to Sources/Optimizely/VuidManager.swift index f1112148..6ffcb45a 100644 --- a/Sources/ODP/OdpVuidManager.swift +++ b/Sources/Optimizely/VuidManager.swift @@ -16,15 +16,22 @@ import Foundation -class OdpVuidManager { - var vuid: String = "" +public class VuidManager { + private var _vuid: String = "" + private(set) var enable: Bool = false let logger = OPTLoggerFactory.getLogger() - + // a single vuid should be shared for all SDK instances - static let shared = OdpVuidManager() + public static let shared = VuidManager() - init() { - self.vuid = load() + public func configure(enable: Bool) { + self.enable = enable + if enable { + self._vuid = load() + } else { + self.remove() + self._vuid = "" + } } static var newVuid: String { @@ -35,16 +42,24 @@ class OdpVuidManager { let vuid = (vuidFull.count <= maxLength) ? vuidFull : String(vuidFull.prefix(maxLength)) return vuid } - + static func isVuid(_ visitorId: String) -> Bool { - return visitorId.starts(with: "vuid_") + return visitorId.lowercased().starts(with: "vuid_") } } // MARK: - VUID Store -extension OdpVuidManager { +extension VuidManager { + public var vuid: String? { + if self.enable { + return _vuid + } else { + logger.w("VUID is not enabled.") + return nil + } + } private var keyForVuid: String { return "optimizely-vuid" @@ -55,11 +70,16 @@ extension OdpVuidManager { return oldVuid } - let vuid = OdpVuidManager.newVuid + let vuid = VuidManager.newVuid save(vuid: vuid) return vuid } - + + private func remove() { + UserDefaults.standard.set(nil, forKey: keyForVuid) + UserDefaults.standard.synchronize() + } + private func save(vuid: String) { UserDefaults.standard.set(vuid, forKey: keyForVuid) UserDefaults.standard.synchronize() diff --git a/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Decide.swift b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Decide.swift index c67ba7b7..a5628f1d 100644 --- a/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Decide.swift +++ b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Decide.swift @@ -24,7 +24,9 @@ class OptimizelyClientTests_Decide: XCTestCase { super.setUp() let datafile = OTUtils.loadJSONDatafile("api_datafile")! - optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey) + let settings = OptimizelySdkSettings(enableVuid: true) + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, settings: settings) + try! optimizely.start(datafile: datafile) } @@ -54,11 +56,11 @@ class OptimizelyClientTests_Decide: XCTestCase { let user = optimizely.createUserContext(attributes: attributes) - XCTAssert(user.optimizely == optimizely) - XCTAssert(user.userId == optimizely.vuid, "vuid should be used as the default userId when not given") - XCTAssert(user.attributes["country"] as! String == "us") - XCTAssert(user.attributes["age"] as! Int == 100) - XCTAssert(user.attributes["old"] as! Bool == true) + XCTAssert(user?.optimizely == optimizely) + XCTAssert(user?.userId == optimizely.vuid, "vuid should be used as the default userId when not given") + XCTAssert(user?.attributes["country"] as! String == "us") + XCTAssert(user?.attributes["age"] as! Int == 100) + XCTAssert(user?.attributes["old"] as! Bool == true) } func testCreateUserContext_multiple() { diff --git a/Tests/OptimizelyTests-APIs/OptimizelyClientTests_ODP.swift b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_ODP.swift index 9151bc0d..22600e79 100644 --- a/Tests/OptimizelyTests-APIs/OptimizelyClientTests_ODP.swift +++ b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_ODP.swift @@ -187,10 +187,19 @@ class OptimizelyClientTests_ODP: XCTestCase { // MARK: - vuid - func testVuid() { - XCTAssert(optimizely.vuid.starts(with: "vuid_")) + func testVuidEnabled() { + let settings = OptimizelySdkSettings(enableVuid: true) + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, settings: settings) + XCTAssertTrue(optimizely.enableVuid) + XCTAssert(optimizely.vuid!.starts(with: "vuid_")) } + func testVuidDiabled() { + // Default client vuid diabled + XCTAssertFalse(optimizely.enableVuid) + XCTAssertNil(optimizely.vuid) + } + // MARK: - OdpConfig Update func testUpdateOpdConfigCalled_wheneverProjectConfigUpdated_initialOrPolling() { diff --git a/Tests/OptimizelyTests-Common/OdpEventManagerTests.swift b/Tests/OptimizelyTests-Common/OdpEventManagerTests.swift index c6d16a87..d06562b8 100644 --- a/Tests/OptimizelyTests-Common/OdpEventManagerTests.swift +++ b/Tests/OptimizelyTests-Common/OdpEventManagerTests.swift @@ -89,7 +89,7 @@ class OdpEventManagerTests: XCTestCase { } func testRegisterVUID_noApiKey() { - manager.registerVUID(vuid: "v1") + manager.sendInitializedEvent(vuid: "v1") XCTAssertEqual(1, manager.eventQueue.count) @@ -149,7 +149,7 @@ class OdpEventManagerTests: XCTestCase { XCTAssertTrue(manager.odpConfig.eventQueueingAllowed, "initially datafile not ready and assumed queueing is allowed") - manager.registerVUID(vuid: "v1") // each of these will try to flush + manager.sendInitializedEvent(vuid: "v1") // each of these will try to flush manager.identifyUser(vuid: "v1", userId: "u1") manager.sendEvent(type: "t1", action: "a1", identifiers: [:], data: [:]) @@ -184,7 +184,7 @@ class OdpEventManagerTests: XCTestCase { XCTAssertTrue(manager.odpConfig.eventQueueingAllowed, "initially datafile not ready and assumed queueing is allowed") - manager.registerVUID(vuid: "v1") // each of these will try to flush + manager.sendInitializedEvent(vuid: "v1") // each of these will try to flush manager.identifyUser(vuid: "v1", userId: "u1") manager.sendEvent(type: "t1", action: "a1", identifiers: [:], data: [:]) @@ -215,7 +215,7 @@ class OdpEventManagerTests: XCTestCase { func testFlush_maxSize() { manager.maxQueueSize = 2 - manager.registerVUID(vuid: "v1") // each of these will try to flush + manager.sendInitializedEvent(vuid: "v1") // each of these will try to flush manager.identifyUser(vuid: "v1", userId: "u1") manager.sendEvent(type: "t1", action: "a1", identifiers: [:], data: [:]) diff --git a/Tests/OptimizelyTests-Common/OdpManagerTests.swift b/Tests/OptimizelyTests-Common/OdpManagerTests.swift index 0d8d8db2..f8989a01 100644 --- a/Tests/OptimizelyTests-Common/OdpManagerTests.swift +++ b/Tests/OptimizelyTests-Common/OdpManagerTests.swift @@ -23,7 +23,6 @@ class OdpManagerTests: XCTestCase { var segmentManager: MockOdpSegmentManager! var eventManager: MockOdpEventManager! var manager: OdpManager! - override func setUp() { OTUtils.clearAllEventQueues() segmentManager = MockOdpSegmentManager(cacheSize: cacheSize, @@ -46,6 +45,7 @@ class OdpManagerTests: XCTestCase { func testConfigurations_cache() { let manager = OdpManager(sdkKey: sdkKey, disable: false, + cacheSize: cacheSize, cacheTimeoutInSecs: cacheTimeout) XCTAssertEqual(manager.segmentManager?.segmentsCache.maxSize, cacheSize) @@ -59,7 +59,6 @@ class OdpManagerTests: XCTestCase { disable: true, cacheSize: cacheSize, cacheTimeoutInSecs: cacheTimeout) - XCTAssertTrue(manager.vuid.starts(with: "vuid_"), "vuid should be serverved even when ODP is disabled.") let sem = DispatchSemaphore(value: 0) manager.fetchQualifiedSegments(userId: "user1", options: []) { segments, error in @@ -73,7 +72,6 @@ class OdpManagerTests: XCTestCase { XCTAssertNil(manager.odpConfig) // these calls should be dropped gracefully with nil - manager.identifyUser(userId: "user1") try? manager.sendEvent(type: "t1", action: "a1", identifiers: [:], data: [:]) @@ -99,12 +97,20 @@ class OdpManagerTests: XCTestCase { XCTAssertEqual(segmentManager.receivedOptions, []) } - // MARK: - registerVuid - func testRegisterVUIDCalledAutomatically() { - XCTAssertEqual(eventManager.receivedRegisterVuid, manager.vuid, "registerVUID is implicitly called on OdpManager init") + func testRegisterVUIDDoesNotCallAutomatically_vuidDisabled() { + let newEventManager = MockOdpEventManager(sdkKey: sdkKey) + + _ = OdpManager(sdkKey: sdkKey, + disable: false, + cacheSize: cacheSize, + cacheTimeoutInSecs: cacheTimeout, + segmentManager: segmentManager, + eventManager: newEventManager) + + XCTAssertNil(newEventManager.receivedRegisterVuid) } - + func testRegisterVUIDCalledAutomatically_odpDisabled() { let newEventManager = MockOdpEventManager(sdkKey: sdkKey) @@ -127,17 +133,19 @@ class OdpManagerTests: XCTestCase { } func testIdentifyUser_odpIntegrated() { + let vuid = "vuid_123" + manager.vuid = vuid manager.updateOdpConfig(apiKey: "key-1", apiHost: "host-1", segmentsToCheck: []) manager.identifyUser(userId: "user-1") - XCTAssert(OdpVuidManager.isVuid(eventManager.receivedIdentifyVuid)) + XCTAssert(VuidManager.isVuid(eventManager.receivedIdentifyVuid)) XCTAssertEqual(eventManager.receivedIdentifyUserId, "user-1") } func testIdentifyUser_odpIntegrated_vuidAsUserId() { manager.updateOdpConfig(apiKey: "key-1", apiHost: "host-1", segmentsToCheck: []) - let vuidAsUserId = OdpVuidManager.newVuid + let vuidAsUserId = VuidManager.newVuid manager.identifyUser(userId: vuidAsUserId) XCTAssertEqual(eventManager.receivedIdentifyVuid, vuidAsUserId) @@ -161,11 +169,14 @@ class OdpManagerTests: XCTestCase { // MARK: - sendEvent func testSendEvent_datafileNotReady() { + let vuid = "vuid_123" + manager.vuid = vuid + try? manager.sendEvent(type: "t1", action: "a1", identifiers: ["id-key1": "id-val-1"], data: ["key1" : "val1"]) XCTAssertEqual(eventManager.receivedType, "t1") XCTAssertEqual(eventManager.receivedAction, "a1") - XCTAssertEqual(eventManager.receivedIdentifiers, ["vuid": manager.vuid,"id-key1": "id-val-1"]) + XCTAssertEqual(eventManager.receivedIdentifiers, ["vuid": "vuid_123","id-key1": "id-val-1"]) XCTAssert(eventManager.receivedData.count == 1) XCTAssert((eventManager.receivedData["key1"] as! String) == "val1") @@ -217,20 +228,23 @@ class OdpManagerTests: XCTestCase { } func testSendEvent_aliasIdentifiers() { + let vuid = "vuid_123" + manager.vuid = vuid + try? manager.sendEvent(type: nil, action: "a1", identifiers: ["fs_user_id": "v1"], data: [:]) - XCTAssertEqual(eventManager.receivedIdentifiers, ["fs_user_id": "v1", "vuid": manager.vuid]) + XCTAssertEqual(eventManager.receivedIdentifiers, ["fs_user_id": "v1", "vuid": vuid]) try? manager.sendEvent(type: nil, action: "a1", identifiers: ["fs-user-id": "v1"], data: [:]) - XCTAssertEqual(eventManager.receivedIdentifiers, ["fs_user_id": "v1", "vuid": manager.vuid]) + XCTAssertEqual(eventManager.receivedIdentifiers, ["fs_user_id": "v1", "vuid": vuid]) try? manager.sendEvent(type: nil, action: "a1", identifiers: ["FS_USER_ID": "v1"], data: [:]) - XCTAssertEqual(eventManager.receivedIdentifiers, ["fs_user_id": "v1", "vuid": manager.vuid]) + XCTAssertEqual(eventManager.receivedIdentifiers, ["fs_user_id": "v1", "vuid": vuid]) try? manager.sendEvent(type: nil, action: "a1", identifiers: ["FS-USER-ID": "v1"], data: [:]) - XCTAssertEqual(eventManager.receivedIdentifiers, ["fs_user_id": "v1", "vuid": manager.vuid]) + XCTAssertEqual(eventManager.receivedIdentifiers, ["fs_user_id": "v1", "vuid": vuid]) try? manager.sendEvent(type: nil, action: "a1", identifiers: ["email": "e1", "FS-USER-ID": "v1"], data: [:]) - XCTAssertEqual(eventManager.receivedIdentifiers, ["email": "e1", "fs_user_id": "v1", "vuid": manager.vuid]) + XCTAssertEqual(eventManager.receivedIdentifiers, ["email": "e1", "fs_user_id": "v1", "vuid": vuid]) } // MARK: - updateConfig @@ -352,11 +366,6 @@ class OdpManagerTests: XCTestCase { XCTAssertEqual(eventManager.flushApiKeys.count, 1, "flush called when app goes to background") } - // MARK: - vuid - - func testVuid() { - XCTAssertEqual(manager.vuid, manager.vuidManager.vuid) - } // MARK: - Helpers @@ -375,11 +384,11 @@ class OdpManagerTests: XCTestCase { var resetCalled = false - override func registerVUID(vuid: String) { + override func sendInitializedEvent(vuid: String) { self.receivedRegisterVuid = vuid } - override func identifyUser(vuid: String, userId: String?) { + override func identifyUser(vuid: String?, userId: String?) { self.receivedIdentifyVuid = vuid self.receivedIdentifyUserId = userId } diff --git a/Tests/OptimizelyTests-Common/OdpVuidManagerTests.swift b/Tests/OptimizelyTests-Common/OdpVuidManagerTests.swift deleted file mode 100644 index 261c0544..00000000 --- a/Tests/OptimizelyTests-Common/OdpVuidManagerTests.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// Copyright 2022, Optimizely, Inc. and contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import XCTest - -class OdpVuidManagerTests: XCTestCase { - var manager = OdpVuidManager() - - func testNewVuid() { - let vuid = OdpVuidManager.newVuid - - XCTAssertTrue(vuid.starts(with: "vuid_")) - XCTAssertEqual(vuid.count, 32) - } - - func testIsVuid() { - XCTAssertTrue(OdpVuidManager.isVuid("vuid_123")) - XCTAssertFalse(OdpVuidManager.isVuid("vuid-123")) - XCTAssertFalse(OdpVuidManager.isVuid("123")) - } - - func testAutoSaveAndLoad() { - UserDefaults.standard.removeObject(forKey: "optimizely-vuid") - - manager = OdpVuidManager() - let vuid1 = manager.vuid - - manager = OdpVuidManager() - let vuid2 = manager.vuid - - XCTAssertTrue(vuid1 == vuid2) - XCTAssert(OdpVuidManager.isVuid(vuid1)) - XCTAssert(OdpVuidManager.isVuid(vuid2)) - - UserDefaults.standard.removeObject(forKey: "optimizely-vuid") - - manager = OdpVuidManager() - let vuid3 = manager.vuid - - XCTAssertTrue(vuid1 != vuid3) - } -} diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift index f0a73284..a08ba0e5 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift @@ -24,7 +24,7 @@ class OptimizelyUserContextTests_Decide_Reasons: XCTestCase { var decisionService: DefaultDecisionService! var ups: OPTUserProfileService! var user: OptimizelyUserContext! - + override func setUp() { super.setUp() @@ -41,13 +41,13 @@ class OptimizelyUserContextTests_Decide_Reasons: XCTestCase { // MARK: - error reasons (always included) extension OptimizelyUserContextTests_Decide_Reasons { - + func testDecideReasons_sdkNotReady() { optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, userProfileService: OTUtils.createClearUserProfileService()) try? optimizely.start(datafile: OTUtils.loadJSONDatafile("unsupported_datafile")!) user = optimizely.createUserContext(userId: kUserId) - + let decision = user.decide(key: "any-key") XCTAssert(decision.hasFailed) XCTAssertEqual(decision.reasons, [OptimizelyError.sdkNotReady.reason]) @@ -59,13 +59,13 @@ extension OptimizelyUserContextTests_Decide_Reasons { XCTAssert(decision.hasFailed) XCTAssertEqual(decision.reasons, [OptimizelyError.featureKeyInvalid(key).reason]) } - + func testDecideReasons_variableValueInvalid() { let featureKey = "feature_1" let rolloutId = "3319450668" let integerVariableId = "2687470095" let integerVariableKey = "i_42" - + // inject invalid variable value var rollout = optimizely.config!.getRollout(id: rolloutId)! var rolloutVariation = rollout.experiments[0].variations[0] @@ -83,12 +83,12 @@ extension OptimizelyUserContextTests_Decide_Reasons { // MARK: - error messages (only with "includeReasons") extension OptimizelyUserContextTests_Decide_Reasons { - + func testDecideReasons_conditionNoMatchingAudience() { let featureKey = "feature_1" let audienceId = "invalid_id" setAudienceForFeatureTest(featureKey: featureKey, audienceId: audienceId) - + var decision = user.decide(key: featureKey) XCTAssert(decision.reasons.isEmpty) decision = user.decide(key: featureKey, options: [.includeReasons]) @@ -99,7 +99,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { let featureKey = "feature_1" let audienceId = "invalid_format" setAudienceForFeatureTest(featureKey: featureKey, audienceId: audienceId) - + var decision = user.decide(key: featureKey) XCTAssert(decision.reasons.isEmpty) decision = user.decide(key: featureKey, options: [.includeReasons]) @@ -110,10 +110,10 @@ extension OptimizelyUserContextTests_Decide_Reasons { let featureKey = "feature_1" let audienceId = "invalid_condition" setAudienceForFeatureTest(featureKey: featureKey, audienceId: audienceId) - + let condition = "{\"match\":\"gt\",\"value\":\"US\",\"name\":\"age\",\"type\":\"custom_attribute\"}" user.setAttribute(key: "age", value: 25) - + var decision = user.decide(key: featureKey) XCTAssert(decision.reasons.isEmpty) decision = user.decide(key: featureKey, options: [.includeReasons]) @@ -124,7 +124,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { let featureKey = "feature_1" let audienceId = "13389130056" setAudienceForFeatureTest(featureKey: featureKey, audienceId: audienceId) - + let condition = "{\"match\":\"exact\",\"value\":\"US\",\"name\":\"country\",\"type\":\"custom_attribute\"}" let attributeKey = "country" let attributeValue = 25 @@ -140,7 +140,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { let featureKey = "feature_1" let audienceId = "age_18" setAudienceForFeatureTest(featureKey: featureKey, audienceId: audienceId) - + let condition = "{\"match\":\"gt\",\"value\":18,\"name\":\"age\",\"type\":\"custom_attribute\"}" user.setAttribute(key: "age", value: pow(2,54) as Double) // TOO-BIG value @@ -154,7 +154,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { let featureKey = "feature_1" let audienceId = "invalid_type" setAudienceForFeatureTest(featureKey: featureKey, audienceId: audienceId) - + let condition = "{\"match\":\"gt\",\"value\":18,\"name\":\"age\",\"type\":\"invalid\"}" user.setAttribute(key: "age", value: 25) @@ -168,7 +168,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { let featureKey = "feature_1" let audienceId = "invalid_match" setAudienceForFeatureTest(featureKey: featureKey, audienceId: audienceId) - + let condition = "{\"match\":\"invalid\",\"value\":18,\"name\":\"age\",\"type\":\"custom_attribute\"}" user.setAttribute(key: "age", value: 25) @@ -182,7 +182,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { let featureKey = "feature_1" let audienceId = "nil_value" setAudienceForFeatureTest(featureKey: featureKey, audienceId: audienceId) - + let condition = "{\"name\":\"age\",\"type\":\"custom_attribute\",\"match\":\"gt\"}" user.setAttribute(key: "age", value: 25) @@ -196,7 +196,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { let featureKey = "feature_1" let audienceId = "invalid_name" setAudienceForFeatureTest(featureKey: featureKey, audienceId: audienceId) - + let condition = "{\"type\":\"custom_attribute\",\"match\":\"gt\",\"value\":18}" user.setAttribute(key: "age", value: 25) @@ -210,7 +210,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { let featureKey = "feature_1" let audienceId = "age_18" setAudienceForFeatureTest(featureKey: featureKey, audienceId: audienceId) - + let condition = "{\"match\":\"gt\",\"value\":18,\"name\":\"age\",\"type\":\"custom_attribute\"}" var decision = user.decide(key: featureKey) @@ -218,13 +218,13 @@ extension OptimizelyUserContextTests_Decide_Reasons { decision = user.decide(key: featureKey, options: [.includeReasons]) XCTAssert(decision.reasons.contains(OptimizelyError.missingAttributeValue(condition, "age").reason)) } - + } // MARK: - log messages (only with "includeReasons") extension OptimizelyUserContextTests_Decide_Reasons { - + func testDecideReasons_experimentNotRunning() { let featureKey = "feature_1" let experimentKey = "exp_with_audience" @@ -242,7 +242,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { let experimentKey = "exp_with_audience" let variationKey2 = "b" let variationId2 = "10416523121" - + OTUtils.setVariationToUPS(ups: ups, userId: kUserId, experimentId: experimentId, variationId: variationId2) let decision = user.decide(key: featureKey, options: [.includeReasons]) @@ -255,7 +255,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { let featureKey = "feature_1" let variationKey = "b" let experimentKey = setForcedVariationForFeatureTest(featureKey: featureKey, userId: kUserId, variationKey: variationKey) - + let decision = user.decide(key: featureKey, options: [.includeReasons]) XCTAssertEqual(decision.variationKey, variationKey) XCTAssertEqual(decision.reasons, [LogMessage.userHasForcedVariation(kUserId, experimentKey, variationKey).reason]) @@ -277,7 +277,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { let featureKey = "feature_1" let variationKey = "b" setWhiteListForFeatureTest(featureKey: featureKey, userId: kUserId, variationKey: variationKey) - + let decision = user.decide(key: featureKey, options: [.includeReasons]) XCTAssertEqual(decision.variationKey, variationKey) XCTAssertEqual(decision.reasons, [LogMessage.forcedVariationFound(variationKey, kUserId).reason]) @@ -287,12 +287,12 @@ extension OptimizelyUserContextTests_Decide_Reasons { let featureKey = "feature_1" let variationKey = "invalid-key" setWhiteListForFeatureTest(featureKey: featureKey, userId: kUserId, variationKey: variationKey) - + let decision = user.decide(key: featureKey, options: [.includeReasons]) XCTAssertNotEqual(decision.variationKey, variationKey) XCTAssert(decision.reasons.contains(LogMessage.forcedVariationFoundButInvalid(variationKey, kUserId).reason)) } - + func testDecideReasons_userMeetsConditionsForTargetingRule() { let key = "feature_1" @@ -322,7 +322,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { decision = user.decide(key: key, options: [.includeReasons]) XCTAssert(decision.reasons.contains(LogMessage.userBucketedIntoTargetingRule(kUserId, "1").reason)) } - + func testDecideReasons_userBucketedIntoEveryoneTargetingRule() { let key = "feature_1" @@ -342,7 +342,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { decision = user.decide(key: key, options: [.includeReasons]) XCTAssert(decision.reasons.contains(LogMessage.userNotBucketedIntoTargetingRule(kUserId, "2").reason)) } - + func testDecideReasons_userBucketedIntoVariationInExperiment() { let featureKey = "feature_2" // embedding experiment: "exp_no_audience" let experimentKey = "exp_no_audience" @@ -359,13 +359,13 @@ extension OptimizelyUserContextTests_Decide_Reasons { func testDecideReasons_userNotBucketedIntoVariation() { let featureKey = "feature_2" // embedding experiment: "exp_no_audience" let experimentId = "10420810910" // "exp_no_audience" - + var experiment = optimizely.config!.getExperiment(id: experimentId)! var trafficAllocation = experiment.trafficAllocation[0] trafficAllocation.endOfRange = 0 experiment.trafficAllocation = [trafficAllocation] optimizely.config!.experimentIdMap = [experimentId: experiment] - + var decision = user.decide(key: featureKey) XCTAssert(decision.reasons.isEmpty) decision = user.decide(key: featureKey, options: [.includeReasons]) @@ -384,7 +384,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { variation.id = variationIdInvalid experiment.variations = [variation] optimizely.config!.experimentIdMap = [experimentId: experiment] - + var decision = user.decide(key: featureKey) XCTAssert(decision.reasons.isEmpty) decision = user.decide(key: featureKey, options: [.includeReasons]) @@ -396,7 +396,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { let experimentKey = "group_exp_1" let groupId = "13142870430" setExperimentForFeatureTest(featureKey: featureKey, experimentKey: experimentKey) - + let decision = user.decide(key: featureKey, options: [.ignoreUserProfileService, .includeReasons]) XCTAssert(decision.reasons.contains(LogMessage.userBucketedIntoExperimentInGroup(kUserId, experimentKey, @@ -408,7 +408,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { let experimentKey = "group_exp_2" let groupId = "13142870430" setExperimentForFeatureTest(featureKey: featureKey, experimentKey: experimentKey) - + let decision = user.decide(key: featureKey, options: [.includeReasons]) XCTAssert(decision.reasons.contains(LogMessage.userNotBucketedIntoExperimentInGroup(kUserId, experimentKey, @@ -420,12 +420,12 @@ extension OptimizelyUserContextTests_Decide_Reasons { var group = optimizely.config!.getGroup(id: groupId)! group.trafficAllocation = [] optimizely.config!.project.groups = [group] - + // set up temp feature-experiments AFTER config.project updated (otherwise overwritten) let featureKey = "feature_3" let experimentKey = "group_exp_1" setExperimentForFeatureTest(featureKey: featureKey, experimentKey: experimentKey) - + let decision = user.decide(key: featureKey, options: [.includeReasons]) XCTAssert(decision.reasons.contains(LogMessage.userNotBucketedIntoAnyExperimentInGroup(kUserId, groupId).reason)) @@ -439,12 +439,12 @@ extension OptimizelyUserContextTests_Decide_Reasons { trafficAllocation.entityId = experimentIdInvalid group.trafficAllocation = [trafficAllocation] optimizely.config!.project.groups = [group] - + // set up temp feature-experiments AFTER config.project updated (otherwise overwritten) let featureKey = "feature_3" let experimentKey = "group_exp_1" setExperimentForFeatureTest(featureKey: featureKey, experimentKey: experimentKey) - + let decision = user.decide(key: featureKey, options: [.includeReasons]) XCTAssert(decision.reasons.contains(LogMessage.userBucketedIntoInvalidExperiment(experimentIdInvalid).reason)) } @@ -456,13 +456,13 @@ extension OptimizelyUserContextTests_Decide_Reasons { let decision = user.decide(key: featureKey, options: [.includeReasons]) XCTAssert(decision.reasons.contains(LogMessage.userNotInExperiment(kUserId, experimentKey).reason)) } - + } // Utils extension OptimizelyUserContextTests_Decide_Reasons { - + func setAudienceForFeatureTest(featureKey: String, audienceId: String) { let experimentId = "10390977673" // "exp_with_audience" var experiment = optimizely.config!.getExperiment(id: experimentId)! @@ -483,7 +483,7 @@ extension OptimizelyUserContextTests_Decide_Reasons { _ = optimizely.setForcedVariation(experimentKey: experiment.key, userId: userId, variationKey: variationKey) return experiment.key } - + func removeVariationsForFeatureTest(featureKey: String, userId: String, variationKey: String) { let experimentId = "10390977673" // "exp_with_audience" var experiment = optimizely.config!.getExperiment(id: experimentId)! @@ -491,20 +491,19 @@ extension OptimizelyUserContextTests_Decide_Reasons { optimizely.config!.experimentIdMap = [experimentId: experiment] optimizely.config!.experimentKeyMap = [experiment.key: experiment] } - + func setWhiteListForFeatureTest(featureKey: String, userId: String, variationKey: String) { let experimentId = "10390977673" // "exp_with_audience" var experiment = optimizely.config!.getExperiment(id: experimentId)! experiment.forcedVariations = [userId: variationKey] optimizely.config!.experimentIdMap = [experimentId: experiment] } - + func setExperimentForFeatureTest(featureKey: String, experimentKey: String) { let experimentId = optimizely.config!.getExperimentId(key: experimentKey)! var feature = optimizely.config!.getFeatureFlag(key: featureKey)! feature.experimentIds = [experimentId] optimizely.config!.featureFlagKeyMap = [featureKey: feature] } - + } - diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_ODP_2.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_ODP_2.swift index 79e7a3ba..2033a740 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_ODP_2.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_ODP_2.swift @@ -26,20 +26,31 @@ class OptimizelyUserContextTests_ODP_2: XCTestCase { // odp disabled to avoid initial noise - let optimizely = OptimizelyClient(sdkKey: sdkKey, - settings: OptimizelySdkSettings(disableOdp: true)) - +// let optimizely = OptimizelyClient(sdkKey: sdkKey, +// settings: OptimizelySdkSettings(disableOdp: true, enableVuid: true)) +// // override with a custom enabled odpManager. // - client_inializatied event will be sent automatically // - will wait in the queue until project config is ready let odpEventApiManager = MockOdpEventApiManager() - optimizely.odpManager = OdpManager(sdkKey: sdkKey, - disable: false, - cacheSize: 10, - cacheTimeoutInSecs: 10, - eventManager: OdpEventManager(sdkKey: sdkKey, - apiManager: odpEventApiManager)) + + let optimizely = OptimizelyClient(sdkKey: sdkKey, + odpManager: + OdpManager(sdkKey: sdkKey, + disable: false, + vuid: "vuid_123", + cacheSize: 10, + cacheTimeoutInSecs: 10, + eventManager: + OdpEventManager( + sdkKey: sdkKey, + apiManager: odpEventApiManager + ) + ), + settings: + OptimizelySdkSettings(disableOdp: true, enableVuid: true) + ) // identified event will sent but wait in the queue until project config is ready _ = optimizely.createUserContext(userId: "tester") diff --git a/Tests/OptimizelyTests-Common/VuidManagerTests.swift b/Tests/OptimizelyTests-Common/VuidManagerTests.swift new file mode 100644 index 00000000..35bb653b --- /dev/null +++ b/Tests/OptimizelyTests-Common/VuidManagerTests.swift @@ -0,0 +1,78 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class VuidManagerTests: XCTestCase { + var manager = VuidManager.shared + + + func testNewVuid() { + manager.configure(enable: true) + + let vuid = VuidManager.newVuid + + XCTAssertTrue(vuid.starts(with: "vuid_")) + XCTAssertEqual(vuid.count, 32) + } + + func testIsVuid() { + manager.configure(enable: true) + XCTAssertTrue(VuidManager.isVuid("vuid_123")) + XCTAssertFalse(VuidManager.isVuid("vuid-123")) + XCTAssertFalse(VuidManager.isVuid("123")) + } + + func testIsVuidIgnoreCase() { + manager.configure(enable: true) + XCTAssertTrue(VuidManager.isVuid("VUID_123")) + XCTAssertFalse(VuidManager.isVuid("VUID-123")) + XCTAssertFalse(VuidManager.isVuid("123")) + } + + func testAutoSaveAndLoad() { + UserDefaults.standard.removeObject(forKey: "optimizely-vuid") + + manager.configure(enable: true) + let vuid1 = manager.vuid + + let vuid2 = manager.vuid + + XCTAssertTrue(vuid1 == vuid2) + XCTAssert(VuidManager.isVuid(vuid1!)) + XCTAssert(VuidManager.isVuid(vuid2!)) + + UserDefaults.standard.removeObject(forKey: "optimizely-vuid") + + manager.configure(enable: true) + let vuid3 = manager.vuid + + XCTAssertTrue(vuid1 != vuid3) + } + + func testRemoveOldVuid() { + manager.configure(enable: true) + let cahcedVuid1 = UserDefaults.standard.string(forKey: "optimizely-vuid") + XCTAssertNotNil(cahcedVuid1) + XCTAssertTrue(cahcedVuid1 == manager.vuid) + + manager.configure(enable: false) + let cahcedVuid2 = UserDefaults.standard.string(forKey: "optimizely-vuid") + XCTAssertNil(cahcedVuid2) + + } + +} From c7460b286ee78eb2ec7fd7c198ab9781ed94db2f Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Mon, 25 Nov 2024 19:46:37 +0600 Subject: [PATCH 12/37] [FSSDK-10761] fix: passed fsc for VUID optln (#564) FSC test cases passed for vuid optln --- Sources/ODP/OdpManager.swift | 9 ++++- .../OptimizelyClient+Decide.swift | 2 +- Sources/Optimizely/OptimizelyClient.swift | 34 +++++++------------ Sources/Optimizely/VuidManager.swift | 1 - .../OptimizelyClientTests_ODP.swift | 24 ++++++++++--- 5 files changed, 40 insertions(+), 30 deletions(-) diff --git a/Sources/ODP/OdpManager.swift b/Sources/ODP/OdpManager.swift index 40ba6ad3..a69242b2 100644 --- a/Sources/ODP/OdpManager.swift +++ b/Sources/ODP/OdpManager.swift @@ -57,7 +57,8 @@ public class OdpManager { cacheTimeoutInSecs: cacheTimeoutInSecs, resourceTimeoutInSecs: timeoutForSegmentFetchInSecs) self.eventManager = eventManager ?? OdpEventManager(sdkKey: sdkKey, - resourceTimeoutInSecs: timeoutForEventDispatchInSecs) + resourceTimeoutInSecs: timeoutForEventDispatchInSecs) + self.odpConfig = OdpConfig() self.segmentManager.odpConfig = odpConfig self.eventManager.odpConfig = odpConfig @@ -80,6 +81,12 @@ public class OdpManager { completionHandler: completionHandler) } + func sendInitializedEvent(vuid: String) throws { + guard enabled else { throw OptimizelyError.odpNotEnabled } + guard odpConfig.eventQueueingAllowed else { throw OptimizelyError.odpNotIntegrated } + eventManager.sendInitializedEvent(vuid: vuid) + } + func identifyUser(userId: String) { guard enabled else { logger.d("ODP identify event is not dispatched (ODP disabled).") diff --git a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift index c49d8e40..eda0d38a 100644 --- a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift +++ b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift @@ -36,7 +36,7 @@ extension OptimizelyClient { /// - Parameter attributes: A map of attribute names to current user attribute values. /// - Returns: An OptimizelyUserContext associated with this OptimizelyClient public func createUserContext(attributes: [String: Any]? = nil) -> OptimizelyUserContext? { - guard enableVuid, let vuid = self.vuid else { + guard let vuid = self.vuid else { logger.e("Vuid is not enabled or invalid VUID. User context not created.") return nil } diff --git a/Sources/Optimizely/OptimizelyClient.swift b/Sources/Optimizely/OptimizelyClient.swift index 49953611..b99c9393 100644 --- a/Sources/Optimizely/OptimizelyClient.swift +++ b/Sources/Optimizely/OptimizelyClient.swift @@ -60,7 +60,7 @@ open class OptimizelyClient: NSObject { var decisionService: OPTDecisionService! public var notificationCenter: OPTNotificationCenter? public var odpManager: OdpManager! - private var vuidManager: VuidManager! + public var vuid: String? let sdkSettings: OptimizelySdkSettings // MARK: - Public interfaces @@ -90,17 +90,19 @@ open class OptimizelyClient: NSObject { self.sdkKey = sdkKey self.sdkSettings = settings ?? OptimizelySdkSettings() self.defaultDecideOptions = defaultDecideOptions ?? [] - + super.init() - self.vuidManager = VuidManager.shared - self.vuidManager.configure(enable: self.sdkSettings.enableVuid) + VuidManager.shared.configure(enable: self.sdkSettings.enableVuid) + if self.sdkSettings.enableVuid { + self.vuid = VuidManager.shared.vuid + } + self.odpManager = odpManager ?? OdpManager(sdkKey: sdkKey, disable: sdkSettings.disableOdp, cacheSize: sdkSettings.segmentsCacheSize, cacheTimeoutInSecs: sdkSettings.segmentsCacheTimeoutInSecs, timeoutForSegmentFetchInSecs: sdkSettings.timeoutForSegmentFetchInSecs, timeoutForEventDispatchInSecs: sdkSettings.timeoutForOdpEventInSecs) - self.odpManager.vuid = vuidManager.vuid let userProfileService = userProfileService ?? DefaultUserProfileService() let logger = logger ?? DefaultLogger() type(of: logger).logLevel = defaultLogLevel ?? .info @@ -117,17 +119,10 @@ open class OptimizelyClient: NSObject { self.datafileHandler = HandlerRegistryService.shared.injectDatafileHandler(sdkKey: self.sdkKey) self.decisionService = HandlerRegistryService.shared.injectDecisionService(sdkKey: self.sdkKey) self.notificationCenter = HandlerRegistryService.shared.injectNotificationCenter(sdkKey: self.sdkKey) - - if let _vuid = self.vuidManager.vuid { - try? sendOdpEvent(type: Constants.ODP.eventType, - action: "client_initialized", - identifiers: [ - Constants.ODP.keyForVuid: _vuid - ], - data: [:]) + if let _vuid = vuid { + self.odpManager.vuid = _vuid + sendInitializedEvent(vuid: _vuid) } - - logger.d("SDK Version: \(version)") } @@ -983,13 +978,8 @@ extension OptimizelyClient { data: data) } - /// the device vuid (read only) - public var vuid: String? { - return self.vuidManager.vuid - } - - public var enableVuid: Bool { - return self.vuidManager.enable + func sendInitializedEvent(vuid: String) { + try? odpManager.sendInitializedEvent(vuid: vuid) } func identifyUserToOdp(userId: String) { diff --git a/Sources/Optimizely/VuidManager.swift b/Sources/Optimizely/VuidManager.swift index 6ffcb45a..47c22348 100644 --- a/Sources/Optimizely/VuidManager.swift +++ b/Sources/Optimizely/VuidManager.swift @@ -20,7 +20,6 @@ public class VuidManager { private var _vuid: String = "" private(set) var enable: Bool = false let logger = OPTLoggerFactory.getLogger() - // a single vuid should be shared for all SDK instances public static let shared = VuidManager() diff --git a/Tests/OptimizelyTests-APIs/OptimizelyClientTests_ODP.swift b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_ODP.swift index 22600e79..882aa717 100644 --- a/Tests/OptimizelyTests-APIs/OptimizelyClientTests_ODP.swift +++ b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_ODP.swift @@ -190,14 +190,28 @@ class OptimizelyClientTests_ODP: XCTestCase { func testVuidEnabled() { let settings = OptimizelySdkSettings(enableVuid: true) optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, settings: settings) - XCTAssertTrue(optimizely.enableVuid) + XCTAssertNotNil(optimizely.vuid) XCTAssert(optimizely.vuid!.starts(with: "vuid_")) } - func testVuidDiabled() { - // Default client vuid diabled - XCTAssertFalse(optimizely.enableVuid) - XCTAssertNil(optimizely.vuid) + func test_register_vuid_autometically_when_enabled() { + let settings = OptimizelySdkSettings(enableVuid: true) + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, settings: settings) + let eventManager = optimizely.odpManager.eventManager! + let evt = eventManager.eventQueue.getFirstItem()! + + XCTAssertEqual("fullstack", evt.type) + XCTAssertEqual("client_initialized", evt.action) + XCTAssertEqual(["vuid": optimizely.vuid], evt.identifiers) + XCTAssertNotNil(optimizely.vuid) + } + + func test_vuid_does_not_register_autometically_when_enabled_but_odp_disabled() { + let settings = OptimizelySdkSettings(disableOdp: true, enableVuid: true) + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, settings: settings) + XCTAssertNotNil(optimizely.vuid) + let eventManager = optimizely.odpManager.eventManager + XCTAssertNil(eventManager) } // MARK: - OdpConfig Update From ff10f88f45dc497fd73be1274a96dc1a14911299 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam Date: Mon, 25 Nov 2024 21:49:40 +0600 Subject: [PATCH 13/37] prepare for release 5.0.0 --- .github/workflows/swift.yml | 2 +- CHANGELOG.md | 11 +++++++++++ README.md | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 4ebc6cc8..82382fa5 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -17,7 +17,7 @@ on: description: release env: - VERSION: 4.1.0 + VERSION: 5.0.0 jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index be38dbc5..4166bf0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Optimizely Swift SDK Changelog +## 5.0.0 +November 25th, 2024 + +### Breaking Changes +* VUID configuration is now independent of ODP ([#556](https://github.com/optimizely/swift-sdk/pull/556)) +* When VUID is disabled: + * `vuid` is not generated or saved. + * `client-initialized` event will not auto fired on SDK init. + * `vuid` is not included in the odp events as a default attribute. + * `createUserContext()` will be rejected if `userId` is not provided. + ## 4.1.0 November 8, 2024 diff --git a/README.md b/README.md index 1a271bc5..858f6295 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ If you have a name conflict with other swift packages when you add the Optimizel #### CocoaPods 1. Add the following lines to the _Podfile_:
 ```use_frameworks!```
-```pod 'OptimizelySwiftSDK', '~> 4.1.0'```
+```pod 'OptimizelySwiftSDK', '~> 5.0.0'```
 
2. Run the following command:
``` pod install ```
From a1eb3ff38bb1c262327d49658e12f23d58e01121 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam Date: Mon, 25 Nov 2024 21:56:11 +0600 Subject: [PATCH 14/37] revert prepare release 5.0.0 --- .github/workflows/swift.yml | 2 +- CHANGELOG.md | 11 ----------- README.md | 2 +- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 82382fa5..4ebc6cc8 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -17,7 +17,7 @@ on: description: release env: - VERSION: 5.0.0 + VERSION: 4.1.0 jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4166bf0b..be38dbc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,5 @@ # Optimizely Swift SDK Changelog -## 5.0.0 -November 25th, 2024 - -### Breaking Changes -* VUID configuration is now independent of ODP ([#556](https://github.com/optimizely/swift-sdk/pull/556)) -* When VUID is disabled: - * `vuid` is not generated or saved. - * `client-initialized` event will not auto fired on SDK init. - * `vuid` is not included in the odp events as a default attribute. - * `createUserContext()` will be rejected if `userId` is not provided. - ## 4.1.0 November 8, 2024 diff --git a/README.md b/README.md index 858f6295..1a271bc5 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ If you have a name conflict with other swift packages when you add the Optimizel #### CocoaPods 1. Add the following lines to the _Podfile_:
 ```use_frameworks!```
-```pod 'OptimizelySwiftSDK', '~> 5.0.0'```
+```pod 'OptimizelySwiftSDK', '~> 4.1.0'```
 
2. Run the following command:
``` pod install ```
From a117ad5c4722109cfe5dc7cc0cd1e482339a66fb Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Tue, 26 Nov 2024 08:15:58 +0600 Subject: [PATCH 15/37] [FSSDK-10944] chore: prepare for release 5.0.0 (#566) * prepare release 5.0.0 --- .github/workflows/source_clear_cron.yml | 2 +- .github/workflows/swift.yml | 8 ++++---- .github/workflows/unit_tests.yml | 2 +- CHANGELOG.md | 11 +++++++++++ README.md | 2 +- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/source_clear_cron.yml b/.github/workflows/source_clear_cron.yml index ca1a715b..764649ab 100644 --- a/.github/workflows/source_clear_cron.yml +++ b/.github/workflows/source_clear_cron.yml @@ -9,7 +9,7 @@ on: jobs: source_clear: - runs-on: macos-12 + runs-on: macos-13 steps: - uses: actions/checkout@v3 - name: Source clear scan diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 4ebc6cc8..3186aa2b 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -17,7 +17,7 @@ on: description: release env: - VERSION: 4.1.0 + VERSION: 5.0.0 jobs: @@ -32,7 +32,7 @@ jobs: TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} lint: - runs-on: macos-12 + runs-on: macos-13 steps: - uses: actions/checkout@v3 - uses: maxim-lobanov/setup-xcode@v1 @@ -50,7 +50,7 @@ jobs: uses: optimizely/swift-sdk/.github/workflows/unit_tests.yml@master prepare_for_release: - runs-on: macos-12 + runs-on: macos-13 if: "${{ github.event.inputs.PREP == 'true' && github.event_name == 'workflow_dispatch' }}" steps: - uses: actions/checkout@v3 @@ -79,7 +79,7 @@ jobs: release: if: "${{github.event.inputs.RELEASE == 'true' && github.event_name == 'workflow_dispatch' }}" - runs-on: macos-12 + runs-on: macos-13 steps: - uses: actions/checkout@v3 - uses: maxim-lobanov/setup-xcode@v1 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 6ef910e8..ed9c1ca1 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -7,7 +7,7 @@ env: jobs: unittests: - runs-on: macos-12 + runs-on: macos-13 strategy: fail-fast: false matrix: diff --git a/CHANGELOG.md b/CHANGELOG.md index be38dbc5..d09e6c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Optimizely Swift SDK Changelog +## 5.0.0 +November 25th, 2024 + +### Breaking Changes +* VUID configuration is now independent of ODP ([#456](https://github.com/optimizely/swift-sdk/pull/556)) +* When VUID is disabled: + * `vuid` is not generated or saved. + * `client-initialized` event will not auto fired on SDK init. + * `vuid` is not included in the odp events as a default attribute. + * `createUserContext()` will be rejected if `userId` is not provided. + ## 4.1.0 November 8, 2024 diff --git a/README.md b/README.md index 1a271bc5..858f6295 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ If you have a name conflict with other swift packages when you add the Optimizel #### CocoaPods 1. Add the following lines to the _Podfile_:
 ```use_frameworks!```
-```pod 'OptimizelySwiftSDK', '~> 4.1.0'```
+```pod 'OptimizelySwiftSDK', '~> 5.0.0'```
 
2. Run the following command:
``` pod install ```
From b3651f6d4dc7b721ebe4d38254f02c4a1d1765bd Mon Sep 17 00:00:00 2001 From: optibot Date: Tue, 26 Nov 2024 03:31:40 +0100 Subject: [PATCH 16/37] ci(git-action): auto release prep for 5.0.0 (#567) Co-authored-by: optibot --- OptimizelySwiftSDK.podspec | 2 +- Sources/Utils/SDKVersion.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/OptimizelySwiftSDK.podspec b/OptimizelySwiftSDK.podspec index 562d6275..415331d0 100644 --- a/OptimizelySwiftSDK.podspec +++ b/OptimizelySwiftSDK.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "OptimizelySwiftSDK" s.module_name = "Optimizely" - s.version = "4.1.0" + s.version = "5.0.0" s.summary = "Optimizely experiment framework for iOS/tvOS/watchOS" s.homepage = "https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs" s.license = { :type => "Apache License, Version 2.0", :file => "LICENSE" } diff --git a/Sources/Utils/SDKVersion.swift b/Sources/Utils/SDKVersion.swift index 1b49adde..fd4f77d4 100644 --- a/Sources/Utils/SDKVersion.swift +++ b/Sources/Utils/SDKVersion.swift @@ -17,4 +17,4 @@ /// Do not edit this field. /// - It is auto updated (Scripts/updated_version.sh) to reflect the current version /// - Do not put underscores in the name (Swiftlint can modify unexpectedly) -let OPTIMIZELYSDKVERSION = "4.1.0" +let OPTIMIZELYSDKVERSION = "5.0.0" From a5cdf42b9a17b70e2fca7ad802d0fed8b0d1d188 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Tue, 4 Feb 2025 21:36:16 +0600 Subject: [PATCH 17/37] [FSSDK-11036] fix: event tags support nested objects (#570) * fix: nested event tag support added * fix: update simulator matrix --- .github/workflows/swift.yml | 4 +- .github/workflows/unit_tests.yml | 16 +++--- .../Data Model/Audience/AttributeValue.swift | 55 ++++++++++++++++++- .../BatchEventBuilderTests_EventTags.swift | 54 +++++++++++++++++- .../DecisionServiceTests_Experiments.swift | 16 ++---- .../AttributeValueTests.swift | 37 ++++++++----- .../UserAttributeTests.swift | 4 +- 7 files changed, 147 insertions(+), 39 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 3186aa2b..869fb226 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -26,7 +26,7 @@ jobs: integration_tests: if: "${{ github.event.inputs.PREP == '' && github.event.inputs.RELEASE == '' }}" - uses: optimizely/swift-sdk/.github/workflows/integration_tests.yml@master + uses: optimizely/swift-sdk/.github/workflows/integration_tests.yml@muzahid/fix-nested-event-tag secrets: CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} @@ -47,7 +47,7 @@ jobs: unittests: if: "${{ github.event.inputs.PREP == '' && github.event.inputs.RELEASE == '' }}" - uses: optimizely/swift-sdk/.github/workflows/unit_tests.yml@master + uses: optimizely/swift-sdk/.github/workflows/unit_tests.yml@muzahid/fix-nested-event-tag prepare_for_release: runs-on: macos-13 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index ed9c1ca1..e6064af3 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -19,27 +19,27 @@ jobs: # - see "https://github.com/actions/runner-images/blob/main/images/macos/macos-11-Readme.md" for installed macOS, xcode and simulator versions. include: - os: 16.1 - device: "iPhone 12" + device: "iPhone 14" scheme: "OptimizelySwiftSDK-iOS" test_sdk: "iphonesimulator" platform: "iOS Simulator" os_type: "iOS" simulator_xcode_version: 14.1 - - os: 15.5 - device: "iPhone 12" + - os: 16.2 + device: "iPhone 14" scheme: "OptimizelySwiftSDK-iOS" test_sdk: "iphonesimulator" platform: "iOS Simulator" os_type: "iOS" - simulator_xcode_version: 13.4.1 - - os: 15.5 + simulator_xcode_version: 14.2 + - os: 16.4 # good to have tests with older OS versions, but it looks like this is min OS+xcode versions supported by github actions - device: "iPad Air (4th generation)" + device: "iPad Air (5th generation)" scheme: "OptimizelySwiftSDK-iOS" test_sdk: "iphonesimulator" platform: "iOS Simulator" os_type: "iOS" - simulator_xcode_version: 13.4.1 + simulator_xcode_version: 14.3.1 - os: 16.1 device: "Apple TV" scheme: "OptimizelySwiftSDK-tvOS" @@ -85,7 +85,7 @@ jobs: # - to find pre-installed xcode version, run this: ##ls /Applications/ # - to find supported simulator os versions, run this (and find simulator with non-error "datapath") - ##xcrun simctl list --json devices + xcrun simctl list --json devices # switch to the target xcode version sudo xcode-select -switch /Applications/Xcode_$SIMULATOR_XCODE_VERSION.app diff --git a/Sources/Data Model/Audience/AttributeValue.swift b/Sources/Data Model/Audience/AttributeValue.swift index 66371d42..3c46a5ee 100644 --- a/Sources/Data Model/Audience/AttributeValue.swift +++ b/Sources/Data Model/Audience/AttributeValue.swift @@ -17,11 +17,15 @@ import Foundation enum AttributeValue: Codable, Equatable, CustomStringConvertible { + typealias AttrArray = Array + typealias AttrDictionary = [String : AttributeValue] + case string(String) case int(Int64) // supported value range [-2^53, 2^53] case double(Double) case bool(Bool) - // not defined in datafile schema, but required for forward compatiblity (see Nikhil's doc) + case array(AttrArray) + case dictionary(AttrDictionary) case others var description: String { @@ -34,6 +38,10 @@ enum AttributeValue: Codable, Equatable, CustomStringConvertible { return "int(\(value))" case .bool(let value): return "bool(\(value))" + case .array(let value): + return "array(\(value))" + case .dictionary(let value): + return "dictionary(\(value))" case .others: return "others" } @@ -63,6 +71,18 @@ enum AttributeValue: Codable, Equatable, CustomStringConvertible { self = .bool(boolValue) return } + + if let arrValue = value as? [Any] { + let attr = arrValue.compactMap { AttributeValue(value: $0) } + self = .array(attr) + return + } + + if let dicValue = value as? [String : Any] { + let attr = dicValue.compactMapValues { AttributeValue(value: $0) } + self = .dictionary(attr) + return + } return nil } @@ -87,7 +107,18 @@ enum AttributeValue: Codable, Equatable, CustomStringConvertible { return } - // accept all other types (null, {}, []) for forward compatibility support + if let value = try? container.decode(AttrArray.self) { + self = .array(value) + return + } + + if let value = try? container.decode(AttrDictionary.self) { + self = .dictionary(value) + return + } + + + // accept all other types (null) for forward compatibility support self = .others } @@ -103,6 +134,10 @@ enum AttributeValue: Codable, Equatable, CustomStringConvertible { try container.encode(value) case .bool(let value): try container.encode(value) + case .array(let value): + try container.encode(value) + case .dictionary(let value): + try container.encode(value.mapValues { $0 }) case .others: return } @@ -135,6 +170,14 @@ extension AttributeValue { return true } + if case .array(let selfArr) = self, case .array(let targetArr) = targetValue { + return selfArr == targetArr + } + + if case .dictionary(let selfDict) = self, case .dictionary(let targetDict) = targetValue { + return selfDict == targetDict + } + return false } @@ -227,6 +270,10 @@ extension AttributeValue { return String(value) case .bool(let value): return String(value) + case .array(let value): + return String(describing: value) + case .dictionary(let value): + return String(describing: value) case .others: return "UNKNOWN" } @@ -240,6 +287,8 @@ extension AttributeValue { case (.double, .int): return true case (.double, .double): return true case (.bool, .bool): return true + case (.array, .array): return true + case (.dictionary, .dictionary): return true default: return false } } @@ -271,6 +320,8 @@ extension AttributeValue { case (.int): return true case (.double): return true case (.bool): return true + case (.array): return true + case (.dictionary): return true default: return false } } diff --git a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_EventTags.swift b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_EventTags.swift index 2411ed2e..6041de92 100644 --- a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_EventTags.swift +++ b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_EventTags.swift @@ -76,7 +76,7 @@ class BatchEventBuilderTests_EventTags: XCTestCase { extension BatchEventBuilderTests_EventTags { - func testEventTagsWhenInvalidType() { + func testEventTagsWhenArrayType() { let eventKey = "event_single_targeted_exp" let eventTags: [String: Any] = ["browser": "chrome", "future": [1, 2, 3]] @@ -87,7 +87,8 @@ extension BatchEventBuilderTests_EventTags { let tags = de["tags"] as! [String: Any] XCTAssertEqual(tags["browser"] as! String, "chrome") - XCTAssertNil(tags["future"]) + XCTAssertNotNil(tags["future"]) + XCTAssertEqual(tags["future"] as? [Int], [1, 2, 3]) } func testEventTagsWhenTooBigNumbers() { @@ -316,6 +317,55 @@ extension BatchEventBuilderTests_EventTags { XCTAssertEqual(de["value"] as! Double, 32, "value must be valid for value") } + + func testNestedTag() { + let properties: [String: Any] = [ + "category": "shoes", + "Text": "value", + "nested": [ + "foot": "value", + "mouth": "mouth_value" + ], + "stringArray": ["a", "b", "c"], + "intArray": [1, 2, 3], + "doubleArray": [1.0, 2.0, 3.0], + "boolAray": [false, true, false, true], + ] + let eventKey = "event_single_targeted_exp" + let eventTags: [String: Any] = ["browser": "chrome", + "v1": Int8(10), + "v2": Int16(20), + "v3": Int32(30), + "revenue": Int64(40), + "value": Float(32), + "$opt_event_properties": properties] + + try! optimizely.track(eventKey: eventKey, userId: userId, attributes: nil, eventTags: eventTags) + + let de = getDispatchEvent(dispatcher: eventDispatcher)! + let tags = de["tags"] as! [String: Any] + + XCTAssertEqual(tags["browser"] as! String, "chrome") + XCTAssertEqual(tags["v1"] as! Int, 10) + XCTAssertEqual(tags["v2"] as! Int, 20) + XCTAssertEqual(tags["v3"] as! Int, 30) + XCTAssertEqual(tags["revenue"] as! Int, 40) + XCTAssertEqual(tags["value"] as! Double, 32) + XCTAssertEqual(de["revenue"] as! Int, 40, "value must be valid for revenue") + XCTAssertEqual(de["value"] as! Double, 32, "value must be valid for value") + + XCTAssertEqual((tags["$opt_event_properties"] as! [String : Any])["category"] as! String, "shoes") + XCTAssertEqual((tags["$opt_event_properties"] as! [String : Any])["nested"] as! [String : String], ["foot": "value", "mouth": "mouth_value"]) + + XCTAssertEqual((tags["$opt_event_properties"] as! [String : Any])["stringArray"] as! [String], ["a", "b", "c"]) + XCTAssertEqual((tags["$opt_event_properties"] as! [String : Any])["intArray"] as! [Int], [1, 2, 3]) + XCTAssertEqual((tags["$opt_event_properties"] as! [String : Any])["doubleArray"] as! [Double], [1, 2, 3]) + XCTAssertEqual((tags["$opt_event_properties"] as! [String : Any])["boolAray"] as! [Bool], [false, true, false, true]) + + + } + + func testEventTagsWithRevenueAndValue_toJSON() { // valid revenue/value types diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift index fff345ea..210b2ffb 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift @@ -158,17 +158,17 @@ class DecisionServiceTests_Experiments: XCTestCase { ], [ "id": kAudienceIdExactInvalidValue, - "conditions": [ "type": "custom_attribute", "name": "age", "match": "exact", "value": ["invalid"] ], + "conditions": [ "type": "custom_attribute", "name": "age", "match": "exact", "value": ["invalid" : nil] ], "name": "age" ], [ "id": kAudienceIdGtInvalidValue, - "conditions": [ "type": "custom_attribute", "name": "age", "match": "gt", "value": ["invalid"] ], + "conditions": [ "type": "custom_attribute", "name": "age", "match": "gt", "value": ["invalid" : nil] ], "name": "age" ], [ "id": kAudienceIdLtInvalidValue, - "conditions": [ "type": "custom_attribute", "name": "age", "match": "lt", "value": ["invalid"] ], + "conditions": [ "type": "custom_attribute", "name": "age", "match": "lt", "value": ["invalid" : nil] ], "name": "age" ], [ @@ -565,7 +565,7 @@ extension DecisionServiceTests_Experiments { } func testDoesMeetAudienceConditionsWithExactMatchAndInvalidValue() { - MockLogger.expectedLog = OptimizelyError.evaluateAttributeInvalidCondition("{\"match\":\"exact\",\"value\":{},\"name\":\"age\",\"type\":\"custom_attribute\"}").localizedDescription + MockLogger.expectedLog = OptimizelyError.evaluateAttributeInvalidCondition("{\"match\":\"exact\",\"value\":{\"invalid\":{}},\"name\":\"age\",\"type\":\"custom_attribute\"}").localizedDescription self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) experiment = try! OTUtils.model(from: sampleExperimentData) @@ -575,8 +575,6 @@ extension DecisionServiceTests_Experiments { result = self.decisionService.doesMeetAudienceConditions(config: config, experiment: experiment, user: OTUtils.user(userId: kUserId, attributes: kAttributesAgeMatch)).result - - XCTAssert(MockLogger.logFound) XCTAssertFalse(result) } @@ -613,7 +611,7 @@ extension DecisionServiceTests_Experiments { } func testDoesMeetAudienceConditionsWithGreaterMatchAndInvalidValue() { - MockLogger.expectedLog = OptimizelyError.evaluateAttributeInvalidCondition("{\"match\":\"gt\",\"value\":{},\"name\":\"age\",\"type\":\"custom_attribute\"}").localizedDescription + MockLogger.expectedLog = OptimizelyError.evaluateAttributeInvalidCondition("{\"match\":\"gt\",\"value\":{\"invalid\":{}},\"name\":\"age\",\"type\":\"custom_attribute\"}").localizedDescription self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) experiment = try! OTUtils.model(from: sampleExperimentData) @@ -623,7 +621,6 @@ extension DecisionServiceTests_Experiments { result = self.decisionService.doesMeetAudienceConditions(config: config, experiment: experiment, user: OTUtils.user(userId: kUserId, attributes: kAttributesAgeMatch)).result - XCTAssert(MockLogger.logFound) XCTAssertFalse(result) } @@ -645,7 +642,7 @@ extension DecisionServiceTests_Experiments { } func testDoesMeetAudienceConditionsWithLessMatchAndInvalidValue() { - MockLogger.expectedLog = OptimizelyError.evaluateAttributeInvalidCondition("{\"match\":\"lt\",\"value\":{},\"name\":\"age\",\"type\":\"custom_attribute\"}").localizedDescription + MockLogger.expectedLog = OptimizelyError.evaluateAttributeInvalidCondition("{\"match\":\"lt\",\"value\":{\"invalid\":{}},\"name\":\"age\",\"type\":\"custom_attribute\"}").localizedDescription self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) experiment = try! OTUtils.model(from: sampleExperimentData) @@ -655,7 +652,6 @@ extension DecisionServiceTests_Experiments { result = self.decisionService.doesMeetAudienceConditions(config: config, experiment: experiment, user: OTUtils.user(userId: kUserId, attributes: kAttributesAgeMatch)).result - XCTAssert(MockLogger.logFound) XCTAssertFalse(result) } diff --git a/Tests/OptimizelyTests-DataModel/AttributeValueTests.swift b/Tests/OptimizelyTests-DataModel/AttributeValueTests.swift index 402d7088..0c466712 100644 --- a/Tests/OptimizelyTests-DataModel/AttributeValueTests.swift +++ b/Tests/OptimizelyTests-DataModel/AttributeValueTests.swift @@ -128,15 +128,27 @@ class AttributeValueTests: XCTestCase { XCTAssert(model2 == AttributeValue.int(Int64(value))) } - func testDecodeSuccessWithInvalidType() { - let value = ["invalid type"] + func testDecodeSuccessWithArrayType() { + let value = ["array type"] let model = try! OTUtils.getAttributeValueFromNative(value) - XCTAssert(model == AttributeValue.others) - let model2 = AttributeValue(value: value) - XCTAssertNil(model2) + XCTAssertEqual(model, model2) + } + + func testEncodeDecodeWithDictionaryType() { + let value: [String: Any] = [ + "string": "stringvalue", + "double": 13.0, + "bool": true, + "array": ["a", "b", "c"] + ] + let model = AttributeValue(value: value) + + let encoded = try! OTUtils.getAttributeValueFromNative(value) + print("hello") + XCTAssertEqual(encoded, model) } func testDecodeSuccessWithInvalidTypeNil() { @@ -275,7 +287,7 @@ extension AttributeValueTests { } func testEncodeJSON5() { - let modelGiven = [AttributeValue.others] + let modelGiven = [AttributeValue.array([AttributeValue.bool(true), AttributeValue.string("us"), AttributeValue.double(4.7)])] XCTAssert(OTUtils.isEqualWithEncodeThenDecode(modelGiven)) } @@ -301,18 +313,17 @@ extension AttributeValueTests { XCTAssert(model == AttributeValue.bool(valueBool)) XCTAssert(model.description == "bool(\(valueBool))") - let valueOther = [3] - model = try! OTUtils.getAttributeValueFromNative(valueOther) - XCTAssert(model == AttributeValue.others) - XCTAssert(model.description == "others") - + let values = [3.0] + model = try! OTUtils.getAttributeValueFromNative(values) + XCTAssert(model == AttributeValue(value: values)) + XCTAssert(model.description == "array([double(3.0)])") let valueInteger = Int64(100) model = AttributeValue(value: valueInteger)! XCTAssert(model.description == "int(\(valueInteger))") - let modelOptional = AttributeValue(value: valueOther) - XCTAssertNil(modelOptional) + let modelOptional = AttributeValue(value: values) + XCTAssertNotNil(modelOptional) } func testStringValue() { diff --git a/Tests/OptimizelyTests-DataModel/UserAttributeTests.swift b/Tests/OptimizelyTests-DataModel/UserAttributeTests.swift index e0ba6382..57022006 100644 --- a/Tests/OptimizelyTests-DataModel/UserAttributeTests.swift +++ b/Tests/OptimizelyTests-DataModel/UserAttributeTests.swift @@ -116,7 +116,7 @@ extension UserAttributeTests { XCTAssert(model.matchSupported == .exact) } - func testDecodeSuccessWithWrongValueType() { + func testDecodeSuccessWithArrayValueType() { let json: [String: Any] = ["name": "geo", "type": "custom_attribute", "match": "exact", "value": ["a1", "a2"]] let jsonData = try! JSONSerialization.data(withJSONObject: json, options: []) let model = try! JSONDecoder().decode(modelType, from: jsonData) @@ -124,7 +124,7 @@ extension UserAttributeTests { XCTAssert(model.name == "geo") XCTAssert(model.typeSupported == .customAttribute) XCTAssert(model.matchSupported == .exact) - XCTAssert(model.value == .others) + XCTAssert(model.value == AttributeValue(value: ["a1", "a2"])) } // MARK: - Forward Compatibility From 1311d22ffb86e49018579bee84e21b5015e67a26 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Tue, 4 Feb 2025 23:15:07 +0600 Subject: [PATCH 18/37] [FSSDK-11036] chore: revert workflow branch to master (#572) * Update swift.yml * Update unit_tests.yml --- .github/workflows/swift.yml | 4 ++-- .github/workflows/unit_tests.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 869fb226..3186aa2b 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -26,7 +26,7 @@ jobs: integration_tests: if: "${{ github.event.inputs.PREP == '' && github.event.inputs.RELEASE == '' }}" - uses: optimizely/swift-sdk/.github/workflows/integration_tests.yml@muzahid/fix-nested-event-tag + uses: optimizely/swift-sdk/.github/workflows/integration_tests.yml@master secrets: CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} @@ -47,7 +47,7 @@ jobs: unittests: if: "${{ github.event.inputs.PREP == '' && github.event.inputs.RELEASE == '' }}" - uses: optimizely/swift-sdk/.github/workflows/unit_tests.yml@muzahid/fix-nested-event-tag + uses: optimizely/swift-sdk/.github/workflows/unit_tests.yml@master prepare_for_release: runs-on: macos-13 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index e6064af3..81bc88af 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -85,7 +85,7 @@ jobs: # - to find pre-installed xcode version, run this: ##ls /Applications/ # - to find supported simulator os versions, run this (and find simulator with non-error "datapath") - xcrun simctl list --json devices + # xcrun simctl list --json devices # switch to the target xcode version sudo xcode-select -switch /Applications/Xcode_$SIMULATOR_XCODE_VERSION.app From a11ba4c73665f51b94f28a8ba43937b5c8a45ed5 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Wed, 5 Feb 2025 19:04:19 +0600 Subject: [PATCH 19/37] [FSSDK-11111] chore: prepare release 5.1.0 (#574) * prepare release 5.0.1 --- .github/workflows/swift.yml | 2 +- CHANGELOG.md | 6 ++++++ README.md | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 3186aa2b..1fc4011b 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -17,7 +17,7 @@ on: description: release env: - VERSION: 5.0.0 + VERSION: 5.1.0 jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index d09e6c11..1c9af189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Optimizely Swift SDK Changelog +## 5.1.0 +February 4th, 2025 + +### Functionality Enhancement +* Add support for nested event tags ([#570](https://github.com/optimizely/swift-sdk/pull/570)). + ## 5.0.0 November 25th, 2024 diff --git a/README.md b/README.md index 858f6295..2c89721e 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ If you have a name conflict with other swift packages when you add the Optimizel #### CocoaPods 1. Add the following lines to the _Podfile_:
 ```use_frameworks!```
-```pod 'OptimizelySwiftSDK', '~> 5.0.0'```
+```pod 'OptimizelySwiftSDK', '~> 5.1.0'```
 
2. Run the following command:
``` pod install ```
From 2fe28f067a5afa6880213a0550d4680d7a246bc2 Mon Sep 17 00:00:00 2001 From: optibot Date: Wed, 5 Feb 2025 16:09:06 +0100 Subject: [PATCH 20/37] ci(git-action): auto release prep for 5.1.0 (#575) Co-authored-by: optibot --- OptimizelySwiftSDK.podspec | 2 +- Sources/Utils/SDKVersion.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/OptimizelySwiftSDK.podspec b/OptimizelySwiftSDK.podspec index 415331d0..14d38f88 100644 --- a/OptimizelySwiftSDK.podspec +++ b/OptimizelySwiftSDK.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "OptimizelySwiftSDK" s.module_name = "Optimizely" - s.version = "5.0.0" + s.version = "5.1.0" s.summary = "Optimizely experiment framework for iOS/tvOS/watchOS" s.homepage = "https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs" s.license = { :type => "Apache License, Version 2.0", :file => "LICENSE" } diff --git a/Sources/Utils/SDKVersion.swift b/Sources/Utils/SDKVersion.swift index fd4f77d4..1caaec32 100644 --- a/Sources/Utils/SDKVersion.swift +++ b/Sources/Utils/SDKVersion.swift @@ -17,4 +17,4 @@ /// Do not edit this field. /// - It is auto updated (Scripts/updated_version.sh) to reflect the current version /// - Do not put underscores in the name (Swiftlint can modify unexpectedly) -let OPTIMIZELYSDKVERSION = "5.0.0" +let OPTIMIZELYSDKVERSION = "5.1.0" From 64d659f7a1eda58d0086a64786c73a82cda088f3 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Mon, 10 Feb 2025 23:32:26 +0600 Subject: [PATCH 21/37] [FSSDK-11102] chore: replace travis-ci-tools repository (#571) * Update integration_tests.yml * remove TRAVIS_COM_TOKEN * Update yml * update yml * update readme * Branch updated * Update swift.yml * test ci * update integration test yml * update swift yml * update yml * update yml * update branch name * clean up * trigger ci * trigger ci * remove token * clean up * clean up * clean up --- .github/workflows/integration_tests.yml | 12 +++--------- .github/workflows/swift.yml | 1 - README.md | 2 +- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 1064e691..e0401f44 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -5,9 +5,6 @@ on: secrets: CI_USER_TOKEN: required: true - TRAVIS_COM_TOKEN: - required: true - jobs: integration_tests: runs-on: ubuntu-latest @@ -16,8 +13,8 @@ jobs: with: # You should create a personal access token and store it in your repository token: ${{ secrets.CI_USER_TOKEN }} - repository: 'optimizely/travisci-tools' - path: 'home/runner/travisci-tools' + repository: 'optimizely/ci-helper-tools' + path: 'home/runner/ci-helper-tools' ref: 'master' - name: set SDK Branch if PR env: @@ -31,7 +28,6 @@ jobs: if: ${{ github.event_name != 'pull_request' }} run: | echo "SDK_BRANCH=$REF_NAME" >> $GITHUB_ENV - echo "TRAVIS_BRANCH=$REF_NAME" >> $GITHUB_ENV - name: Trigger build env: SDK: swift @@ -41,14 +37,12 @@ jobs: GITHUB_TOKEN: ${{ secrets.CI_USER_TOKEN }} EVENT_TYPE: ${{ github.event_name }} GITHUB_CONTEXT: ${{ toJson(github) }} - #REPO_SLUG: ${{ github.repository }} PULL_REQUEST_SLUG: ${{ github.repository }} UPSTREAM_REPO: ${{ github.repository }} PULL_REQUEST_SHA: ${{ github.event.pull_request.head.sha }} PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} UPSTREAM_SHA: ${{ github.sha }} - TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} EVENT_MESSAGE: ${{ github.event.message }} HOME: 'home/runner' run: | - home/runner/travisci-tools/trigger-script-with-status-update.sh + home/runner/ci-helper-tools/trigger-script-with-status-update.sh diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 1fc4011b..23c5cd24 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -29,7 +29,6 @@ jobs: uses: optimizely/swift-sdk/.github/workflows/integration_tests.yml@master secrets: CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} - TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }} lint: runs-on: macos-13 diff --git a/README.md b/README.md index 2c89721e..826c3385 100644 --- a/README.md +++ b/README.md @@ -129,4 +129,4 @@ Used to enforce Swift style and conventions. - React - https://github.com/optimizely/react-sdk -- Ruby - https://github.com/optimizely/ruby-sdk +- Ruby - https://github.com/optimizely/ruby-sdk \ No newline at end of file From e5a0f8ef04b02e845084e38f6a23fb1ffb2e7267 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Mon, 3 Mar 2025 20:58:14 +0600 Subject: [PATCH 22/37] [FSSDK-11111] chore: update cocoapods version (#576) * Update cocoapods version --- .github/workflows/swift.yml | 7 +++---- .github/workflows/unit_tests.yml | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 23c5cd24..9652d5d1 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -40,14 +40,13 @@ jobs: - env: SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }} run: | - gem install cocoapods -v '1.9.3' + gem install cocoapods -v '1.15.2' pod spec lint --quick curl -sSL https://download.sourceclear.com/ci.sh | bash unittests: if: "${{ github.event.inputs.PREP == '' && github.event.inputs.RELEASE == '' }}" uses: optimizely/swift-sdk/.github/workflows/unit_tests.yml@master - prepare_for_release: runs-on: macos-13 if: "${{ github.event.inputs.PREP == 'true' && github.event_name == 'workflow_dispatch' }}" @@ -68,7 +67,7 @@ jobs: BRANCH: ${{ github.ref_name }} GITHUB_USER: optibot GITHUB_TOKEN: ${{ secrets.CI_USER_TOKEN }} - COCOAPODS_VERSION: '1.12.1' + COCOAPODS_VERSION: '1.15.2' run: | gem install cocoapods -v $COCOAPODS_VERSION Scripts/run_prep.sh @@ -95,7 +94,7 @@ jobs: BRANCH: ${{ github.ref_name }} GITHUB_TOKEN: ${{ secrets.CI_USER_TOKEN }} COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} - COCOAPODS_VERSION: '1.12.1' + COCOAPODS_VERSION: '1.15.2' run: | gem install cocoapods -v $COCOAPODS_VERSION Scripts/run_release.sh diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 81bc88af..8ad2e451 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -76,7 +76,7 @@ jobs: NAME: ${{ matrix.device }} run: | gem install coveralls-lcov - gem install cocoapods -v '1.11.3' + gem install cocoapods -v '1.15.2' pod repo update pod install HOMEBREW_NO_INSTALL_CLEANUP=true brew update && brew install jq From 9d635dc64f669b342b26aced845f6b6c1c45deb5 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Mon, 7 Apr 2025 08:17:29 +0600 Subject: [PATCH 23/37] [FSSDK-11371] parsing holdout section from the datafile (#577) - Parse Holdout section --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 76 +++++- Sources/Data Model/Experiment.swift | 67 +---- Sources/Data Model/ExperimentCore.swift | 84 ++++++ Sources/Data Model/Holdout.swift | 84 ++++++ .../HoldoutTests.swift | 253 ++++++++++++++++++ 5 files changed, 497 insertions(+), 67 deletions(-) create mode 100644 Sources/Data Model/ExperimentCore.swift create mode 100644 Sources/Data Model/Holdout.swift create mode 100644 Tests/OptimizelyTests-DataModel/HoldoutTests.swift diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index 3df0de83..9770d72b 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -1978,8 +1978,42 @@ 84F6BAB427FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F6BAB227FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift */; }; 84F6BADD27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F6BADC27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift */; }; 84F6BADE27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F6BADC27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift */; }; + 980CC8F72D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC8F82D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC8F92D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC8FA2D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC8FB2D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC8FC2D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC8FD2D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC8FE2D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC8FF2D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC9002D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC9012D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC9022D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC9032D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC9042D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC9052D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC9062D833F0D00E07D24 /* Holdout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC8F62D833F0D00E07D24 /* Holdout.swift */; }; + 980CC9082D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC9092D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC90A2D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC90B2D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC90C2D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC90D2D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC90E2D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC90F2D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC9102D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC9112D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC9122D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC9132D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC9142D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC9152D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC9162D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; + 980CC9172D833F2800E07D24 /* ExperimentCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980CC9072D833F2800E07D24 /* ExperimentCore.swift */; }; 98137C552A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98137C542A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift */; }; 98137C572A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */; }; + 982C071F2D8C82AE0068B1FF /* HoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */; }; + 982C07202D8C82AE0068B1FF /* HoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */; }; 984E2FDC2B27199B001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; 984E2FDD2B27199C001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; 984E2FDE2B27199D001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; @@ -2436,8 +2470,11 @@ 84E7ABBA27D2A1F100447CAE /* ThreadSafeLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThreadSafeLogger.swift; sourceTree = ""; }; 84F6BAB227FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP.swift; sourceTree = ""; }; 84F6BADC27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP_Decide.swift; sourceTree = ""; }; + 980CC8F62D833F0D00E07D24 /* Holdout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Holdout.swift; sourceTree = ""; }; + 980CC9072D833F2800E07D24 /* ExperimentCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentCore.swift; sourceTree = ""; }; 98137C542A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_Init_Async_Await.swift; sourceTree = ""; }; 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP_Aync_Await.swift; sourceTree = ""; }; + 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutTests.swift; sourceTree = ""; }; 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileTracker.swift; sourceTree = ""; }; 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; BD6485812491474500F30986 /* Optimizely.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Optimizely.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -2821,6 +2858,8 @@ 6E75169122C520D400B2B157 /* TrafficAllocation.swift */, 6E75169222C520D400B2B157 /* Project.swift */, 6E75169322C520D400B2B157 /* Experiment.swift */, + 980CC9072D833F2800E07D24 /* ExperimentCore.swift */, + 980CC8F62D833F0D00E07D24 /* Holdout.swift */, 6E75169422C520D400B2B157 /* FeatureFlag.swift */, 6E75169522C520D400B2B157 /* Group.swift */, 6E75169622C520D400B2B157 /* Variable.swift */, @@ -3012,6 +3051,7 @@ 6E7519A922C5211100B2B157 /* UserAttributeTests.swift */, 6E7519AA22C5211100B2B157 /* GroupTests.swift */, 6E7519AB22C5211100B2B157 /* VariationTests.swift */, + 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */, 6E7519AC22C5211100B2B157 /* ExperimentTests.swift */, 6E7519AD22C5211100B2B157 /* EventTests.swift */, 6E7519AE22C5211100B2B157 /* ConditionHolderTests.swift */, @@ -4209,7 +4249,9 @@ 6E14CD722423F96B00010234 /* OptimizelyClient+ObjC.swift in Sources */, 6E14CDA62423F9C300010234 /* HandlerRegistryService.swift in Sources */, 6E14CD7B2423F98D00010234 /* OPTEventDispatcher.swift in Sources */, + 980CC9022D833F0D00E07D24 /* Holdout.swift in Sources */, 6E14CD902423F9A700010234 /* Variation.swift in Sources */, + 980CC9162D833F2800E07D24 /* ExperimentCore.swift in Sources */, 6E14CD8E2423F9A700010234 /* FeatureVariable.swift in Sources */, 8464087728130D3200CCF97D /* Integration.swift in Sources */, 6E14CD8D2423F9A700010234 /* ProjectConfig.swift in Sources */, @@ -4272,6 +4314,7 @@ 6E424D0326324B620081004A /* AttributeValue.swift in Sources */, 84E2E9482852A378001114AB /* VuidManager.swift in Sources */, 6E424D0426324B620081004A /* ConditionLeaf.swift in Sources */, + 980CC90E2D833F2800E07D24 /* ExperimentCore.swift in Sources */, 6E424D0526324B620081004A /* ConditionHolder.swift in Sources */, 6E424D5226324C4D0081004A /* OptimizelyClient+Decide.swift in Sources */, 6E424D0626324B620081004A /* UserAttribute.swift in Sources */, @@ -4300,6 +4343,7 @@ 6E424D1526324B620081004A /* DataStoreQueueStack.swift in Sources */, 6E424D1626324B620081004A /* OPTDataStore.swift in Sources */, 6E424D2F26324BBA0081004A /* OTUtils.swift in Sources */, + 980CC8F72D833F0D00E07D24 /* Holdout.swift in Sources */, 6E424D1726324B620081004A /* OPTDecisionService.swift in Sources */, 6E424D1826324B620081004A /* OPTBucketer.swift in Sources */, 6E424D1926324B620081004A /* ArrayEventForDispatch+Extension.swift in Sources */, @@ -4358,6 +4402,7 @@ 6EF8DE3224BF7D69008B9488 /* DecisionReasons.swift in Sources */, 6E75192522C520D500B2B157 /* DataStoreQueueStack.swift in Sources */, 6E7516FB22C520D400B2B157 /* OptimizelyLogLevel.swift in Sources */, + 980CC9042D833F0D00E07D24 /* Holdout.swift in Sources */, 84E7ABBC27D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E75184D22C520D400B2B157 /* ProjectConfig.swift in Sources */, 8464087128130D3200CCF97D /* Integration.swift in Sources */, @@ -4404,6 +4449,7 @@ 6E75177F22C520D400B2B157 /* ArrayEventForDispatch+Extension.swift in Sources */, 6E75181122C520D400B2B157 /* DataStoreQueueStackImpl.swift in Sources */, 6EF8DE1B24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, + 980CC90D2D833F2800E07D24 /* ExperimentCore.swift in Sources */, 6E75173722C520D400B2B157 /* MurmurHash3.swift in Sources */, 6E7517F922C520D400B2B157 /* DataStoreUserDefaults.swift in Sources */, C78CAF592445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, @@ -4516,7 +4562,9 @@ 6E75174A22C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 6E7516F622C520D400B2B157 /* OptimizelyError.swift in Sources */, 6E75188422C520D400B2B157 /* TrafficAllocation.swift in Sources */, + 980CC8F92D833F0D00E07D24 /* Holdout.swift in Sources */, 6EA2CC2C2345618E001E7531 /* OptimizelyConfig.swift in Sources */, + 980CC9152D833F2800E07D24 /* ExperimentCore.swift in Sources */, 6E7517D022C520D400B2B157 /* DefaultBucketer.swift in Sources */, 8464087C28130D3200CCF97D /* Integration.swift in Sources */, 6E75180022C520D400B2B157 /* DataStoreUserDefaults.swift in Sources */, @@ -4565,6 +4613,7 @@ 6E75181422C520D400B2B157 /* DataStoreQueueStackImpl.swift in Sources */, 6E593FB625BB9C5500EC72BC /* OptimizelyClientTests_Decide.swift in Sources */, 6E7516C222C520D400B2B157 /* DefaultEventDispatcher.swift in Sources */, + 980CC8FC2D833F0D00E07D24 /* Holdout.swift in Sources */, 848617F22863E21200B7F41B /* OdpEventApiManager.swift in Sources */, 6E75188022C520D400B2B157 /* TrafficAllocation.swift in Sources */, 6E9B11DD22C548A200C22D81 /* OptimizelyClientTests_Valid.swift in Sources */, @@ -4600,6 +4649,7 @@ 6E75194C22C520D500B2B157 /* OPTDatafileHandler.swift in Sources */, 6E652308278E688B00954EA1 /* LruCache.swift in Sources */, 6E7517C022C520D400B2B157 /* DefaultDatafileHandler.swift in Sources */, + 980CC90F2D833F2800E07D24 /* ExperimentCore.swift in Sources */, 6E75183822C520D400B2B157 /* EventForDispatch.swift in Sources */, 6E75175222C520D400B2B157 /* LogMessage.swift in Sources */, 6E9B11DB22C548A200C22D81 /* OptimizelyClientTests_Variables.swift in Sources */, @@ -4702,6 +4752,7 @@ 6E75181722C520D400B2B157 /* DataStoreQueueStackImpl.swift in Sources */, 6E75185322C520D400B2B157 /* ProjectConfig.swift in Sources */, 84E2E94D2852A378001114AB /* VuidManager.swift in Sources */, + 980CC8F82D833F0D00E07D24 /* Holdout.swift in Sources */, 6E75173D22C520D400B2B157 /* MurmurHash3.swift in Sources */, 6E7516E922C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E7518A722C520D400B2B157 /* FeatureFlag.swift in Sources */, @@ -4718,6 +4769,7 @@ 84B4D75B27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */, 6E7517E722C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 6E424BE7263228E90081004A /* AtomicArray.swift in Sources */, + 980CC9102D833F2800E07D24 /* ExperimentCore.swift in Sources */, 6E7516DD22C520D400B2B157 /* OPTUserProfileService.swift in Sources */, 6E75188F22C520D400B2B157 /* Project.swift in Sources */, 6E75195B22C520D500B2B157 /* OPTBucketer.swift in Sources */, @@ -4807,6 +4859,7 @@ 84F6BAB427FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift in Sources */, 6E7516C722C520D400B2B157 /* DefaultEventDispatcher.swift in Sources */, 6E9B11B522C5489600C22D81 /* MockUrlSession.swift in Sources */, + 980CC9012D833F0D00E07D24 /* Holdout.swift in Sources */, 6EF8DE1724BD1BB2008B9488 /* OptimizelyDecision.swift in Sources */, 8428D3D12807337400D0FB0C /* LruCacheTests.swift in Sources */, 84861816286D0B8900B7F41B /* VuidManagerTests.swift in Sources */, @@ -4847,6 +4900,7 @@ 845945C8287758A500D13E11 /* OdpConfig.swift in Sources */, 6E9B116422C5487100C22D81 /* BucketTests_Others.swift in Sources */, 6E7518CD22C520D400B2B157 /* Audience.swift in Sources */, + 980CC90C2D833F2800E07D24 /* ExperimentCore.swift in Sources */, 84E2E96E28540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, 6E9B117322C5487100C22D81 /* BatchEventBuilderTests_Attributes.swift in Sources */, 6E9B11B622C5489600C22D81 /* OTUtils.swift in Sources */, @@ -4924,6 +4978,7 @@ 0B97DD9A249D332C003DE606 /* SemanticVersionTests.swift in Sources */, 6E65230E278E688B00954EA1 /* LruCache.swift in Sources */, 6E9B119C22C5488300C22D81 /* ProjectConfigTests.swift in Sources */, + 980CC8FF2D833F0D00E07D24 /* Holdout.swift in Sources */, 6E7518FE22C520D500B2B157 /* UserAttribute.swift in Sources */, 6E7517F622C520D400B2B157 /* DataStoreMemory.swift in Sources */, 6E9B119322C5488300C22D81 /* AttributeTests.swift in Sources */, @@ -4936,6 +4991,8 @@ 84861809286CF33700B7F41B /* OdpEvent.swift in Sources */, 6E75171022C520D400B2B157 /* OptimizelyClient.swift in Sources */, 6E7516C822C520D400B2B157 /* DefaultEventDispatcher.swift in Sources */, + 982C071F2D8C82AE0068B1FF /* HoldoutTests.swift in Sources */, + 980CC9112D833F2800E07D24 /* ExperimentCore.swift in Sources */, 84E2E9502852A378001114AB /* VuidManager.swift in Sources */, 6E75194622C520D500B2B157 /* OPTDecisionService.swift in Sources */, 6E75185622C520D400B2B157 /* ProjectConfig.swift in Sources */, @@ -5095,7 +5152,7 @@ 6E75171522C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 6E6522E3278E4F3800954EA1 /* OdpManager.swift in Sources */, 6EA2CC272345618E001E7531 /* OptimizelyConfig.swift in Sources */, - 84861815286D0B8900B7F41B /* OdpVuidManagerTests.swift in Sources */, + 84861815286D0B8900B7F41B /* VuidManagerTests.swift in Sources */, 984FE51E2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, C78CAFA724486E0A009FE876 /* OptimizelyJSON+ObjC.swift in Sources */, 6E75185B22C520D400B2B157 /* FeatureVariable.swift in Sources */, @@ -5122,6 +5179,7 @@ 6E0A72D426C5B9AE00FF92B5 /* OptimizelyUserContextTests_ForcedDecisions.swift in Sources */, 6EF41A332522BE1900EAADF1 /* OptimizelyUserContextTests_Decide.swift in Sources */, 848617EF2863E21200B7F41B /* OdpEventApiManager.swift in Sources */, + 980CC9002D833F0D00E07D24 /* Holdout.swift in Sources */, 6E27ECBE266FD78600B4A6D4 /* DecisionReasonsTests.swift in Sources */, 6E9B115E22C5486E00C22D81 /* DataStoreTests.swift in Sources */, 6E4544AF270E67C800F2CEBC /* NetworkReachability.swift in Sources */, @@ -5153,6 +5211,7 @@ 6E7517E322C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 6E75179922C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, 6E9B115C22C5486E00C22D81 /* DatafileHandlerTests.swift in Sources */, + 980CC9082D833F2800E07D24 /* ExperimentCore.swift in Sources */, 6E9B114D22C5486E00C22D81 /* BatchEventBuilderTests_Events.swift in Sources */, 6E75188B22C520D400B2B157 /* Project.swift in Sources */, 6E75187F22C520D400B2B157 /* TrafficAllocation.swift in Sources */, @@ -5193,6 +5252,7 @@ 0B97DD99249D332C003DE606 /* SemanticVersionTests.swift in Sources */, 6E652309278E688B00954EA1 /* LruCache.swift in Sources */, 6E9B118622C5488100C22D81 /* ProjectConfigTests.swift in Sources */, + 980CC9032D833F0D00E07D24 /* Holdout.swift in Sources */, 6E7518F922C520D500B2B157 /* UserAttribute.swift in Sources */, 6E7517F122C520D400B2B157 /* DataStoreMemory.swift in Sources */, 6E9B117D22C5488100C22D81 /* AttributeTests.swift in Sources */, @@ -5205,6 +5265,8 @@ 84861804286CF33700B7F41B /* OdpEvent.swift in Sources */, 6E75170B22C520D400B2B157 /* OptimizelyClient.swift in Sources */, 6E7516C322C520D400B2B157 /* DefaultEventDispatcher.swift in Sources */, + 982C07202D8C82AE0068B1FF /* HoldoutTests.swift in Sources */, + 980CC9172D833F2800E07D24 /* ExperimentCore.swift in Sources */, 84E2E94B2852A378001114AB /* VuidManager.swift in Sources */, 6E75194122C520D500B2B157 /* OPTDecisionService.swift in Sources */, 6E75185122C520D400B2B157 /* ProjectConfig.swift in Sources */, @@ -5327,6 +5389,7 @@ 6EC6DD4924ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */, 6E75188222C520D400B2B157 /* TrafficAllocation.swift in Sources */, 6E5D12252638DDF4000ABFC3 /* MockEventDispatcher.swift in Sources */, + 980CC9142D833F2800E07D24 /* ExperimentCore.swift in Sources */, 848617F42863E21200B7F41B /* OdpEventApiManager.swift in Sources */, 6ECB60D0234D5D9C00016D41 /* OptimizelyConfig+ObjC.swift in Sources */, 6E9B11E322C548AF00C22D81 /* ThrowableConditionListTest.swift in Sources */, @@ -5343,6 +5406,7 @@ 6E7516E822C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E75191222C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, 6E7518E222C520D400B2B157 /* ConditionLeaf.swift in Sources */, + 980CC9062D833F0D00E07D24 /* Holdout.swift in Sources */, 6E75182E22C520D400B2B157 /* BatchEvent.swift in Sources */, 6E7516DC22C520D400B2B157 /* OPTUserProfileService.swift in Sources */, 6E75182222C520D400B2B157 /* BatchEventBuilder.swift in Sources */, @@ -5429,6 +5493,7 @@ 6EC6DD4E24ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */, 6E75188722C520D400B2B157 /* TrafficAllocation.swift in Sources */, 6E5D122A2638DDF4000ABFC3 /* MockEventDispatcher.swift in Sources */, + 980CC9122D833F2800E07D24 /* ExperimentCore.swift in Sources */, 848617F92863E21200B7F41B /* OdpEventApiManager.swift in Sources */, 6ECB60D5234D5D9C00016D41 /* OptimizelyConfig+ObjC.swift in Sources */, 6E9B11E522C548B100C22D81 /* ThrowableConditionListTest.swift in Sources */, @@ -5445,6 +5510,7 @@ 6E7516ED22C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E75191722C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, 6E7518E722C520D400B2B157 /* ConditionLeaf.swift in Sources */, + 980CC8FD2D833F0D00E07D24 /* Holdout.swift in Sources */, 6E75183322C520D400B2B157 /* BatchEvent.swift in Sources */, 6E7516E122C520D400B2B157 /* OPTUserProfileService.swift in Sources */, 6E75182722C520D400B2B157 /* BatchEventBuilder.swift in Sources */, @@ -5518,6 +5584,7 @@ 6EF8DE3124BF7D69008B9488 /* DecisionReasons.swift in Sources */, 6E7517BC22C520D400B2B157 /* DefaultDatafileHandler.swift in Sources */, 6E7516CA22C520D400B2B157 /* OPTLogger.swift in Sources */, + 980CC9052D833F0D00E07D24 /* Holdout.swift in Sources */, 84E7ABBB27D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E75182822C520D400B2B157 /* BatchEvent.swift in Sources */, 8464087028130D3200CCF97D /* Integration.swift in Sources */, @@ -5564,6 +5631,7 @@ 6E75173622C520D400B2B157 /* MurmurHash3.swift in Sources */, 6E75185822C520D400B2B157 /* FeatureVariable.swift in Sources */, 6EF8DE1A24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, + 980CC90B2D833F2800E07D24 /* ExperimentCore.swift in Sources */, 6E75186422C520D400B2B157 /* Rollout.swift in Sources */, 6E75179622C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, C78CAF582445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, @@ -5676,7 +5744,9 @@ 6E75174422C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 6E7516F022C520D400B2B157 /* OptimizelyError.swift in Sources */, 6E75187E22C520D400B2B157 /* TrafficAllocation.swift in Sources */, + 980CC8FA2D833F0D00E07D24 /* Holdout.swift in Sources */, 6EA2CC262345618E001E7531 /* OptimizelyConfig.swift in Sources */, + 980CC90A2D833F2800E07D24 /* ExperimentCore.swift in Sources */, 6E7517CA22C520D400B2B157 /* DefaultBucketer.swift in Sources */, 8464087428130D3200CCF97D /* Integration.swift in Sources */, 6E7517FA22C520D400B2B157 /* DataStoreUserDefaults.swift in Sources */, @@ -5707,6 +5777,7 @@ 8464087328130D3200CCF97D /* Integration.swift in Sources */, 75C71A0025E454460084187E /* OptimizelyError.swift in Sources */, 75C71A0125E454460084187E /* OptimizelyLogLevel.swift in Sources */, + 980CC8FB2D833F0D00E07D24 /* Holdout.swift in Sources */, 75C71A0225E454460084187E /* OptimizelyClient.swift in Sources */, 75C71A0325E454460084187E /* OptimizelyClient+ObjC.swift in Sources */, 75C71A0425E454460084187E /* OptimizelyResult.swift in Sources */, @@ -5748,6 +5819,7 @@ 75C71A2025E454460084187E /* BatchEvent.swift in Sources */, 75C71A2125E454460084187E /* EventForDispatch.swift in Sources */, 75C71A2225E454460084187E /* SemanticVersion.swift in Sources */, + 980CC9092D833F2800E07D24 /* ExperimentCore.swift in Sources */, 75C71A2325E454460084187E /* Audience.swift in Sources */, 84E2E9452852A378001114AB /* VuidManager.swift in Sources */, 6E6522E1278E4F3800954EA1 /* OdpManager.swift in Sources */, @@ -5810,6 +5882,7 @@ 6EF8DE3324BF7D69008B9488 /* DecisionReasons.swift in Sources */, BD6485432491474500F30986 /* DefaultDatafileHandler.swift in Sources */, BD6485442491474500F30986 /* OPTLogger.swift in Sources */, + 980CC8FE2D833F0D00E07D24 /* Holdout.swift in Sources */, 84E7ABBD27D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, BD6485452491474500F30986 /* BatchEvent.swift in Sources */, 8464087228130D3200CCF97D /* Integration.swift in Sources */, @@ -5856,6 +5929,7 @@ BD6485622491474500F30986 /* MurmurHash3.swift in Sources */, BD6485632491474500F30986 /* FeatureVariable.swift in Sources */, 6EF8DE1C24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, + 980CC9132D833F2800E07D24 /* ExperimentCore.swift in Sources */, BD6485642491474500F30986 /* Rollout.swift in Sources */, BD6485652491474500F30986 /* DataStoreQueueStackImpl+Extension.swift in Sources */, BD6485662491474500F30986 /* OptimizelyJSON.swift in Sources */, diff --git a/Sources/Data Model/Experiment.swift b/Sources/Data Model/Experiment.swift index 415bcb98..ab75730e 100644 --- a/Sources/Data Model/Experiment.swift +++ b/Sources/Data Model/Experiment.swift @@ -16,7 +16,7 @@ import Foundation -struct Experiment: Codable, OptimizelyExperiment { +struct Experiment: Codable, ExperimentCore { enum Status: String, Codable { case running = "Running" case launched = "Launched" @@ -64,74 +64,9 @@ extension Experiment: Equatable { // MARK: - Utils extension Experiment { - func getVariation(id: String) -> Variation? { - return variations.filter { $0.id == id }.first - } - - func getVariation(key: String) -> Variation? { - return variations.filter { $0.key == key }.first - } var isActivated: Bool { return status == .running } - mutating func serializeAudiences(with audiencesMap: [String: String]) { - guard let conditions = audienceConditions else { return } - - let serialized = conditions.serialized - audiences = replaceAudienceIdsWithNames(string: serialized, audiencesMap: audiencesMap) - } - - /// Replace audience ids with audience names - /// - /// example: - /// - string: "(AUDIENCE(1) OR AUDIENCE(2)) AND AUDIENCE(3)" - /// - replaced: "(\"us\" OR \"female\") AND \"adult\"" - /// - /// - Parameter string: before replacement - /// - Returns: string after replacement - func replaceAudienceIdsWithNames(string: String, audiencesMap: [String: String]) -> String { - let beginWord = "AUDIENCE(" - let endWord = ")" - var keyIdx = 0 - var audienceId = "" - var collect = false - - var replaced = "" - for ch in string { - // extract audience id in parenthesis (example: AUDIENCE("35") => "35") - if collect { - if String(ch) == endWord { - // output the extracted audienceId - replaced += "\"\(audiencesMap[audienceId] ?? audienceId)\"" - collect = false - audienceId = "" - } else { - audienceId += String(ch) - } - continue - } - - // walk-through until finding a matching keyword "AUDIENCE(" - if ch == Array(beginWord)[keyIdx] { - keyIdx += 1 - if keyIdx == beginWord.count { - keyIdx = 0 - collect = true - } - continue - } else { - if keyIdx > 0 { - replaced += Array(beginWord)[.. Variation? { + return variations.filter { $0.id == id }.first + } + + func getVariation(key: String) -> Variation? { + return variations.filter { $0.key == key }.first + } + + func replaceAudienceIdsWithNames(string: String, audiencesMap: [String: String]) -> String { + let beginWord = "AUDIENCE(" + let endWord = ")" + var keyIdx = 0 + var audienceId = "" + var collect = false + + var replaced = "" + for ch in string { + if collect { + if String(ch) == endWord { + replaced += "\"\(audiencesMap[audienceId] ?? audienceId)\"" + collect = false + audienceId = "" + } else { + audienceId += String(ch) + } + continue + } + + if ch == Array(beginWord)[keyIdx] { + keyIdx += 1 + if keyIdx == beginWord.count { + keyIdx = 0 + collect = true + } + continue + } else { + if keyIdx > 0 { + replaced += Array(beginWord)[.. Bool { + return lhs.id == rhs.id && + lhs.key == rhs.key && + lhs.status == rhs.status && + lhs.layerId == rhs.layerId && + lhs.variations == rhs.variations && + lhs.trafficAllocation == rhs.trafficAllocation && + lhs.audienceIds == rhs.audienceIds && + lhs.audienceConditions == rhs.audienceConditions && + lhs.includedFlags == rhs.includedFlags && + lhs.excludedFlags == rhs.excludedFlags + } +} + + +extension Holdout { + var isActivated: Bool { + return status == .running + } +} diff --git a/Tests/OptimizelyTests-DataModel/HoldoutTests.swift b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift new file mode 100644 index 00000000..b065165b --- /dev/null +++ b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift @@ -0,0 +1,253 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + + +import XCTest + +// MARK: - Sample Data + +class HoldoutTests: XCTestCase { + /// Global holoout without included and excluded key + static var sampleData: [String: Any] = ["id": "11111", + "key": "background", + "status": "Running", + "layerId": "22222", + "variations": [VariationTests.sampleData], + "trafficAllocation": [TrafficAllocationTests.sampleData], + "audienceIds": ["33333"], + "audienceConditions": ConditionHolderTests.sampleData] + +} + +// MARK: - Decode + +extension HoldoutTests { + + func testDecodeSuccessWithJSONValid() { + let data: [String: Any] = HoldoutTests.sampleData + let model: Holdout = try! OTUtils.model(from: data) + + XCTAssert(model.id == "11111") + XCTAssert(model.key == "background") + XCTAssert(model.status == .running) + XCTAssert(model.layerId == "22222") + XCTAssert(model.variations == [try! OTUtils.model(from: VariationTests.sampleData)]) + XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: TrafficAllocationTests.sampleData)]) + XCTAssert(model.audienceIds == ["33333"]) + XCTAssert(model.audienceConditions == (try! OTUtils.model(from: ConditionHolderTests.sampleData))) + } + + func testDecodeSuccessWithIncludedFlags() { + var data: [String: Any] = HoldoutTests.sampleData + data["includedFlags"] = ["4444", "5555"] + + let model: Holdout = try! OTUtils.model(from: data) + + XCTAssert(model.id == "11111") + XCTAssert(model.key == "background") + XCTAssert(model.status == .running) + XCTAssert(model.layerId == "22222") + XCTAssert(model.variations == [try! OTUtils.model(from: VariationTests.sampleData)]) + XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: TrafficAllocationTests.sampleData)]) + XCTAssert(model.audienceIds == ["33333"]) + XCTAssert(model.audienceConditions == (try! OTUtils.model(from: ConditionHolderTests.sampleData))) + XCTAssertEqual(model.includedFlags, ["4444", "5555"]) + } + + func testDecodeSuccessWithExcludedFlags() { + var data: [String: Any] = HoldoutTests.sampleData + data["excludedFlags"] = ["4444", "5555"] + + let model: Holdout = try! OTUtils.model(from: data) + + XCTAssert(model.id == "11111") + XCTAssert(model.key == "background") + XCTAssert(model.status == .running) + XCTAssert(model.layerId == "22222") + XCTAssert(model.variations == [try! OTUtils.model(from: VariationTests.sampleData)]) + XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: TrafficAllocationTests.sampleData)]) + XCTAssert(model.audienceIds == ["33333"]) + XCTAssert(model.audienceConditions == (try! OTUtils.model(from: ConditionHolderTests.sampleData))) + XCTAssertEqual(model.excludedFlags, ["4444", "5555"]) + } + + + func testDecodeSuccessWithMissingAudienceConditions() { + var data: [String: Any] = HoldoutTests.sampleData + data["audienceConditions"] = nil + + let model: Holdout = try! OTUtils.model(from: data) + + XCTAssert(model.id == "11111") + XCTAssert(model.key == "background") + XCTAssert(model.status == .running) + XCTAssert(model.layerId == "22222") + XCTAssert(model.variations == [try! OTUtils.model(from: VariationTests.sampleData)]) + XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: TrafficAllocationTests.sampleData)]) + XCTAssert(model.audienceIds == ["33333"]) + } + + func testDecodeFailWithMissingId() { + var data: [String: Any] = HoldoutTests.sampleData + data["id"] = nil + + let model: Holdout? = try? OTUtils.model(from: data) + XCTAssertNil(model) + } + + func testDecodeFailWithMissingKey() { + var data: [String: Any] = HoldoutTests.sampleData + data["key"] = nil + + let model: Holdout? = try? OTUtils.model(from: data) + XCTAssertNil(model) + } + + func testDecodeFailWithMissingStatus() { + var data: [String: Any] = HoldoutTests.sampleData + data["status"] = nil + + let model: Holdout? = try? OTUtils.model(from: data) + XCTAssertNil(model) + } + + func testDecodeFailWithMissingLayerId() { + var data: [String: Any] = HoldoutTests.sampleData + data["layerId"] = nil + + let model: Holdout? = try? OTUtils.model(from: data) + XCTAssertNil(model) + } + + func testDecodeFailWithMissingVariations() { + var data: [String: Any] = HoldoutTests.sampleData + data["variations"] = nil + + let model: Holdout? = try? OTUtils.model(from: data) + XCTAssertNil(model) + } + + func testDecodeFailWithMissingTrafficAllocation() { + var data: [String: Any] = HoldoutTests.sampleData + data["trafficAllocation"] = nil + + let model: Holdout? = try? OTUtils.model(from: data) + XCTAssertNil(model) + } + +} + +// MARK: - Encode + +extension HoldoutTests { + + func testEncodeJSON() { + let data: [String: Any] = HoldoutTests.sampleData + let modelGiven: Holdout = try! OTUtils.model(from: data) + + XCTAssert(OTUtils.isEqualWithEncodeThenDecode(modelGiven)) + } + +} + +// MARK: - audiences serialization + +extension HoldoutTests { + + func testAudiencesSerialization() { + let commonData: [String: Any] = ["id": "11111", + "key": "background", + "status": "Running", + "layerId": "22222", + "variations": [VariationTests.sampleData], + "trafficAllocation": [TrafficAllocationTests.sampleData], + "audienceIds": [], + "audienceConditions": [], + "forcedVariations": ["12345": "1234567890"]] + + let audiencesMap = [ + "1": "us", + "11": "fr", + "2": "female", + "12": "male", + "3": "adult", + "13": "kid" + ] + + let audiencesInput: [Any] = [ + [], + ["or", "1", "2"], + ["and", "1", "2", "3"], + ["not", "1"], + ["or", "1"], + ["and", "1"], + ["1"], + ["1", "2"], + ["and", ["or", "1", "2"], "3"], + ["and", ["or", "1", ["and", "2", "3"]], ["and", "11", ["or", "12", "13"]]], + ["not", ["and", "1", "2"]], + ["or", "1", "100000"], + ["and", "and"] + ] + + let audiencesOutput: [String] = [ + "", + "\"us\" OR \"female\"", + "\"us\" AND \"female\" AND \"adult\"", + "NOT \"us\"", + "\"us\"", + "\"us\"", + "\"us\"", + "\"us\" OR \"female\"", + "(\"us\" OR \"female\") AND \"adult\"", + "(\"us\" OR (\"female\" AND \"adult\")) AND (\"fr\" AND (\"male\" OR \"kid\"))", + "NOT (\"us\" AND \"female\")", + "\"us\" OR \"100000\"", + "" + ] + + for (idx, audience) in audiencesInput.enumerated() { + var data = commonData + data["audienceConditions"] = audience + var model: Holdout = try! OTUtils.model(from: data) + model.serializeAudiences(with: audiencesMap) + XCTAssertEqual(model.audiences, audiencesOutput[idx]) + } + } + +} + +// MARK: - Test Utils + +extension HoldoutTests { + + func testIsActivated() { + let data: [String: Any] = HoldoutTests.sampleData + var model: Holdout = try! OTUtils.model(from: data) + + XCTAssertTrue(model.isActivated) + + let allNotActiveStates: [Holdout.Status] = [.draft, .concluded, .archived] + for status in allNotActiveStates { + model.status = status + XCTAssertFalse(model.isActivated) + } + + model.status = .running + XCTAssertTrue(model.isActivated) + } +} + From 7c4fee747c820da54f445612409db9bb142f8040 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Mon, 21 Apr 2025 19:15:42 +0600 Subject: [PATCH 24/37] [FSSDK-11372] assign holdoutIds to feature flags (#578) * wip: Holdout Model created * wip: add holdout model test cases * wip: assging holdoutids to feature flags * wip: included and excluded flags updated to required * wip: move OptimizelyExperiment compliance to OptmizelyConfig file * wip: fix uint test * wip: make holdouts array empty if no key presented * wip: add id and key to experiement core * wip: clean up * wip: clean up * clean up * wip: address review request * wip: Update holdout sample data * wip: update flag to holdout mapping approach * wip: holdout config struct added * wip: unit tests updated * wip: HoldoutConfig test cases added * wip: fix holdout config tests * clean up: fix test cases * cleanup: add unit test cases * Update Sources/Data Model/HoldoutConfig.swift Co-authored-by: Jae Kim <45045038+jaeopt@users.noreply.github.com> * cleanup: address review changes --------- Co-authored-by: Jae Kim <45045038+jaeopt@users.noreply.github.com> --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 40 +++++ Sources/Data Model/FeatureFlag.swift | 2 + Sources/Data Model/Holdout.swift | 4 +- Sources/Data Model/HoldoutConfig.swift | 118 +++++++++++++ Sources/Data Model/Project.swift | 37 ++++- Sources/Data Model/ProjectConfig.swift | 17 +- .../HoldoutConfigTests.swift | 155 ++++++++++++++++++ .../HoldoutTests.swift | 79 ++++++--- .../ProjectConfigTests.swift | 91 ++++++++++ .../ProjectTests.swift | 12 ++ 10 files changed, 525 insertions(+), 30 deletions(-) create mode 100644 Sources/Data Model/HoldoutConfig.swift create mode 100644 Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index 9770d72b..e9c921b8 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2034,6 +2034,24 @@ 984FE51E2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; 984FE51F2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; 984FE5202CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 98AC97E22DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97E32DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97E42DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97E52DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97E62DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97E72DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97E82DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97E92DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97EA2DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97EB2DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97EC2DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97ED2DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97EE2DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97EF2DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97F02DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97F12DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97F32DAE9685001405DD /* HoldoutConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */; }; + 98AC97F42DAE9685001405DD /* HoldoutConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */; }; BD1C3E8524E4399C0084B4DA /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B97DD93249D327F003DE606 /* SemanticVersion.swift */; }; BD64853C2491474500F30986 /* Optimizely.h in Headers */ = {isa = PBXBuildFile; fileRef = 6E75167A22C520D400B2B157 /* Optimizely.h */; settings = {ATTRIBUTES = (Public, ); }; }; BD64853E2491474500F30986 /* Audience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E75169822C520D400B2B157 /* Audience.swift */; }; @@ -2477,6 +2495,8 @@ 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutTests.swift; sourceTree = ""; }; 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileTracker.swift; sourceTree = ""; }; 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutConfig.swift; sourceTree = ""; }; + 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutConfigTests.swift; sourceTree = ""; }; BD6485812491474500F30986 /* Optimizely.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Optimizely.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyJSON.swift; sourceTree = ""; }; C78CAF652446DB91009FE876 /* OptimizelyClientTests_OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_OptimizelyJSON.swift; sourceTree = ""; }; @@ -2857,6 +2877,7 @@ 6E75169022C520D400B2B157 /* Variation.swift */, 6E75169122C520D400B2B157 /* TrafficAllocation.swift */, 6E75169222C520D400B2B157 /* Project.swift */, + 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */, 6E75169322C520D400B2B157 /* Experiment.swift */, 980CC9072D833F2800E07D24 /* ExperimentCore.swift */, 980CC8F62D833F0D00E07D24 /* Holdout.swift */, @@ -3048,6 +3069,7 @@ 6E7519A622C5211100B2B157 /* AttributeValueTests_Evaluate.swift */, 6E7519A722C5211100B2B157 /* RolloutTests.swift */, 6E7519A822C5211100B2B157 /* ProjectConfigTests.swift */, + 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */, 6E7519A922C5211100B2B157 /* UserAttributeTests.swift */, 6E7519AA22C5211100B2B157 /* GroupTests.swift */, 6E7519AB22C5211100B2B157 /* VariationTests.swift */, @@ -4181,6 +4203,7 @@ 845945C3287758A100D13E11 /* OdpConfig.swift in Sources */, 6E14CD832423F9A100010234 /* DataStoreQueueStackImpl.swift in Sources */, 848617F12863E21200B7F41B /* OdpEventApiManager.swift in Sources */, + 98AC97E52DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E14CD812423F9A100010234 /* DataStoreUserDefaults.swift in Sources */, 6E14CD802423F9A100010234 /* DataStoreMemory.swift in Sources */, 6E14CDA02423F9C300010234 /* OptimizelyClient+Extension.swift in Sources */, @@ -4369,6 +4392,7 @@ 6E424CB926324B1D0081004A /* Constants.swift in Sources */, 6E424CBA26324B1D0081004A /* Notifications.swift in Sources */, 6E424CBB26324B1D0081004A /* MurmurHash3.swift in Sources */, + 98AC97EC2DAE4579001405DD /* HoldoutConfig.swift in Sources */, 8464087628130D3200CCF97D /* Integration.swift in Sources */, 848617CE2863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, 848617F02863E21200B7F41B /* OdpEventApiManager.swift in Sources */, @@ -4410,6 +4434,7 @@ 845945BD2877589E00D13E11 /* OdpConfig.swift in Sources */, 984FE51C2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171322C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, + 98AC97EF2DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E75191922C520D500B2B157 /* OPTNotificationCenter.swift in Sources */, 6E7518A122C520D400B2B157 /* FeatureFlag.swift in Sources */, 6E994B3525A3E6EA00999262 /* DecisionResponse.swift in Sources */, @@ -4494,6 +4519,7 @@ 845945C7287758A300D13E11 /* OdpConfig.swift in Sources */, 6E75175622C520D400B2B157 /* LogMessage.swift in Sources */, 848617F62863E21200B7F41B /* OdpEventApiManager.swift in Sources */, + 98AC97EB2DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E75193822C520D500B2B157 /* OPTDataStore.swift in Sources */, 6E75191422C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, 6E75172622C520D400B2B157 /* OptimizelyResult.swift in Sources */, @@ -4596,6 +4622,7 @@ 6EF8DE2024BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E7517D822C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E75177622C520D400B2B157 /* SDKVersion.swift in Sources */, + 98AC97E32DAE4579001405DD /* HoldoutConfig.swift in Sources */, 84518B1F287665020023F104 /* OptimizelyClientTests_ODP.swift in Sources */, 6E7516FE22C520D400B2B157 /* OptimizelyLogLevel.swift in Sources */, 6E75173A22C520D400B2B157 /* MurmurHash3.swift in Sources */, @@ -4720,6 +4747,7 @@ 6EC6DD4A24ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */, 6E75170122C520D400B2B157 /* OptimizelyLogLevel.swift in Sources */, 8464087B28130D3200CCF97D /* Integration.swift in Sources */, + 98AC97EA2DAE4579001405DD /* HoldoutConfig.swift in Sources */, 84E2E96C28540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, 6EF8DE3A24BF7D69008B9488 /* DecisionReasons.swift in Sources */, 6E7516B922C520D400B2B157 /* DefaultUserProfileService.swift in Sources */, @@ -4866,6 +4894,7 @@ 6E7517C522C520D400B2B157 /* DefaultDatafileHandler.swift in Sources */, 6E75190922C520D500B2B157 /* Attribute.swift in Sources */, 6E75177B22C520D400B2B157 /* SDKVersion.swift in Sources */, + 98AC97F12DAE4579001405DD /* HoldoutConfig.swift in Sources */, 84E7ABC827D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E7E9B562523F8C6009E4426 /* OptimizelyUserContextTests_Decide_Legacy.swift in Sources */, 6EC6DD3C24ABF6990017D296 /* OptimizelyClient+Decide.swift in Sources */, @@ -5035,6 +5064,7 @@ 84E7ABC927D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E9B119122C5488300C22D81 /* EventForDispatchTests.swift in Sources */, 6E7517EA22C520D400B2B157 /* DefaultDecisionService.swift in Sources */, + 98AC97F02DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E75171C22C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 6E7516B022C520D400B2B157 /* DefaultLogger.swift in Sources */, 0B97DD9C249D3735003DE606 /* SemanticVersion.swift in Sources */, @@ -5059,6 +5089,7 @@ 6E8A3D522637408500DAEA13 /* MockDatafileHandler.swift in Sources */, 6E75180E22C520D400B2B157 /* DataStoreFile.swift in Sources */, 6E9B11B822C5489600C22D81 /* OTUtils.swift in Sources */, + 98AC97F42DAE9685001405DD /* HoldoutConfigTests.swift in Sources */, 6E9B119022C5488300C22D81 /* AttributeValueTests.swift in Sources */, 6E994B4025A3E6EA00999262 /* DecisionResponse.swift in Sources */, 84E2E9802855875E001114AB /* OdpEventManager.swift in Sources */, @@ -5159,6 +5190,7 @@ 6E7516B522C520D400B2B157 /* DefaultUserProfileService.swift in Sources */, 6E7516A922C520D400B2B157 /* DefaultLogger.swift in Sources */, 6E7517D722C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, + 98AC97E72DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E75181F22C520D400B2B157 /* BatchEventBuilder.swift in Sources */, 84F6BADD27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift in Sources */, 84E2E9472852A378001114AB /* VuidManager.swift in Sources */, @@ -5309,6 +5341,7 @@ 84E7ABC427D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E9B117B22C5488100C22D81 /* EventForDispatchTests.swift in Sources */, 6E7517E522C520D400B2B157 /* DefaultDecisionService.swift in Sources */, + 98AC97E92DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E75171722C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 6E7516AB22C520D400B2B157 /* DefaultLogger.swift in Sources */, 0B97DD9B249D3733003DE606 /* SemanticVersion.swift in Sources */, @@ -5333,6 +5366,7 @@ 6E8A3D4D2637408500DAEA13 /* MockDatafileHandler.swift in Sources */, 6E75180922C520D400B2B157 /* DataStoreFile.swift in Sources */, 6E9B11AE22C5489300C22D81 /* OTUtils.swift in Sources */, + 98AC97F32DAE9685001405DD /* HoldoutConfigTests.swift in Sources */, 6E9B117A22C5488100C22D81 /* AttributeValueTests.swift in Sources */, 6E994B3B25A3E6EA00999262 /* DecisionResponse.swift in Sources */, 84E2E97B2855875E001114AB /* OdpEventManager.swift in Sources */, @@ -5433,6 +5467,7 @@ 84E7ABC527D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E7518BE22C520D400B2B157 /* Variable.swift in Sources */, 6E7518CA22C520D400B2B157 /* Audience.swift in Sources */, + 98AC97E62DAE4579001405DD /* HoldoutConfig.swift in Sources */, 848617E42863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, 6E75187622C520D400B2B157 /* Variation.swift in Sources */, 6E7517F222C520D400B2B157 /* DataStoreMemory.swift in Sources */, @@ -5537,6 +5572,7 @@ 84E7ABCA27D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E7518C322C520D400B2B157 /* Variable.swift in Sources */, 6E7518CF22C520D400B2B157 /* Audience.swift in Sources */, + 98AC97E42DAE4579001405DD /* HoldoutConfig.swift in Sources */, 848617E92863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, 6E75187B22C520D400B2B157 /* Variation.swift in Sources */, 6E7517F722C520D400B2B157 /* DataStoreMemory.swift in Sources */, @@ -5592,6 +5628,7 @@ 845945BC2877589D00D13E11 /* OdpConfig.swift in Sources */, 984FE5192CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75184022C520D400B2B157 /* Event.swift in Sources */, + 98AC97EE2DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E7516E222C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E7517D422C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E994B3425A3E6EA00999262 /* DecisionResponse.swift in Sources */, @@ -5676,6 +5713,7 @@ 845945C02877589F00D13E11 /* OdpConfig.swift in Sources */, 6E75175022C520D400B2B157 /* LogMessage.swift in Sources */, 848617EE2863E21200B7F41B /* OdpEventApiManager.swift in Sources */, + 98AC97E82DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E75193222C520D500B2B157 /* OPTDataStore.swift in Sources */, 6E75190E22C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, 6E75172022C520D400B2B157 /* OptimizelyResult.swift in Sources */, @@ -5841,6 +5879,7 @@ 75C71A3125E454460084187E /* Group.swift in Sources */, 75C71A3225E454460084187E /* Variable.swift in Sources */, 848617DD2863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, + 98AC97E22DAE4579001405DD /* HoldoutConfig.swift in Sources */, 75C71A3325E454460084187E /* Attribute.swift in Sources */, 75C71A3425E454460084187E /* BackgroundingCallbacks.swift in Sources */, 75C71A3525E454460084187E /* OPTNotificationCenter.swift in Sources */, @@ -5890,6 +5929,7 @@ 845945BE2877589E00D13E11 /* OdpConfig.swift in Sources */, 984FE51A2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, BD6485462491474500F30986 /* Event.swift in Sources */, + 98AC97ED2DAE4579001405DD /* HoldoutConfig.swift in Sources */, BD6485472491474500F30986 /* OPTEventDispatcher.swift in Sources */, BD6485482491474500F30986 /* DefaultNotificationCenter.swift in Sources */, 6E994B3625A3E6EA00999262 /* DecisionResponse.swift in Sources */, diff --git a/Sources/Data Model/FeatureFlag.swift b/Sources/Data Model/FeatureFlag.swift index f0650561..e402825a 100644 --- a/Sources/Data Model/FeatureFlag.swift +++ b/Sources/Data Model/FeatureFlag.swift @@ -35,6 +35,8 @@ struct FeatureFlag: Codable, Equatable, OptimizelyFeature { case variables } +// var holdoutIds: [String] = [] + // MARK: - OptimizelyConfig var experimentsMap: [String: OptimizelyExperiment] = [:] diff --git a/Sources/Data Model/Holdout.swift b/Sources/Data Model/Holdout.swift index 16a24ba0..8a230bc9 100644 --- a/Sources/Data Model/Holdout.swift +++ b/Sources/Data Model/Holdout.swift @@ -39,11 +39,10 @@ struct Holdout: Codable, ExperimentCore { case id, key, status, layerId, variations, trafficAllocation, audienceIds, audienceConditions, includedFlags, excludedFlags } - var variationsMap: [String : OptimizelyVariation] = [:] + var variationsMap: [String: OptimizelyVariation] = [:] // replace with serialized string representation with audience names when ProjectConfig is ready var audiences: String = "" - init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -76,7 +75,6 @@ extension Holdout: Equatable { } } - extension Holdout { var isActivated: Bool { return status == .running diff --git a/Sources/Data Model/HoldoutConfig.swift b/Sources/Data Model/HoldoutConfig.swift new file mode 100644 index 00000000..dae915a4 --- /dev/null +++ b/Sources/Data Model/HoldoutConfig.swift @@ -0,0 +1,118 @@ +// +// Copyright 2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct HoldoutConfig { + var allHoldouts: [Holdout] { + didSet { + updateHoldoutMapping() + } + } + private(set) var global: [Holdout] = [] + private(set) var holdoutIdMap: [String: Holdout] = [:] + private(set) var flagHoldoutsMap: [String: [Holdout]] = [:] + private(set) var includedHoldouts: [String: [Holdout]] = [:] + private(set) var excludedHoldouts: [String: [Holdout]] = [:] + + init(allholdouts: [Holdout] = []) { + self.allHoldouts = allholdouts + updateHoldoutMapping() + } + + /// Updates internal mappings of holdouts including the id map, global list, and per-flag inclusion/exclusion maps. + mutating func updateHoldoutMapping() { + holdoutIdMap = { + var map = [String: Holdout]() + allHoldouts.forEach { map[$0.id] = $0 } + return map + }() + + flagHoldoutsMap = [:] + global = [] + includedHoldouts = [:] + excludedHoldouts = [:] + + for holdout in allHoldouts { + switch (holdout.includedFlags.isEmpty, holdout.excludedFlags.isEmpty) { + case (true, true): + global.append(holdout) + + case (false, _): + holdout.includedFlags.forEach { flagId in + if var existing = includedHoldouts[flagId] { + existing.append(holdout) + includedHoldouts[flagId] = existing + } else { + includedHoldouts[flagId] = [holdout] + } + } + + case (true, false): + global.append(holdout) + + holdout.excludedFlags.forEach { flagId in + if var existing = excludedHoldouts[flagId] { + existing.append(holdout) + excludedHoldouts[flagId] = existing + } else { + excludedHoldouts[flagId] = [holdout] + } + } + } + } + } + + /// Returns the applicable holdouts for the given flag ID by combining global holdouts (excluding any specified) and included holdouts, in that order. + /// Caches the result for future calls. + /// - Parameter id: The flag identifier. + /// - Returns: An array of `Holdout` objects relevant to the given flag. + mutating func getHoldoutForFlag(id: String) -> [Holdout] { + guard !allHoldouts.isEmpty else { return [] } + + // Check cache and return persistent holdouts + if let holdouts = flagHoldoutsMap[id] { + return holdouts + } + + // Prioritize global holdouts first + var activeHoldouts: [Holdout] = [] + + let excluded = excludedHoldouts[id] ?? [] + + if !excluded.isEmpty { + activeHoldouts = global.filter { holdout in + return !excluded.contains(holdout) + } + } else { + activeHoldouts = global + } + + let includedHoldouts = includedHoldouts[id] ?? [] + + activeHoldouts += includedHoldouts + + flagHoldoutsMap[id] = activeHoldouts + + return flagHoldoutsMap[id] ?? [] + } + + /// Get a Holdout object for an Id. + func getHoldout(id: String) -> Holdout? { + return holdoutIdMap[id] + } +} + diff --git a/Sources/Data Model/Project.swift b/Sources/Data Model/Project.swift index c85ad6a2..3a518c25 100644 --- a/Sources/Data Model/Project.swift +++ b/Sources/Data Model/Project.swift @@ -46,7 +46,8 @@ struct Project: Codable, Equatable { var sendFlagDecisions: Bool? var sdkKey: String? var environmentKey: String? - + // Holdouts + var holdouts: [Holdout] let logger = OPTLoggerFactory.getLogger() // Required since logger is not decodable @@ -56,12 +57,42 @@ struct Project: Codable, Equatable { // V3 case anonymizeIP // V4 - case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey + case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey, holdouts + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // V2 + version = try container.decode(String.self, forKey: .version) + projectId = try container.decode(String.self, forKey: .projectId) + experiments = try container.decode([Experiment].self, forKey: .experiments) + audiences = try container.decode([Audience].self, forKey: .audiences) + groups = try container.decode([Group].self, forKey: .groups) + attributes = try container.decode([Attribute].self, forKey: .attributes) + accountId = try container.decode(String.self, forKey: .accountId) + events = try container.decode([Event].self, forKey: .events) + revision = try container.decode(String.self, forKey: .revision) + + // V3 + anonymizeIP = try container.decode(Bool.self, forKey: .anonymizeIP) + + // V4 + rollouts = try container.decode([Rollout].self, forKey: .rollouts) + integrations = try container.decodeIfPresent([Integration].self, forKey: .integrations) + typedAudiences = try container.decodeIfPresent([Audience].self, forKey: .typedAudiences) + featureFlags = try container.decode([FeatureFlag].self, forKey: .featureFlags) + botFiltering = try container.decodeIfPresent(Bool.self, forKey: .botFiltering) + sendFlagDecisions = try container.decodeIfPresent(Bool.self, forKey: .sendFlagDecisions) + sdkKey = try container.decodeIfPresent(String.self, forKey: .sdkKey) + environmentKey = try container.decodeIfPresent(String.self, forKey: .environmentKey) + // Holdouts - defaults to empty array if key is not present + holdouts = try container.decodeIfPresent([Holdout].self, forKey: .holdouts) ?? [] } // Required since logger is not equatable static func == (lhs: Project, rhs: Project) -> Bool { - return lhs.version == rhs.version && lhs.projectId == rhs.projectId && lhs.experiments == rhs.experiments && + return lhs.version == rhs.version && lhs.projectId == rhs.projectId && lhs.experiments == rhs.experiments && lhs.holdouts == rhs.holdouts && lhs.audiences == rhs.audiences && lhs.groups == rhs.groups && lhs.attributes == rhs.attributes && lhs.accountId == rhs.accountId && lhs.events == rhs.events && lhs.revision == rhs.revision && lhs.anonymizeIP == rhs.anonymizeIP && lhs.rollouts == rhs.rollouts && diff --git a/Sources/Data Model/ProjectConfig.swift b/Sources/Data Model/ProjectConfig.swift index c1faa357..28474a77 100644 --- a/Sources/Data Model/ProjectConfig.swift +++ b/Sources/Data Model/ProjectConfig.swift @@ -17,12 +17,12 @@ import Foundation class ProjectConfig { - var project: Project! { didSet { updateProjectDependentProps() } } + let logger = OPTLoggerFactory.getLogger() // local runtime forcedVariations [UserId: [ExperimentId: VariationId]] @@ -40,6 +40,7 @@ class ProjectConfig { var allExperiments = [Experiment]() var flagVariationsMap = [String: [Variation]]() var allSegments = [String]() + var holdoutConfig = HoldoutConfig() // MARK: - Init @@ -66,8 +67,11 @@ class ProjectConfig { init() {} func updateProjectDependentProps() { + self.allExperiments = project.experiments + project.groups.map { $0.experiments }.flatMap { $0 } + holdoutConfig.allHoldouts = project.holdouts + self.experimentKeyMap = { var map = [String: Experiment]() allExperiments.forEach { exp in @@ -155,6 +159,10 @@ class ProjectConfig { } + func getHoldoutForFlag(id: String) -> [Holdout] { + return holdoutConfig.getHoldoutForFlag(id: id) + } + func getAllRulesForFlag(_ flag: FeatureFlag) -> [Experiment] { var rules = flag.experimentIds.compactMap { experimentIdMap[$0] } let rollout = self.rolloutIdMap[flag.rolloutId] @@ -270,6 +278,13 @@ extension ProjectConfig { return rolloutIdMap[id] } + /** + * Get a Holdout object for an Id. + */ + func getHoldout(id: String) -> Holdout? { + return holdoutConfig.getHoldout(id: id) + } + /** * Gets an event for a corresponding event key */ diff --git a/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift b/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift new file mode 100644 index 00000000..b4c7f9ed --- /dev/null +++ b/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift @@ -0,0 +1,155 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class HoldoutConfigTests: XCTestCase { + func testEmptyHoldouts_shouldHaveEmptyMaps() { + let config = HoldoutConfig(allholdouts: []) + + XCTAssertTrue(config.holdoutIdMap.isEmpty) + XCTAssertTrue(config.global.isEmpty) + XCTAssertTrue(config.includedHoldouts.isEmpty) + XCTAssertTrue(config.excludedHoldouts.isEmpty) + } + + func testHoldoutMap() { + let holdout0: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + let holdout1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedFlags) + let holdout2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithExcludedFlags) + + let allHoldouts = [holdout0, holdout1, holdout2] + let holdoutConfig = HoldoutConfig(allholdouts: allHoldouts) + + XCTAssertEqual(holdoutConfig.holdoutIdMap["11111"]?.includedFlags, []) + XCTAssertEqual(holdoutConfig.holdoutIdMap["11111"]?.excludedFlags, []) + + XCTAssertEqual(holdoutConfig.holdoutIdMap["55555"]?.includedFlags, ["4444", "5555"]) + XCTAssertEqual(holdoutConfig.holdoutIdMap["55555"]?.excludedFlags, []) + + XCTAssertEqual(holdoutConfig.holdoutIdMap["3333"]?.includedFlags, []) + XCTAssertEqual(holdoutConfig.holdoutIdMap["3333"]?.excludedFlags, ["8888", "9999"]) + + XCTAssertEqual(holdoutConfig.global, [holdout0, holdout2]) + + XCTAssertEqual(holdoutConfig.includedHoldouts["4444"], [holdout1]) + XCTAssertEqual(holdoutConfig.excludedHoldouts["8888"], [holdout2]) + + } + + func testGetHoldoutById() { + var holdout0: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + holdout0.id = "00000" + var holdout1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedFlags) + holdout1.id = "11111" + var holdout2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithExcludedFlags) + holdout2.id = "22222" + + let allHoldouts = [holdout0, holdout1, holdout2] + let holdoutConfig = HoldoutConfig(allholdouts: allHoldouts) + + XCTAssertEqual(holdoutConfig.getHoldout(id: "00000"), holdout0) + XCTAssertEqual(holdoutConfig.getHoldout(id: "11111"), holdout1) + XCTAssertEqual(holdoutConfig.getHoldout(id: "22222"), holdout2) + + } + + func testHoldoutOrdering_globalThenIncluded() { + var global1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + global1.id = "g1" + + var global2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + global2.id = "g2" + + var included: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + included.id = "i1" + included.includedFlags = ["f"] + + var config = HoldoutConfig(allholdouts: [included, global1, global2]) + + let result = config.getHoldoutForFlag(id: "f").map(\.id) + XCTAssertEqual(result, ["g1", "g2", "i1"]) + } + + func testHoldoutOrdering_with_Both_IncludedAndExcludedFlags() { + let flag1 = "11111" + let flag2 = "22222" + let flag3 = "33333" + let flag4 = "44444" + + var inc: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + inc.id = "i1" + inc.includedFlags = [flag1] + + var exc: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + exc.id = "e1" + exc.excludedFlags = [flag2] + + var gh1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + gh1.id = "gh1" + gh1.includedFlags = [] + gh1.excludedFlags = [] + + var gh2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + gh2.id = "gh2" + gh2.includedFlags = [] + gh2.excludedFlags = [] + + + let allHoldouts = [inc, exc, gh1, gh2] + var holdoutConfig = HoldoutConfig(allholdouts: allHoldouts) + + XCTAssertEqual(holdoutConfig.getHoldoutForFlag(id: flag1), [exc, gh1, gh2, inc]) + XCTAssertEqual(holdoutConfig.getHoldoutForFlag(id: flag2), [gh1, gh2]) + + XCTAssertEqual(holdoutConfig.getHoldoutForFlag(id: flag3), [exc, gh1, gh2]) + XCTAssertEqual(holdoutConfig.getHoldoutForFlag(id: flag4), [exc, gh1, gh2]) + + } + + func testExcludedHoldout_shouldNotAppearInGlobalForFlag() { + var global: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + global.id = "global" + + var excluded: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + excluded.id = "excluded" + excluded.excludedFlags = ["f"] + + var config = HoldoutConfig(allholdouts: [global, excluded]) + + let result = config.getHoldoutForFlag(id: "f").map(\.id) + XCTAssertEqual(result, ["global"]) // excluded should not appear + } + + func testGetHoldoutForFlag_shouldUseCacheOnSecondCall() { + var ho1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + ho1.id = "h1" + ho1.includedFlags = ["f1"] + + var config = HoldoutConfig(allholdouts: [ho1]) + + // Initially no cache + XCTAssertEqual(config.flagHoldoutsMap.count, 0) + + let _ = config.getHoldoutForFlag(id: "f1") + XCTAssertEqual(config.flagHoldoutsMap.count, 1) + + let cache_v = config.getHoldoutForFlag(id: "f1") + XCTAssertEqual(config.flagHoldoutsMap.count, 1) + XCTAssertEqual(cache_v, config.flagHoldoutsMap["f1"]) + } + +} diff --git a/Tests/OptimizelyTests-DataModel/HoldoutTests.swift b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift index b065165b..8e5f6e3a 100644 --- a/Tests/OptimizelyTests-DataModel/HoldoutTests.swift +++ b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift @@ -20,15 +20,49 @@ import XCTest // MARK: - Sample Data class HoldoutTests: XCTestCase { + static var variationData: [String: Any] = ["id": "553339214", + "key": "house", + "featureEnabled": true, + "variables": [["id": "553339214", "value": "100"]]] + + static var trafficAllocationData: [String: Any] = ["entityId": "553339214", "endOfRange": 5000] + + static var conditionHolderData: [Any] = ["or", ["name": "geo", + "type": "custom_attribute", + "match": "exact", + "value": 30]] + /// Global holoout without included and excluded key static var sampleData: [String: Any] = ["id": "11111", "key": "background", "status": "Running", "layerId": "22222", - "variations": [VariationTests.sampleData], - "trafficAllocation": [TrafficAllocationTests.sampleData], + "variations": [HoldoutTests.variationData], + "trafficAllocation": [HoldoutTests.trafficAllocationData], + "audienceIds": ["33333"], + "audienceConditions": HoldoutTests.conditionHolderData] + + static var sampleDataWithIncludedFlags: [String: Any] = ["id": "55555", + "key": "background", + "status": "Running", + "layerId": "22222", + "variations": [HoldoutTests.variationData], + "trafficAllocation": [HoldoutTests.trafficAllocationData], "audienceIds": ["33333"], - "audienceConditions": ConditionHolderTests.sampleData] + "audienceConditions": HoldoutTests.conditionHolderData, + "includedFlags": ["4444", "5555"]] + + static var sampleDataWithExcludedFlags: [String: Any] = ["id": "3333", + "key": "background", + "status": "Running", + "layerId": "22222", + "variations": [HoldoutTests.variationData], + "trafficAllocation": [HoldoutTests.trafficAllocationData], + "audienceIds": ["33333"], + "audienceConditions": HoldoutTests.conditionHolderData, + "excludedFlags": ["8888", "9999"]] + + } @@ -44,44 +78,43 @@ extension HoldoutTests { XCTAssert(model.key == "background") XCTAssert(model.status == .running) XCTAssert(model.layerId == "22222") - XCTAssert(model.variations == [try! OTUtils.model(from: VariationTests.sampleData)]) - XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: TrafficAllocationTests.sampleData)]) + XCTAssert(model.variations == [try! OTUtils.model(from: HoldoutTests.variationData)]) + XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: HoldoutTests.trafficAllocationData)]) XCTAssert(model.audienceIds == ["33333"]) - XCTAssert(model.audienceConditions == (try! OTUtils.model(from: ConditionHolderTests.sampleData))) + XCTAssert(model.audienceConditions == (try! OTUtils.model(from: HoldoutTests.conditionHolderData))) } func testDecodeSuccessWithIncludedFlags() { - var data: [String: Any] = HoldoutTests.sampleData - data["includedFlags"] = ["4444", "5555"] + let data: [String: Any] = HoldoutTests.sampleDataWithIncludedFlags let model: Holdout = try! OTUtils.model(from: data) - XCTAssert(model.id == "11111") + XCTAssert(model.id == "55555") XCTAssert(model.key == "background") XCTAssert(model.status == .running) XCTAssert(model.layerId == "22222") - XCTAssert(model.variations == [try! OTUtils.model(from: VariationTests.sampleData)]) - XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: TrafficAllocationTests.sampleData)]) + XCTAssert(model.variations == [try! OTUtils.model(from: HoldoutTests.variationData)]) + XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: HoldoutTests.trafficAllocationData)]) XCTAssert(model.audienceIds == ["33333"]) - XCTAssert(model.audienceConditions == (try! OTUtils.model(from: ConditionHolderTests.sampleData))) + XCTAssert(model.audienceConditions == (try! OTUtils.model(from: HoldoutTests.conditionHolderData))) XCTAssertEqual(model.includedFlags, ["4444", "5555"]) } func testDecodeSuccessWithExcludedFlags() { - var data: [String: Any] = HoldoutTests.sampleData - data["excludedFlags"] = ["4444", "5555"] + let data: [String: Any] = HoldoutTests.sampleDataWithExcludedFlags let model: Holdout = try! OTUtils.model(from: data) - XCTAssert(model.id == "11111") + XCTAssert(model.id == "3333") XCTAssert(model.key == "background") XCTAssert(model.status == .running) XCTAssert(model.layerId == "22222") - XCTAssert(model.variations == [try! OTUtils.model(from: VariationTests.sampleData)]) - XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: TrafficAllocationTests.sampleData)]) + XCTAssert(model.variations == [try! OTUtils.model(from: HoldoutTests.variationData)]) + XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: HoldoutTests.trafficAllocationData)]) XCTAssert(model.audienceIds == ["33333"]) - XCTAssert(model.audienceConditions == (try! OTUtils.model(from: ConditionHolderTests.sampleData))) - XCTAssertEqual(model.excludedFlags, ["4444", "5555"]) + XCTAssert(model.audienceConditions == (try! OTUtils.model(from: HoldoutTests.conditionHolderData))) + XCTAssertEqual(model.includedFlags, []) + XCTAssertEqual(model.excludedFlags, ["8888", "9999"]) } @@ -95,8 +128,8 @@ extension HoldoutTests { XCTAssert(model.key == "background") XCTAssert(model.status == .running) XCTAssert(model.layerId == "22222") - XCTAssert(model.variations == [try! OTUtils.model(from: VariationTests.sampleData)]) - XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: TrafficAllocationTests.sampleData)]) + XCTAssert(model.variations == [try! OTUtils.model(from: HoldoutTests.variationData)]) + XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: HoldoutTests.trafficAllocationData)]) XCTAssert(model.audienceIds == ["33333"]) } @@ -172,8 +205,8 @@ extension HoldoutTests { "key": "background", "status": "Running", "layerId": "22222", - "variations": [VariationTests.sampleData], - "trafficAllocation": [TrafficAllocationTests.sampleData], + "variations": [HoldoutTests.variationData], + "trafficAllocation": [HoldoutTests.trafficAllocationData], "audienceIds": [], "audienceConditions": [], "forcedVariations": ["12345": "1234567890"]] diff --git a/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift b/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift index aebde55f..9faa1046 100644 --- a/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift +++ b/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift @@ -80,6 +80,97 @@ class ProjectConfigTests: XCTestCase { XCTAssertEqual(featureMap["1004"], ["2002"]) } + func testHoldoutIdMapIsBuiltFromProject() { + var exp0 = ExperimentTests.sampleData + var exp1 = ExperimentTests.sampleData + var exp2 = ExperimentTests.sampleData + var exp3 = ExperimentTests.sampleData + var exp4 = ExperimentTests.sampleData + exp0["id"] = "1000" + exp1["id"] = "1001" + exp2["id"] = "1002" + exp3["id"] = "1003" + exp4["id"] = "1004" + + + var holdout0 = HoldoutTests.sampleData + var holdout1 = HoldoutTests.sampleData + var holdout2 = HoldoutTests.sampleData + var holdout3 = HoldoutTests.sampleData + var holdout4 = HoldoutTests.sampleData + + holdout0["id"] = "3000" // Global holdout (no included or excluded flags) + holdout1["id"] = "3001" // Global holdout (no included or excluded flags) + holdout2["id"] = "3002" // Global holdout (no included or excluded flags) + holdout3["id"] = "3003" // Included flagids ["2000", "2002"] + holdout4["id"] = "3004" // Excluded flagids ["2001"] + + holdout3["includedFlags"] = ["2000", "2002"] + holdout4["excludedFlags"] = ["2001"] + + var feature0 = FeatureFlagTests.sampleData + var feature1 = FeatureFlagTests.sampleData + var feature2 = FeatureFlagTests.sampleData + var feature3 = FeatureFlagTests.sampleData + + feature0["id"] = "2000" + feature0["key"] = "key_2000" + + feature1["id"] = "2001" + feature1["key"] = "key_2001" + + feature2["id"] = "2002" + feature2["key"] = "key_2002" + + feature3["id"] = "2003" + feature3["key"] = "key_2003" + + feature0["experimentIds"] = ["1000"] + feature1["experimentIds"] = ["1000", "1001", "1002"] + feature2["experimentIds"] = ["1000", "1003", "1004"] + feature3["experimentIds"] = ["1000", "1003", "1004"] + + var projectData = ProjectTests.sampleData + projectData["experiments"] = [exp0, exp1, exp2, exp3, exp4] + projectData["featureFlags"] = [feature0, feature1, feature2, feature3] + projectData["holdouts"] = [holdout0, holdout1, holdout2, holdout3, holdout4] + + // check experimentFeatureMap extracted properly + + let model: Project = try! OTUtils.model(from: projectData) + let projectConfig = ProjectConfig() + projectConfig.project = model + + let holdoutIdMap = projectConfig.holdoutConfig.holdoutIdMap + + XCTAssertEqual(holdoutIdMap["3000"]?.includedFlags, []) + XCTAssertEqual(holdoutIdMap["3000"]?.excludedFlags, []) + + XCTAssertEqual(holdoutIdMap["3001"]?.includedFlags, []) + XCTAssertEqual(holdoutIdMap["3001"]?.excludedFlags, []) + + XCTAssertEqual(holdoutIdMap["3002"]?.includedFlags, []) + XCTAssertEqual(holdoutIdMap["3002"]?.excludedFlags, []) + + XCTAssertEqual(holdoutIdMap["3003"]?.includedFlags, ["2000", "2002"]) + XCTAssertEqual(holdoutIdMap["3003"]?.excludedFlags, []) + + + XCTAssertEqual(holdoutIdMap["3004"]?.includedFlags, []) + XCTAssertEqual(holdoutIdMap["3004"]?.excludedFlags, ["2001"]) + + /// Test Global holdout + included + + XCTAssertEqual(projectConfig.holdoutConfig.getHoldoutForFlag(id: "2000").map { $0.id }, ["3000", "3001", "3002", "3004", "3003"]) + XCTAssertEqual(projectConfig.holdoutConfig.getHoldoutForFlag(id: "2002").map { $0.id }, ["3000", "3001", "3002", "3004","3003"]) + + /// Test Global holdout - excluded + XCTAssertEqual(projectConfig.holdoutConfig.getHoldoutForFlag(id: "2001").map { $0.id }, ["3000", "3001", "3002"]) + + /// Test Global holdout + others + XCTAssertEqual(projectConfig.holdoutConfig.getHoldoutForFlag(id: "2003").map { $0.id }, ["3000", "3001", "3002", "3004"]) + } + func testFlagVariations() { let datafile = OTUtils.loadJSONDatafile("decide_datafile")! let optimizely = OptimizelyClient(sdkKey: "12345", diff --git a/Tests/OptimizelyTests-DataModel/ProjectTests.swift b/Tests/OptimizelyTests-DataModel/ProjectTests.swift index dbf5ba4c..e0d10fb9 100644 --- a/Tests/OptimizelyTests-DataModel/ProjectTests.swift +++ b/Tests/OptimizelyTests-DataModel/ProjectTests.swift @@ -22,6 +22,7 @@ class ProjectTests: XCTestCase { static var sampleData: [String: Any] = ["version": "4", "projectId": "11111", "experiments": [ExperimentTests.sampleData], + "holdouts": [HoldoutTests.sampleData], "audiences": [AudienceTests.sampleData], "groups": [GroupTests.sampleData], "attributes": [AttributeTests.sampleData], @@ -49,6 +50,7 @@ extension ProjectTests { XCTAssert(model.version == "4") XCTAssert(model.projectId == "11111") XCTAssert(model.experiments == [try! OTUtils.model(from: ExperimentTests.sampleData)]) + XCTAssert(model.holdouts == [try! OTUtils.model(from: HoldoutTests.sampleData)]) XCTAssert(model.audiences == [try! OTUtils.model(from: AudienceTests.sampleData)]) XCTAssert(model.groups == [try! OTUtils.model(from: GroupTests.sampleData)]) XCTAssert(model.attributes == [try! OTUtils.model(from: AttributeTests.sampleData)]) @@ -210,6 +212,16 @@ extension ProjectTests { XCTAssertNil(model.sendFlagDecisions) } + func testDecodeSuccessWithMissingHoldouts() { + var data: [String: Any] = ProjectTests.sampleData + data["holdouts"] = nil + + let model: Project = try! OTUtils.model(from: data) + XCTAssertNotNil(model) + XCTAssertEqual(model.holdouts, []) + + } + } // MARK: - Encode From 51b03870187f77e60509b34fab86ab4970ad9f00 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Mon, 5 May 2025 16:50:39 +0600 Subject: [PATCH 25/37] [FSSDK-11373] add holdout support and refactor decision logic in DefaultDecisionService (#587) --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 50 ++ Sources/Data Model/FeatureFlag.swift | 2 - Sources/Data Model/HoldoutConfig.swift | 1 - Sources/Implementation/DecisionInfo.swift | 4 +- Sources/Implementation/DefaultBucketer.swift | 2 +- .../DefaultDecisionService.swift | 357 ++++++--- .../Events/BatchEventBuilder.swift | 2 +- Sources/Optimizely/OptimizelyClient.swift | 4 +- Sources/Protocols/OPTBucketer.swift | 2 +- Sources/Utils/Constants.swift | 1 + Sources/Utils/LogMessage.swift | 18 +- .../BucketTests_HoldoutToVariation.swift | 143 ++++ .../DecisionListenerTests.swift | 2 +- .../DecisionServiceTests_Features.swift | 6 +- .../DecisionServiceTests_Holdouts.swift | 675 ++++++++++++++++++ ...zelyUserContextTests_Decide_Holdouts.swift | 564 +++++++++++++++ ...izelyUserContextTests_Decide_Reasons.swift | 1 - ...xtTests_Decide_With_Holdouts_Reasons.swift | 199 ++++++ .../HoldoutTests.swift | 5 +- Tests/TestUtils/MockBucketer.swift | 40 ++ 20 files changed, 1971 insertions(+), 107 deletions(-) create mode 100644 Tests/OptimizelyTests-Common/BucketTests_HoldoutToVariation.swift create mode 100644 Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift create mode 100644 Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift create mode 100644 Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift create mode 100644 Tests/TestUtils/MockBucketer.swift diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index e9c921b8..db67fedf 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2034,6 +2034,18 @@ 984FE51E2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; 984FE51F2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; 984FE5202CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 989428B32DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428B42DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428B52DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428B62DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428B72DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428B82DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428B92DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428BA2DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428BB2DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428BC2DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428BD2DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428BE2DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; 98AC97E22DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; 98AC97E32DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; 98AC97E42DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; @@ -2052,6 +2064,14 @@ 98AC97F12DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; 98AC97F32DAE9685001405DD /* HoldoutConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */; }; 98AC97F42DAE9685001405DD /* HoldoutConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */; }; + 98AC98462DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */; }; + 98AC98472DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */; }; + 98AC98492DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */; }; + 98AC984B2DB8FFE0001405DD /* DecisionServiceTests_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */; }; + 98AC985E2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */; }; + 98AC985F2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */; }; + 98D5AE842DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */; }; + 98D5AE852DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */; }; BD1C3E8524E4399C0084B4DA /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B97DD93249D327F003DE606 /* SemanticVersion.swift */; }; BD64853C2491474500F30986 /* Optimizely.h in Headers */ = {isa = PBXBuildFile; fileRef = 6E75167A22C520D400B2B157 /* Optimizely.h */; settings = {ATTRIBUTES = (Public, ); }; }; BD64853E2491474500F30986 /* Audience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E75169822C520D400B2B157 /* Audience.swift */; }; @@ -2495,8 +2515,13 @@ 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutTests.swift; sourceTree = ""; }; 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileTracker.swift; sourceTree = ""; }; 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 989428B22DBFA431008BA1C8 /* MockBucketer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBucketer.swift; sourceTree = ""; }; 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutConfig.swift; sourceTree = ""; }; 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutConfigTests.swift; sourceTree = ""; }; + 98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketTests_HoldoutToVariation.swift; sourceTree = ""; }; + 98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecisionServiceTests_Holdouts.swift; sourceTree = ""; }; + 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift; sourceTree = ""; }; + 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Holdouts.swift; sourceTree = ""; }; BD6485812491474500F30986 /* Optimizely.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Optimizely.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyJSON.swift; sourceTree = ""; }; C78CAF652446DB91009FE876 /* OptimizelyClientTests_OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_OptimizelyJSON.swift; sourceTree = ""; }; @@ -3003,6 +3028,7 @@ 6E75198F22C5211100B2B157 /* BucketTests_BucketVariation.swift */, 6E75198C22C5211100B2B157 /* BucketTests_ExpToVariation.swift */, 6E75198322C5211100B2B157 /* BucketTests_GroupToExp.swift */, + 98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */, 6E75198422C5211100B2B157 /* BucketTests_Others.swift */, 6E75199622C5211100B2B157 /* DatafileHandlerTests.swift */, 6E75199822C5211100B2B157 /* DataStoreTests.swift */, @@ -3011,6 +3037,7 @@ 6E27ECBD266FD78600B4A6D4 /* DecisionReasonsTests.swift */, 6E75198022C5211100B2B157 /* DecisionServiceTests_Experiments.swift */, 6E75199122C5211100B2B157 /* DecisionServiceTests_Features.swift */, + 98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */, 6E75199422C5211100B2B157 /* DecisionServiceTests_Others.swift */, 6E75198622C5211100B2B157 /* DecisionServiceTests_UserProfiles.swift */, 6E75198822C5211100B2B157 /* DefaultLoggerTests.swift */, @@ -3031,7 +3058,9 @@ 6E75198122C5211100B2B157 /* OptimizelyErrorTests.swift */, 6EC6DD6824AE94820017D296 /* OptimizelyUserContextTests.swift */, 6E2D34B8250AD14000A0CDFE /* OptimizelyUserContextTests_Decide.swift */, + 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */, 6E7E9B362523F8BF009E4426 /* OptimizelyUserContextTests_Decide_Reasons.swift */, + 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */, 6EB97BCC24C89DFB00068883 /* OptimizelyUserContextTests_Decide_Legacy.swift */, 6E0A72D326C5B9AE00FF92B5 /* OptimizelyUserContextTests_ForcedDecisions.swift */, 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */, @@ -3101,6 +3130,7 @@ 6E7519B722C5211100B2B157 /* MockUrlSession.swift */, 6E5D121E2638DDF4000ABFC3 /* MockEventDispatcher.swift */, 6E20050726B4D28400278087 /* MockLogger.swift */, + 989428B22DBFA431008BA1C8 /* MockBucketer.swift */, ); path = TestUtils; sourceTree = ""; @@ -4198,6 +4228,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 989428BB2DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E14CDAB2423F9EB00010234 /* MockUrlSession.swift in Sources */, 6E14CDAA2423F9C300010234 /* SDKVersion.swift in Sources */, 845945C3287758A100D13E11 /* OdpConfig.swift in Sources */, @@ -4334,6 +4365,7 @@ 6E424D0026324B620081004A /* EventForDispatch.swift in Sources */, 6E424D0126324B620081004A /* SemanticVersion.swift in Sources */, 6E424D0226324B620081004A /* Audience.swift in Sources */, + 989428B62DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E424D0326324B620081004A /* AttributeValue.swift in Sources */, 84E2E9482852A378001114AB /* VuidManager.swift in Sources */, 6E424D0426324B620081004A /* ConditionLeaf.swift in Sources */, @@ -4514,6 +4546,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 989428B32DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E75170222C520D400B2B157 /* OptimizelyLogLevel.swift in Sources */, 6E7516BA22C520D400B2B157 /* DefaultUserProfileService.swift in Sources */, 845945C7287758A300D13E11 /* OdpConfig.swift in Sources */, @@ -4710,6 +4743,7 @@ 6E9B11DA22C548A200C22D81 /* OptimizelyClientTests_ObjcAPIs.m in Sources */, 84518B21287737070023F104 /* OdpConfig.swift in Sources */, 6E75179A22C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, + 989428B52DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E75182022C520D400B2B157 /* BatchEventBuilder.swift in Sources */, 6E5AB69323F6130D007A82B1 /* OptimizelyClientTests_Init_Sync.swift in Sources */, 6E4544B2270E67C800F2CEBC /* NetworkReachability.swift in Sources */, @@ -4836,6 +4870,7 @@ 6E7517A922C520D400B2B157 /* Array+Extension.swift in Sources */, 6E75186B22C520D400B2B157 /* Rollout.swift in Sources */, 6E75183B22C520D400B2B157 /* EventForDispatch.swift in Sources */, + 989428B42DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E75194322C520D500B2B157 /* OPTDecisionService.swift in Sources */, 84E2E97D2855875E001114AB /* OdpEventManager.swift in Sources */, 84861806286CF33700B7F41B /* OdpEvent.swift in Sources */, @@ -4882,6 +4917,7 @@ 6E9B116322C5487100C22D81 /* BucketTests_GroupToExp.swift in Sources */, 6E7516AF22C520D400B2B157 /* DefaultLogger.swift in Sources */, 6EF8DE2524BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, + 98D5AE852DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */, 6E75194522C520D500B2B157 /* OPTDecisionService.swift in Sources */, 6E75185522C520D400B2B157 /* ProjectConfig.swift in Sources */, 84F6BAB427FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift in Sources */, @@ -4901,6 +4937,7 @@ 6E75192D22C520D500B2B157 /* DataStoreQueueStack.swift in Sources */, 6E7516D322C520D400B2B157 /* OPTLogger.swift in Sources */, 6E75180122C520D400B2B157 /* DataStoreUserDefaults.swift in Sources */, + 98AC98472DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */, 6E75175722C520D400B2B157 /* LogMessage.swift in Sources */, 6E7516EB22C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E75188522C520D400B2B157 /* TrafficAllocation.swift in Sources */, @@ -4954,6 +4991,7 @@ 6E27EC9C266EF11000B4A6D4 /* OptimizelyDecisionTests.swift in Sources */, 6E7518D922C520D400B2B157 /* AttributeValue.swift in Sources */, 6E9B116822C5487100C22D81 /* DefaultLoggerTests.swift in Sources */, + 98AC985F2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */, C78CAF622445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, 6E75179322C520D400B2B157 /* OptimizelyClient+Extension.swift in Sources */, 6E9B117122C5487100C22D81 /* DecisionServiceTests_Features.swift in Sources */, @@ -4983,6 +5021,8 @@ 6E7516F722C520D400B2B157 /* OptimizelyError.swift in Sources */, 84861812286D0B8900B7F41B /* OdpSegmentManagerTests.swift in Sources */, 6E75189122C520D400B2B157 /* Project.swift in Sources */, + 98AC98492DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift in Sources */, + 989428B92DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E7517F522C520D400B2B157 /* DataStoreMemory.swift in Sources */, 6E0207A9272A11CF008C3711 /* NetworkReachabilityTests.swift in Sources */, 6E75183D22C520D400B2B157 /* EventForDispatch.swift in Sources */, @@ -5009,6 +5049,7 @@ 6E9B119C22C5488300C22D81 /* ProjectConfigTests.swift in Sources */, 980CC8FF2D833F0D00E07D24 /* Holdout.swift in Sources */, 6E7518FE22C520D500B2B157 /* UserAttribute.swift in Sources */, + 989428B82DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E7517F622C520D400B2B157 /* DataStoreMemory.swift in Sources */, 6E9B119322C5488300C22D81 /* AttributeTests.swift in Sources */, 845945C9287758A600D13E11 /* OdpConfig.swift in Sources */, @@ -5158,6 +5199,7 @@ 6E9B114922C5486E00C22D81 /* BucketTests_GroupToExp.swift in Sources */, 6E75182B22C520D400B2B157 /* BatchEvent.swift in Sources */, 6EF8DE1E24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, + 98D5AE842DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */, 6E75190322C520D500B2B157 /* Attribute.swift in Sources */, 6E75192722C520D500B2B157 /* DataStoreQueueStack.swift in Sources */, 6E7516F122C520D400B2B157 /* OptimizelyError.swift in Sources */, @@ -5166,6 +5208,7 @@ 6E75175D22C520D400B2B157 /* AtomicProperty.swift in Sources */, 6E7516D922C520D400B2B157 /* OPTUserProfileService.swift in Sources */, 6E7516E522C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, + 98AC98462DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */, 6E7E9B552523F8C6009E4426 /* OptimizelyUserContextTests_Decide_Legacy.swift in Sources */, 6E652305278E688B00954EA1 /* LruCache.swift in Sources */, 6EC6DD3524ABF6990017D296 /* OptimizelyClient+Decide.swift in Sources */, @@ -5230,6 +5273,7 @@ 6E7518EB22C520D400B2B157 /* ConditionHolder.swift in Sources */, 6E27EC9B266EF11000B4A6D4 /* OptimizelyDecisionTests.swift in Sources */, 6E75176922C520D400B2B157 /* Utils.swift in Sources */, + 98AC985E2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */, 6E9B114E22C5486E00C22D81 /* DefaultLoggerTests.swift in Sources */, C78CAF5B2445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, 6E7518C722C520D400B2B157 /* Audience.swift in Sources */, @@ -5260,6 +5304,7 @@ 6E75193F22C520D500B2B157 /* OPTDecisionService.swift in Sources */, 84E7ABC027D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E7516CD22C520D400B2B157 /* OPTLogger.swift in Sources */, + 989428BD2DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 8428D3D02807337400D0FB0C /* LruCacheTests.swift in Sources */, 84E2E9772855875E001114AB /* OdpEventManager.swift in Sources */, 6E7517FB22C520D400B2B157 /* DataStoreUserDefaults.swift in Sources */, @@ -5334,6 +5379,7 @@ 6EF8DE2124BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 8464087928130D3200CCF97D /* Integration.swift in Sources */, 6E9B118122C5488100C22D81 /* ConditionLeafTests.swift in Sources */, + 98AC984B2DB8FFE0001405DD /* DecisionServiceTests_Holdouts.swift in Sources */, 6E75184522C520D400B2B157 /* Event.swift in Sources */, 6E75191122C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, 848617D12863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, @@ -5356,6 +5402,7 @@ 6E75175F22C520D400B2B157 /* AtomicProperty.swift in Sources */, C78CAF5E2445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, 6E7516B722C520D400B2B157 /* DefaultUserProfileService.swift in Sources */, + 989428BC2DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E623F09253F9045000617D0 /* DecisionInfo.swift in Sources */, 6E4544B3270E67C800F2CEBC /* NetworkReachability.swift in Sources */, 84E2E96A28540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, @@ -5468,6 +5515,7 @@ 6E7518BE22C520D400B2B157 /* Variable.swift in Sources */, 6E7518CA22C520D400B2B157 /* Audience.swift in Sources */, 98AC97E62DAE4579001405DD /* HoldoutConfig.swift in Sources */, + 989428BA2DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 848617E42863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, 6E75187622C520D400B2B157 /* Variation.swift in Sources */, 6E7517F222C520D400B2B157 /* DataStoreMemory.swift in Sources */, @@ -5573,6 +5621,7 @@ 6E7518C322C520D400B2B157 /* Variable.swift in Sources */, 6E7518CF22C520D400B2B157 /* Audience.swift in Sources */, 98AC97E42DAE4579001405DD /* HoldoutConfig.swift in Sources */, + 989428B72DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 848617E92863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, 6E75187B22C520D400B2B157 /* Variation.swift in Sources */, 6E7517F722C520D400B2B157 /* DataStoreMemory.swift in Sources */, @@ -5708,6 +5757,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 989428BE2DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E7516FC22C520D400B2B157 /* OptimizelyLogLevel.swift in Sources */, 6E7516B422C520D400B2B157 /* DefaultUserProfileService.swift in Sources */, 845945C02877589F00D13E11 /* OdpConfig.swift in Sources */, diff --git a/Sources/Data Model/FeatureFlag.swift b/Sources/Data Model/FeatureFlag.swift index e402825a..f0650561 100644 --- a/Sources/Data Model/FeatureFlag.swift +++ b/Sources/Data Model/FeatureFlag.swift @@ -35,8 +35,6 @@ struct FeatureFlag: Codable, Equatable, OptimizelyFeature { case variables } -// var holdoutIds: [String] = [] - // MARK: - OptimizelyConfig var experimentsMap: [String: OptimizelyExperiment] = [:] diff --git a/Sources/Data Model/HoldoutConfig.swift b/Sources/Data Model/HoldoutConfig.swift index dae915a4..24726e2f 100644 --- a/Sources/Data Model/HoldoutConfig.swift +++ b/Sources/Data Model/HoldoutConfig.swift @@ -115,4 +115,3 @@ struct HoldoutConfig { return holdoutIdMap[id] } } - diff --git a/Sources/Implementation/DecisionInfo.swift b/Sources/Implementation/DecisionInfo.swift index 72c38c30..fcb30eb2 100644 --- a/Sources/Implementation/DecisionInfo.swift +++ b/Sources/Implementation/DecisionInfo.swift @@ -22,7 +22,7 @@ struct DecisionInfo { let decisionType: Constants.DecisionType /// The experiment that the decision variation belongs to. - var experiment: Experiment? + var experiment: ExperimentCore? /// The variation selected by the decision. var variation: Variation? @@ -58,7 +58,7 @@ struct DecisionInfo { var decisionEventDispatched: Bool init(decisionType: Constants.DecisionType, - experiment: Experiment? = nil, + experiment: ExperimentCore? = nil, variation: Variation? = nil, source: String? = nil, feature: FeatureFlag? = nil, diff --git a/Sources/Implementation/DefaultBucketer.swift b/Sources/Implementation/DefaultBucketer.swift index 44f896b0..7f616eeb 100644 --- a/Sources/Implementation/DefaultBucketer.swift +++ b/Sources/Implementation/DefaultBucketer.swift @@ -120,7 +120,7 @@ class DefaultBucketer: OPTBucketer { return DecisionResponse(result: nil, reasons: reasons) } - func bucketToVariation(experiment: Experiment, + func bucketToVariation(experiment: ExperimentCore, bucketingId: String) -> DecisionResponse { let reasons = DecisionReasons() diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 9267a4f6..003cc04f 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -17,7 +17,7 @@ import Foundation struct FeatureDecision { - var experiment: Experiment? + var experiment: ExperimentCore? let variation: Variation let source: String } @@ -42,7 +42,20 @@ class DefaultDecisionService: OPTDecisionService { self.userProfileService = userProfileService } - /// Public Method + init(userProfileService: OPTUserProfileService, bucketer: OPTBucketer) { + self.bucketer = bucketer + self.userProfileService = userProfileService + } + + // MARK: - Experiment Decision + + /// Determines the variation for a user in a given experiment. + /// - Parameters: + /// - config: The project configuration containing experiment and feature details. + /// - experiment: The experiment to evaluate. + /// - user: The user context containing user ID and attributes. + /// - options: Optional decision options (e.g., ignore user profile service). + /// - Returns: A `DecisionResponse` containing the assigned variation (if any) and decision reasons. func getVariation(config: ProjectConfig, experiment: Experiment, user: OptimizelyUserContext, @@ -64,6 +77,14 @@ class DefaultDecisionService: OPTDecisionService { return response } + /// Determines the variation for a user in an experiment, considering user profile and decision rules. + /// - Parameters: + /// - config: The project configuration. + /// - experiment: The experiment to evaluate. + /// - user: The user context. + /// - options: Optional decision options. + /// - userProfileTracker: Optional tracker for user profile data. + /// - Returns: A `DecisionResponse` with the variation (if any) and decision reasons. func getVariation(config: ProjectConfig, experiment: Experiment, user: OptimizelyUserContext, @@ -157,62 +178,15 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: bucketedVariation, reasons: reasons) } - func doesMeetAudienceConditions(config: ProjectConfig, - experiment: Experiment, - user: OptimizelyUserContext, - logType: Constants.EvaluationLogType = .experiment, - loggingKey: String? = nil) -> DecisionResponse { - let reasons = DecisionReasons() - - var result = true // success as default (no condition, etc) - let evType = logType.rawValue - let finalLoggingKey = loggingKey ?? experiment.key - - do { - if let conditions = experiment.audienceConditions { - logger.d { () -> String in - return LogMessage.evaluatingAudiencesCombined(evType, finalLoggingKey, Utils.getConditionString(conditions: conditions)).description - } - switch conditions { - case .array(let arrConditions): - if arrConditions.count > 0 { - result = try conditions.evaluate(project: config.project, user: user) - } else { - // empty conditions (backward compatibility with "audienceIds" is ignored if exists even though empty - result = true - } - case .leaf: - result = try conditions.evaluate(project: config.project, user: user) - default: - result = true - } - } - // backward compatibility with audienceIds list - else if experiment.audienceIds.count > 0 { - var holder = [ConditionHolder]() - holder.append(.logicalOp(.or)) - for id in experiment.audienceIds { - holder.append(.leaf(.audienceId(id))) - } - logger.d { () -> String in - return LogMessage.evaluatingAudiencesCombined(evType, finalLoggingKey, Utils.getConditionString(conditions: holder)).description - } - result = try holder.evaluate(project: config.project, user: user) - } - } catch { - if let error = error as? OptimizelyError { - logger.i(error) - reasons.addInfo(error) - } - result = false - } - - logger.i(.audienceEvaluationResultCombined(evType, finalLoggingKey, result.description)) - - return DecisionResponse(result: result, reasons: reasons) - } + // MARK: - Feature Flag Decision - /// Public Method + /// Determines the feature decision for a user for a specific feature flag. + /// - Parameters: + /// - config: The project configuration. + /// - featureFlag: The feature flag to evaluate. + /// - user: The user context. + /// - options: Optional decision options. + /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons. func getVariationForFeature(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, @@ -228,12 +202,18 @@ class DefaultDecisionService: OPTDecisionService { return response! } + /// Determines feature decisions for a list of feature flags. + /// - Parameters: + /// - config: The project configuration. + /// - featureFlags: The list of feature flags to evaluate. + /// - user: The user context. + /// - options: Optional decision options. + /// - Returns: An array of `DecisionResponse` objects, each containing a feature decision and reasons. func getVariationForFeatureList(config: ProjectConfig, featureFlags: [FeatureFlag], user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil) -> [DecisionResponse] { - let reasons = DecisionReasons(options: options) let userId = user.userId let ignoreUPS = (options ?? []).contains(.ignoreUserProfileService) var profileTracker: UserProfileTracker? @@ -245,24 +225,8 @@ class DefaultDecisionService: OPTDecisionService { var decisions = [DecisionResponse]() for featureFlag in featureFlags { - var decisionResponse = getVariationForFeatureExperiment(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker) - - reasons.merge(decisionResponse.reasons) - - if let decision = decisionResponse.result { - decisions.append(DecisionResponse(result: decision, reasons: reasons)) - continue - } - - decisionResponse = getVariationForFeatureRollout(config: config, featureFlag: featureFlag, user: user) - - reasons.merge(decisionResponse.reasons) - - if let decision = decisionResponse.result { - decisions.append(DecisionResponse(result: decision, reasons: reasons)) - } else { - decisions.append(DecisionResponse(result: nil, reasons: reasons)) - } + let flagDecisionResponse = getDecisionForFlag(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker) + decisions.append(flagDecisionResponse) } // save profile @@ -272,13 +236,65 @@ class DefaultDecisionService: OPTDecisionService { return decisions } - - func getVariationForFeatureExperiment(config: ProjectConfig, - featureFlag: FeatureFlag, - user: OptimizelyUserContext, - userProfileTracker: UserProfileTracker? = nil, - options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + /// Determines the feature decision for a feature flag, considering holdout, experiment and rollout + /// - Parameters: + /// - config: The project configuration. + /// - featureFlag: The feature flag to evaluate. + /// - user: The user context. + /// - userProfileTracker: Optional tracker for user profile data. + /// - options: Optional decision options. + /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons. + func getDecisionForFlag(config: ProjectConfig, + featureFlag: FeatureFlag, + user: OptimizelyUserContext, + userProfileTracker: UserProfileTracker? = nil, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + let reasons = DecisionReasons(options: options) + + let holdouts = config.getHoldoutForFlag(id: featureFlag.id) + for holdout in holdouts { + let holdoutDecision = getVariationForHoldout(config: config, + flagKey: featureFlag.key, + holdout: holdout, + user: user) + reasons.merge(holdoutDecision.reasons) + if let variation = holdoutDecision.result { + let featureDicision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue) + return DecisionResponse(result: featureDicision, reasons: reasons) + } + } + + let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker) + reasons.merge(flagExpDecision.reasons) + + if let decision = flagExpDecision.result { + return DecisionResponse(result: decision, reasons: reasons) + } + + let rolloutDecision = getVariationForFeatureRollout(config: config, featureFlag: featureFlag, user: user) + reasons.merge(rolloutDecision.reasons) + + if let decision = rolloutDecision.result { + return DecisionResponse(result: decision, reasons: reasons) + } else { + return DecisionResponse(result: nil, reasons: reasons) + } + } + + /// Determines the feature decision for a feature flag, considering experiments + /// - Parameters: + /// - config: The project configuration. + /// - featureFlag: The feature flag to evaluate. + /// - user: The user context. + /// - userProfileTracker: Optional tracker for user profile data. + /// - options: Optional decision options. + /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons. + func getVariationForFeatureExperiments(config: ProjectConfig, + featureFlag: FeatureFlag, + user: OptimizelyUserContext, + userProfileTracker: UserProfileTracker? = nil, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) let experimentIds = featureFlag.experimentIds @@ -309,6 +325,13 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: nil, reasons: reasons) } + /// Determines the feature decision for a feature flag's rollout rules. + /// - Parameters: + /// - config: The project configuration. + /// - featureFlag: The feature flag to evaluate. + /// - user: The user context. + /// - options: Optional decision options. + /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons. func getVariationForFeatureRollout(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, @@ -363,6 +386,86 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: nil, reasons: reasons) } + + // MARK: - Holdout and Rule Decisions + + /// Determines the variation for a holdout group. + /// - Parameters: + /// - config: The project configuration. + /// - flagKey: The feature flag key. + /// - holdout: The holdout group to evaluate. + /// - user: The user context. + /// - options: Optional decision options. + /// - Returns: A `DecisionResponse` with the variation (if any) and reasons. + func getVariationForHoldout(config: ProjectConfig, + flagKey: String, + holdout: Holdout, + user: OptimizelyUserContext, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + let reasons = DecisionReasons(options: options) + + guard holdout.isActivated else { + let info = LogMessage.holdoutNotRunning(holdout.key) + reasons.addInfo(info) + logger.i(info) + return DecisionResponse(result: nil, reasons: reasons) + } + + // ---- check if the user passes audience targeting before bucketing ---- + let audienceResponse = doesMeetAudienceConditions(config: config, + experiment: holdout, + user: user) + + reasons.merge(audienceResponse.reasons) + + let userId = user.userId + let attributes = user.attributes + + // Acquire bucketingId . + let bucketingId = getBucketingId(userId: userId, attributes: attributes) + var bucketedVariation: Variation? + + if audienceResponse.result ?? false { + let info = LogMessage.userMeetsConditionsForHoldout(userId, holdout.key) + reasons.addInfo(info) + logger.i(info) + + // bucket user into holdout variation + let decisionResponse = (bucketer as? DefaultBucketer)?.bucketToVariation(experiment: holdout, bucketingId: bucketingId) + if let reason = decisionResponse?.reasons { + reasons.merge(reason) + } + + bucketedVariation = decisionResponse?.result + + if let variation = bucketedVariation { + let info = LogMessage.userBucketedIntoVariationInHoldout(userId, holdout.key, variation.key) + reasons.addInfo(info) + logger.i(info) + } else { + let info = LogMessage.userNotBucketedIntoHoldoutVariation(userId) + reasons.addInfo(info) + logger.i(info) + } + + } else { + let info = LogMessage.userDoesntMeetConditionsForHoldout(userId, holdout.key) + reasons.addInfo(info) + logger.i(info) + } + + return DecisionResponse(result: bucketedVariation, reasons: reasons) + } + + /// Determines the variation for an experiment rule within a feature flag. + /// - Parameters: + /// - config: The project configuration. + /// - flagKey: The feature flag key. + /// - rule: The experiment rule to evaluate. + /// - user: The user context. + /// - userProfileTracker: Optional tracker for user profile data. + /// - options: Optional decision options. + /// - Returns: A `DecisionResponse` with the variation (if any) and reasons. func getVariationFromExperimentRule(config: ProjectConfig, flagKey: String, rule: Experiment, @@ -389,7 +492,15 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: variation, reasons: reasons) } - + /// Determines the variation for a delivery rule in a rollout. + /// - Parameters: + /// - config: The project configuration. + /// - flagKey: The feature flag key. + /// - rules: The list of rollout rules. + /// - ruleIndex: The index of the rule to evaluate. + /// - user: The user context. + /// - options: Optional decision options. + /// - Returns: A `DecisionResponse` with the variation (if any), a flag indicating whether to skip to the "Everyone Else" rule, and reasons. func getVariationFromDeliveryRule(config: ProjectConfig, flagKey: String, rules: [Experiment], @@ -461,8 +572,79 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: (bucketedVariation, skipToEveryoneElse), reasons: reasons) } - func getBucketingId(userId: String, attributes: OptimizelyAttributes) -> String { + // MARK: - Audience Evaluation + + /// Evaluates whether a user meets the audience conditions for an experiment or rule. + /// - Parameters: + /// - config: The project configuration. + /// - experiment: The experiment or rule to evaluate. + /// - user: The user context. + /// - logType: The type of evaluation for logging (e.g., experiment or rollout rule). + /// - loggingKey: Optional key for logging. + /// - Returns: A `DecisionResponse` with a boolean indicating whether conditions are met and reasons. + func doesMeetAudienceConditions(config: ProjectConfig, + experiment: ExperimentCore, + user: OptimizelyUserContext, + logType: Constants.EvaluationLogType = .experiment, + loggingKey: String? = nil) -> DecisionResponse { + let reasons = DecisionReasons() + + var result = true // success as default (no condition, etc) + let evType = logType.rawValue + let finalLoggingKey = loggingKey ?? experiment.key + do { + if let conditions = experiment.audienceConditions { + logger.d { () -> String in + return LogMessage.evaluatingAudiencesCombined(evType, finalLoggingKey, Utils.getConditionString(conditions: conditions)).description + } + switch conditions { + case .array(let arrConditions): + if arrConditions.count > 0 { + result = try conditions.evaluate(project: config.project, user: user) + } else { + // empty conditions (backward compatibility with "audienceIds" is ignored if exists even though empty + result = true + } + case .leaf: + result = try conditions.evaluate(project: config.project, user: user) + default: + result = true + } + } + // backward compatibility with audienceIds list + else if experiment.audienceIds.count > 0 { + var holder = [ConditionHolder]() + holder.append(.logicalOp(.or)) + for id in experiment.audienceIds { + holder.append(.leaf(.audienceId(id))) + } + logger.d { () -> String in + return LogMessage.evaluatingAudiencesCombined(evType, finalLoggingKey, Utils.getConditionString(conditions: holder)).description + } + result = try holder.evaluate(project: config.project, user: user) + } + } catch { + if let error = error as? OptimizelyError { + logger.i(error) + reasons.addInfo(error) + } + result = false + } + + logger.i(.audienceEvaluationResultCombined(evType, finalLoggingKey, result.description)) + + return DecisionResponse(result: result, reasons: reasons) + } + + // MARK: - Utilities + + /// Retrieves the bucketing ID for a user, defaulting to user ID unless overridden in attributes. + /// - Parameters: + /// - userId: The user's ID. + /// - attributes: The user's attributes. + /// - Returns: The bucketing ID to use for variation assignment. + func getBucketingId(userId: String, attributes: OptimizelyAttributes) -> String { // By default, the bucketing ID should be the user ID . var bucketingId = userId // If the bucketing ID key is defined in attributes, then use that @@ -474,7 +656,12 @@ class DefaultDecisionService: OPTDecisionService { return bucketingId } - /// Public Method + /// Finds and validates a forced decision for a given context. + /// - Parameters: + /// - config: The project configuration. + /// - user: The user context. + /// - context: The decision context (flag and rule keys). + /// - Returns: A `DecisionResponse` with the forced variation (if valid) and reasons. func findValidatedForcedDecision(config: ProjectConfig, user: OptimizelyUserContext, context: OptimizelyDecisionContext) -> DecisionResponse { diff --git a/Sources/Implementation/Events/BatchEventBuilder.swift b/Sources/Implementation/Events/BatchEventBuilder.swift index 78fb329c..4dbd0961 100644 --- a/Sources/Implementation/Events/BatchEventBuilder.swift +++ b/Sources/Implementation/Events/BatchEventBuilder.swift @@ -22,7 +22,7 @@ class BatchEventBuilder { // MARK: - Impression Event static func createImpressionEvent(config: ProjectConfig, - experiment: Experiment?, + experiment: ExperimentCore?, variation: Variation?, userId: String, attributes: OptimizelyAttributes?, diff --git a/Sources/Optimizely/OptimizelyClient.swift b/Sources/Optimizely/OptimizelyClient.swift index b99c9393..15905a5c 100644 --- a/Sources/Optimizely/OptimizelyClient.swift +++ b/Sources/Optimizely/OptimizelyClient.swift @@ -804,7 +804,7 @@ extension OptimizelyClient { return (source == Constants.DecisionSource.featureTest.rawValue && decision?.variation != nil) || config.sendFlagDecisions } - func sendImpressionEvent(experiment: Experiment?, + func sendImpressionEvent(experiment: ExperimentCore?, variation: Variation?, userId: String, attributes: OptimizelyAttributes? = nil, @@ -892,7 +892,7 @@ extension OptimizelyClient { extension OptimizelyClient { - func sendActivateNotification(experiment: Experiment, + func sendActivateNotification(experiment: ExperimentCore, variation: Variation, userId: String, attributes: OptimizelyAttributes?, diff --git a/Sources/Protocols/OPTBucketer.swift b/Sources/Protocols/OPTBucketer.swift index 0f9440ec..f7b9fd83 100644 --- a/Sources/Protocols/OPTBucketer.swift +++ b/Sources/Protocols/OPTBucketer.swift @@ -36,7 +36,7 @@ protocol OPTBucketer { func bucketExperiment(config: ProjectConfig, experiment: Experiment, bucketingId: String) -> DecisionResponse - + /** Hash the bucketing ID and map it to the range [0, 10000). - Parameter bucketingId: The ID for which to generate the hash and bucket values. diff --git a/Sources/Utils/Constants.swift b/Sources/Utils/Constants.swift index f60f6fdc..2623f49b 100644 --- a/Sources/Utils/Constants.swift +++ b/Sources/Utils/Constants.swift @@ -57,6 +57,7 @@ struct Constants { case experiment = "experiment" case featureTest = "feature-test" case rollout = "rollout" + case holdout = "holdout" } struct DecisionInfoKeys { diff --git a/Sources/Utils/LogMessage.swift b/Sources/Utils/LogMessage.swift index 2be76f5e..4ce7c08a 100644 --- a/Sources/Utils/LogMessage.swift +++ b/Sources/Utils/LogMessage.swift @@ -18,6 +18,7 @@ import Foundation enum LogMessage { case experimentNotRunning(_ key: String) + case holdoutNotRunning(_ key: String) case featureEnabledForUser(_ key: String, _ userId: String) case featureNotEnabledForUser(_ key: String, _ userId: String) case featureHasNoExperiments(_ key: String) @@ -34,7 +35,9 @@ enum LogMessage { case userAssignedToBucketValue(_ bucket: Int, _ userId: String) case userMappedToForcedVariation(_ userId: String, _ expId: String, _ varId: String) case userMeetsConditionsForTargetingRule(_ userId: String, _ rule: String) + case userMeetsConditionsForHoldout(_ userId: String, _ holdoutKey: String) case userDoesntMeetConditionsForTargetingRule(_ userId: String, _ rule: String) + case userDoesntMeetConditionsForHoldout(_ userId: String, _ holdoutKey: String) case userBucketedIntoTargetingRule(_ userId: String, _ rule: String) case userNotBucketedIntoTargetingRule(_ userId: String, _ rule: String) case userHasForcedDecision(_ userId: String, _ flagKey: String, _ ruleKey: String?, _ varKey: String) @@ -44,8 +47,10 @@ enum LogMessage { case userHasNoForcedVariation(_ userId: String) case userHasNoForcedVariationForExperiment(_ userId: String, _ expKey: String) case userBucketedIntoVariationInExperiment(_ userId: String, _ expKey: String, _ varKey: String) + case userBucketedIntoVariationInHoldout(_ userId: String, _ expKey: String, _ varKey: String) case userNotBucketedIntoVariation(_ userId: String) case userBucketedIntoInvalidVariation(_ id: String) + case userNotBucketedIntoHoldoutVariation(_ userId: String) case userBucketedIntoExperimentInGroup(_ userId: String, _ expKey: String, _ group: String) case userNotBucketedIntoExperimentInGroup(_ userId: String, _ expKey: String, _ group: String) case userNotBucketedIntoAnyExperimentInGroup(_ userId: String, _ group: String) @@ -76,6 +81,7 @@ extension LogMessage: CustomStringConvertible { switch self { case .experimentNotRunning(let key): message = "Experiment (\(key)) is not running." + case .holdoutNotRunning(let key): message = "Holdout (\(key)) is not running." case .featureEnabledForUser(let key, let userId): message = "Feature (\(key)) is enabled for user (\(userId))." case .featureNotEnabledForUser(let key, let userId): message = "Feature (\(key)) is not enabled for user (\(userId))." case .featureHasNoExperiments(let key): message = "Feature (\(key)) is not attached to any experiments." @@ -91,10 +97,12 @@ extension LogMessage: CustomStringConvertible { case .savedVariationInUserProfile(let varId, let expId, let userId): message = "Saved variation (\(varId)) of experiment (\(expId)) for user (\(userId))." case .userAssignedToBucketValue(let bucket, let userId): message = "Assigned bucket (\(bucket)) to user with bucketing ID (\(userId))." case .userMappedToForcedVariation(let userId, let expId, let varId): message = "Set variation (\(varId)) for experiment (\(expId)) and user (\(userId)) in the forced variation map." - case .userMeetsConditionsForTargetingRule(let userId, let rule): message = "User (\(userId)) meets conditions for targeting rule (\(rule))." - case .userDoesntMeetConditionsForTargetingRule(let userId, let rule): message = "User (\(userId)) does not meet conditions for targeting rule (\(rule))." - case .userBucketedIntoTargetingRule(let userId, let rule): message = "User (\(userId)) is in the traffic group of targeting rule (\(rule))." - case .userNotBucketedIntoTargetingRule(let userId, let rule): message = "User (\(userId)) is not in the traffic group for targeting rule (\(rule)). Checking (Everyone Else) rule now." + case .userMeetsConditionsForTargetingRule(let userId, let rule): message = "User (\(userId)) meets conditions for targeting rule (\(rule))." + case .userMeetsConditionsForHoldout(let userId, let holdoutKey): message = "User (\(userId)) meets conditions for holdout(\(holdoutKey))." + case .userDoesntMeetConditionsForTargetingRule(let userId, let rule): message = "User (\(userId)) does not meet conditions for targeting rule (\(rule))." + case .userDoesntMeetConditionsForHoldout(let userId, let holdoutKey): message = "User (\(userId)) does not meet conditions for holdout (\(holdoutKey))." + case .userBucketedIntoTargetingRule(let userId, let rule): message = "User (\(userId)) is in the traffic group of targeting rule (\(rule))." + case .userNotBucketedIntoTargetingRule(let userId, let rule): message = "User (\(userId)) is not in the traffic group for targeting rule (\(rule)). Checking (Everyone Else) rule now." case .userHasForcedDecision(let userId, let flagKey, let ruleKey, let varKey): let target = (ruleKey != nil) ? "flag (\(flagKey)), rule (\(ruleKey!))" : "flag (\(flagKey))" message = "Variation (\(varKey)) is mapped to \(target) and user (\(userId)) in the forced decision map." @@ -106,7 +114,9 @@ extension LogMessage: CustomStringConvertible { case .userHasNoForcedVariation(let userId): message = "User (\(userId)) is not in the forced variation map." case .userHasNoForcedVariationForExperiment(let userId, let expKey): message = "No experiment (\(expKey)) mapped to user (\(userId)) in the forced variation map." case .userBucketedIntoVariationInExperiment(let userId, let expKey, let varKey): message = "User (\(userId)) is in variation (\(varKey)) of experiment (\(expKey))" + case .userBucketedIntoVariationInHoldout(let userId, let holdoutKey, let varKey): message = "User (\(userId)) is in variation (\(varKey)) of holdout (\(holdoutKey))" case .userNotBucketedIntoVariation(let userId): message = "User (\(userId)) is in no variation." + case .userNotBucketedIntoHoldoutVariation(let userId): message = "User (\(userId)) is in no holdout variation." case .userBucketedIntoInvalidVariation(let id): message = "Bucketed into an invalid variation id (\(id))" case .userBucketedIntoExperimentInGroup(let userId, let expId, let group): message = "User (\(userId)) is in experiment (\(expId)) of group (\(group))." case .userNotBucketedIntoExperimentInGroup(let userId, let expKey, let group): message = "User (\(userId)) is not in experiment (\(expKey)) of group (\(group))." diff --git a/Tests/OptimizelyTests-Common/BucketTests_HoldoutToVariation.swift b/Tests/OptimizelyTests-Common/BucketTests_HoldoutToVariation.swift new file mode 100644 index 00000000..02b59464 --- /dev/null +++ b/Tests/OptimizelyTests-Common/BucketTests_HoldoutToVariation.swift @@ -0,0 +1,143 @@ +// +// Copyright 2019, 2021, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class BucketTests_HoldoutToVariation: XCTestCase { + var optimizely: OptimizelyClient! + var config: ProjectConfig! + var bucketer: DefaultBucketer! + + var kUserId = "123456" + var kHoldoutId = "4444444" + var kHoldoutKey = "holdout_key" + + var kVariationKeyA = "a" + var kVariationIdA = "a11" + + var kAudienceIdCountry = "10" + var kAudienceIdAge = "20" + var kAudienceIdInvalid = "9999999" + + var kAttributesCountryMatch: [String: Any] = ["country": "us"] + var kAttributesCountryNotMatch: [String: Any] = ["country": "ca"] + var kAttributesAgeMatch: [String: Any] = ["age": 30] + var kAttributesAgeNotMatch: [String: Any] = ["age": 10] + var kAttributesEmpty: [String: Any] = [:] + + var holdout: Holdout! + + // MARK: - Sample datafile data + + var sampleHoldoutData: [String: Any] { + return [ + "status": "Running", + "id": kHoldoutId, + "key": kHoldoutKey, + "layerId": "10420273888", + "trafficAllocation": [ + ["entityId": kVariationIdA, "endOfRange": 1000] // 10% traffic allocation (0-1000 out of 10000) + ], + "audienceIds": [kAudienceIdCountry], + "variations": [ + ["variables": [], "id": kVariationIdA, "key": kVariationKeyA] + ], + ] + } + + // MARK: - Setup + + override func setUp() { + super.setUp() + + self.optimizely = OTUtils.createOptimizely(datafileName: "empty_datafile", + clearUserProfileService: true) + self.config = self.optimizely.config! + self.bucketer = ((optimizely.decisionService as! DefaultDecisionService).bucketer as! DefaultBucketer) + + // Initialize holdout + holdout = try! OTUtils.model(from: sampleHoldoutData) + } + + // MARK: - Tests for bucketToVariation + + func testBucketToVariation_ValidBucketingWithinAllocation() { + // Test users that should bucket into the single variation (within 0-1000 range) + let testCases = [ + ["userId": "user1", "expectedVariation": kVariationKeyA], // Buckets to variation A + ["userId": "testuser", "expectedVariation": kVariationKeyA] // Buckets to variation A + ] + + for (index, test) in testCases.enumerated() { + // Mock bucket value to ensure it falls within 0-1000 + let mockBucketValue = 500 // Within 10% allocation + let mockBucketer = MockBucketer(mockBucketValue: mockBucketValue) + let response = mockBucketer.bucketToVariation(experiment: holdout, bucketingId: test["userId"]!) + XCTAssertNotNil(response.result, "Variation should not be nil for test case \(index)") + XCTAssertEqual(response.result?.key, test["expectedVariation"], "Wrong variation for test case \(index)") + } + } + + func testBucketToVariation_BucketValueOutsideAllocation() { + // Test users that fall outside the 10% allocation (bucket value > 1000) + let testCases = [ + ["userId": "user2"], + ["userId": "anotheruser"] + ] + + for (index, test) in testCases.enumerated() { + // Mock bucket value to ensure it falls outside 0-1000 + let mockBucketValue = 1500 // Outside 10% allocation + let mockBucketer = MockBucketer(mockBucketValue: mockBucketValue) + let response = mockBucketer.bucketToVariation(experiment: holdout, bucketingId: test["userId"]!) + XCTAssertNil(response.result, "Variation should be nil for test case \(index) when outside allocation") + } + } + + func testBucketToVariation_NoTrafficAllocation() { + // Create a holdout with empty traffic allocation + var modifiedHoldoutData = sampleHoldoutData + modifiedHoldoutData["trafficAllocation"] = [] + let modifiedHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout + + let response = bucketer.bucketToVariation(experiment: modifiedHoldout, bucketingId: kUserId) + + XCTAssertNil(response.result, "Variation should be nil when no traffic allocation") + } + + func testBucketToVariation_InvalidVariationId() { + // Create a holdout with invalid variation ID in traffic allocation + var modifiedHoldoutData = sampleHoldoutData + modifiedHoldoutData["trafficAllocation"] = [ + ["entityId": "invalid_variation_id", "endOfRange": 1000] + ] + let modifiedHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout + + let response = bucketer.bucketToVariation(experiment: modifiedHoldout, bucketingId: kUserId) + + XCTAssertNil(response.result, "Variation should be nil for invalid variation ID") + } + + func testBucketToVariation_EmptyBucketingId() { + // Test with empty bucketing ID, still within allocation + let mockBucketValue = 500 + let mockBucketer = MockBucketer(mockBucketValue: mockBucketValue) + let response = mockBucketer.bucketToVariation(experiment: holdout, bucketingId: "") + + XCTAssertNotNil(response.result, "Should still bucket with empty bucketing ID") + XCTAssertEqual(response.result?.key, kVariationKeyA, "Should bucket to variation A") + } +} diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift index 3837ff15..e7ca6a33 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift @@ -1263,7 +1263,7 @@ class FakeDecisionService: DefaultDecisionService { return DecisionResponse.responseNoReasons(result: featureDecision) } - override func getVariationForFeatureExperiment(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + override func getVariationForFeatureExperiments(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { guard let experiment = self.experiment, let tmpVariation = self.variation else { return DecisionResponse.nilNoReasons() } diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift index 4101578d..6a4b3eeb 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift @@ -258,7 +258,7 @@ class DecisionServiceTests_Features: XCTestCase { extension DecisionServiceTests_Features { func testGetVariationForFeatureExperimentWhenMatched() { - let pair = self.decisionService.getVariationForFeatureExperiment(config: config, + let pair = self.decisionService.getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch)).result @@ -268,7 +268,7 @@ extension DecisionServiceTests_Features { } func testGetVariationForFeatureExperimentWhenNotMatched() { - let pair = self.decisionService.getVariationForFeatureExperiment(config: config, + let pair = self.decisionService.getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch)).result @@ -280,7 +280,7 @@ extension DecisionServiceTests_Features { featureFlag.experimentIds = ["99999"] // not-existing experiment self.config.project.featureFlags = [featureFlag] - let pair = self.decisionService.getVariationForFeatureExperiment(config: config, + let pair = self.decisionService.getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch)).result diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift new file mode 100644 index 00000000..c18096c9 --- /dev/null +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift @@ -0,0 +1,675 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class DecisionServiceTests_Holdouts: XCTestCase { + + var optimizely: OptimizelyClient! + var config: ProjectConfig! + var mockDecisionService: DefaultDecisionService! + + var kUserId = "12345" + var kExperimentKey = "countryExperiment" + var kExperimentId = "country11" + + var kVariationKeyA = "a" + var kVariationKeyB = "b" + var kVariationKeyC = "c" + var kVariationKeyD = "d" + + var kAudienceIdCountry = "10" + var kAudienceIdAge = "20" + var kAudienceIdInvalid = "9999999" + + var kAttributesCountryMatch: [String: Any] = ["country": "us"] + var kAttributesCountryNotMatch: [String: Any] = ["country": "ca"] + var kAttributesAgeMatch: [String: Any] = ["age": 30] + var kAttributesAgeNotMatch: [String: Any] = ["age": 10] + var kAttributesEmpty: [String: Any] = [:] + + var holdout: Holdout! + var variation: Variation! + var featureFlag: FeatureFlag! + + // MARK: - Sample datafile data + + var sampleExperimentData: [String: Any] { return + [ + "status": "Running", + "id": kExperimentId, + "key": kExperimentKey, + "layerId": "10420273888", + "trafficAllocation": [ + [ + "entityId": "16456523121", + "endOfRange": 10000 + ] + ], + "audienceIds": [kAudienceIdCountry], + "variations": [ + [ + "variables": [], + "id": "10389729780", + "key": kVariationKeyA + ], + [ + "variables": [], + "id": "10416523121", + "key": kVariationKeyB + ], + [ + "variables": [], + "id": "13456523121", + "key": kVariationKeyC + ], + [ + "variables": [], + "id": "16456523121", + "key": kVariationKeyD + ] + ], + "forcedVariations": [:] + ] + } + + var sampleTypedAudiencesData: [[String: Any]] { return + [ + [ + "id": kAudienceIdCountry, + "conditions": [ "type": "custom_attribute", "name": "country", "match": "exact", "value": "us" ], + "name": "country" + ] + ] + } + + var sampleFeatureFlagData: [String: Any] { return + [ + "id": "flag_id_1234", + "key": "flag_key", + "experimentIds": [kExperimentId], + "rolloutId": "", + "variables": [] + ] + } + + var sampleHoldout: [String: Any] { + return [ + "status": "Running", + "id": "holdout_4444444", + "key": "holdout_key", + "layerId": "10420273888", + "trafficAllocation": [ + ["entityId": "holdout_variation_a11", "endOfRange": 1000] // 10% traffic allocation + ], + "audienceIds": [kAudienceIdCountry], + "variations": [ + [ + "variables": [], + "id": "holdout_variation_a11", + "key": "holdout_a" + ] + ], + "includedFlags": ["flag_id_1234"], + "excludedFlags": [] + ] + } + + var sampleHoldoutGlobal: [String: Any] { + return [ + "status": "Running", + "id": "holdout_global", + "key": "holdout_global", + "layerId": "10420273888", + "trafficAllocation": [ + ["entityId": "holdout_global_variation", "endOfRange": 500] + ], + "audienceIds": [kAudienceIdCountry], + "variations": [ + [ + "variables": [], + "id": "holdout_global_variation", + "key": "global_variation" + ] + ], + "includedFlags": [], + "excludedFlags": [] + ] + } + + var sampleHoldoutIncluded: [String: Any] { + return [ + "status": "Running", + "id": "holdout_included", + "key": "holdout_included", + "layerId": "10420273889", + "trafficAllocation": [ + ["entityId": "holdout_included_variation", "endOfRange": 1000] + ], + "audienceIds": [kAudienceIdCountry], + "variations": [ + [ + "variables": [], + "id": "holdout_included_variation", + "key": "included_variation" + ] + ], + "includedFlags": ["flag_id_1234"], + "excludedFlags": [] + ] + } + + var sampleHoldoutExcluded: [String: Any] { + return [ + "status": "Running", + "id": "holdout_excluded", + "key": "holdout_excluded", + "layerId": "10420273890", + "trafficAllocation": [ + ["entityId": "holdout_excluded_variation", "endOfRange": 1000] + ], + "audienceIds": [kAudienceIdCountry], + "variations": [ + [ + "variables": [], + "id": "holdout_excluded_variation", + "key": "excluded_variation" + ] + ], + "includedFlags": [], + "excludedFlags": ["flag_id_1234"] + ] + } + + // MARK: - Setup + + override func setUp() { + super.setUp() + + self.optimizely = OTUtils.createOptimizely(datafileName: "empty_datafile", + clearUserProfileService: true) + self.config = self.optimizely.config! + + // Mock bucketer to ensure user would bucket in holdout + let mockBucketer = MockBucketer(mockBucketValue: 500) // Within holdout range + self.mockDecisionService = MockDecisionService(bucketer: mockBucketer) + self.optimizely.decisionService = mockDecisionService + + // Project config + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + holdout = try! OTUtils.model(from: sampleHoldout) + var experiment: Experiment = try! OTUtils.model(from: sampleExperimentData) + experiment.audienceIds = [kAudienceIdCountry] + self.config.project.experiments = [experiment] + + featureFlag = try! OTUtils.model(from: sampleFeatureFlagData) + self.config.project.featureFlags = [featureFlag] + self.config.project.holdouts = [holdout] + } + +} + +// MARK: - Test doesMeetAudienceConditions() + +extension DecisionServiceTests_Holdouts { + + func testDoesMeetAudienceConditionsWithAudienceConditions() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + + // (1) matching true + + holdout = try! OTUtils.model(from: sampleHoldout) + holdout.audienceConditions = try! OTUtils.model(from: ["or", kAudienceIdCountry]) + holdout.audienceIds = [kAudienceIdAge] + self.config.project.holdouts = [holdout] + + var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result + XCTAssert(result, "attribute should be matched to audienceConditions") + + // (2) matching false + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryNotMatch)).result + XCTAssertFalse(result, "attribute should be matched to audienceConditions") + + // (3) other attribute + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesAgeMatch)).result + XCTAssertFalse(result, "no matching attribute provided") + } + + func testDoesMeetAudienceConditionsWithAudienceIds() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + + // (1) matching true + + holdout = try! OTUtils.model(from: sampleHoldout) + holdout.audienceConditions = nil + holdout.audienceIds = [kAudienceIdCountry] + self.config.project.holdouts = [holdout] + + var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result + XCTAssert(result, "attribute should be matched to audienceConditions") + + // (2) matching false + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryNotMatch)).result + XCTAssertFalse(result, "attribute should be matched to audienceConditions") + + // (3) other attribute + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesAgeMatch)).result + XCTAssertFalse(result, "no matching attribute provided") + } + + func testDoesMeetAudienceConditionsWithAudienceConditionsEmptyArray() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + holdout = try! OTUtils.model(from: sampleHoldout) + holdout.audienceConditions = try! OTUtils.model(from: []) + holdout.audienceIds = [kAudienceIdAge] + self.config.project.holdouts = [holdout] + + let result: Bool! = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result + XCTAssert(result, "empty conditions is true always") + } + + func testDoesMeetAudienceConditionsWithAudienceIdsEmpty() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + holdout = try! OTUtils.model(from: sampleHoldout) + holdout.audienceConditions = nil + holdout.audienceIds = [] + self.config.project.holdouts = [holdout] + + let result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result + XCTAssert(result, "empty conditions is true always") + } + + func testDoesMeetAudienceConditionsWithCornerCases() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + holdout = try! OTUtils.model(from: sampleHoldout) + + // (1) leaf (not array) in "audienceConditions" still works ok + + // JSON does not support raw string, so wrap in array for decode + var array: [ConditionHolder] = try! OTUtils.model(from: [kAudienceIdCountry]) + holdout.audienceConditions = array[0] + holdout.audienceIds = [kAudienceIdAge] + self.config.project.holdouts = [holdout] + + var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result + XCTAssert(result) + + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesEmpty)).result + XCTAssertFalse(result) + + // (2) invalid string in "audienceConditions" + array = try! OTUtils.model(from: ["and"]) + holdout.audienceConditions = array[0] + self.config.project.holdouts = [holdout] + + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result + XCTAssert(result) + + // (2) invalid string in "audienceConditions" + holdout.audienceConditions = nil + holdout.audienceIds = [] + self.config.project.holdouts = [holdout] + + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result + XCTAssert(result) + } +} + + +// MARK: - Test getVariationForFeatureExperiment + +extension DecisionServiceTests_Holdouts { + func testGetVariationForFeatureExperiment_HoldoutMatch() { + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + XCTAssertNotNil(decision, "Decision should not be nil") + XCTAssertEqual(decision?.experiment?.id, holdout.id, "Should return holdout experiment") + XCTAssertEqual(decision?.variation.key, "holdout_a", "Should return holdout variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue, "Source should be holdout") + } + + func testGetVariationForFeatureExperiment_HoldoutAudienceMismatch() { + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) + ).result + + // Should fall back to experiment, but experiment also requires country match + XCTAssertNil(decision, "Decision should be nil due to audience mismatch for both holdout and experiment") + } + + func testGetVariationForFeatureExperiment_HoldoutNotBucketed() { + // Mock bucketer to ensure user is not bucketed into holdout variation + let mockBucketer = MockBucketer(mockBucketValue: 1500) // Outside holdout range (0-1000) + mockDecisionService = MockDecisionService(bucketer: mockBucketer) + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should fall back to experiment and bucket into variation D + XCTAssertNotNil(decision, "Decision should not be nil") + XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") + XCTAssertEqual(decision?.variation.key, kVariationKeyD, "Should return experiment variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should be featureTest") + } + + + func testGetVariationForFeatureExperiment_HoldoutInactive() { + // Set holdout to inactive + var modifiedHoldoutData = sampleHoldout + modifiedHoldoutData["status"] = "Draft" + let inactiveHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout + self.config.project.holdouts = [inactiveHoldout] + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should skip holdout and bucket into experiment + XCTAssertNotNil(decision, "Decision should not be nil") + XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") + XCTAssertEqual(decision?.variation.key, kVariationKeyD, "Should return experiment variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should be featureTest") + } + + + func testGetVariationForFeatureExperiment_NoHoldouts() { + // Remove holdouts + self.config.project.holdouts = [] + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should bucket into experiment + XCTAssertNotNil(decision, "Decision should not personally identifiable informationnil") + XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") + XCTAssertEqual(decision?.variation.key, kVariationKeyD, "Should return experiment variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should be featureTest") + } + + func testGetVariationForFeatureExperiment_NoExperiments() { + // Set feature flag with no experiment IDs + var modifiedFeatureFlagData = sampleFeatureFlagData + modifiedFeatureFlagData["experimentIds"] = [] + featureFlag = try! OTUtils.model(from: modifiedFeatureFlagData) + self.config.project.featureFlags = [featureFlag] + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should return holdout decision + XCTAssertNotNil(decision, "Decision should not be nil") + XCTAssertEqual(decision?.experiment?.id, holdout.id, "Should return holdout experiment") + XCTAssertEqual(decision?.variation.key, "holdout_a", "Should return holdout variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue, "Source should be holdout") + } + + func testGetVariationForFeatureExperiment_InvalidExperimentIds() { + // Set feature flag with invalid experiment IDs + var modifiedFeatureFlagData = sampleFeatureFlagData + modifiedFeatureFlagData["experimentIds"] = ["invalid_experiment_id"] + featureFlag = try! OTUtils.model(from: modifiedFeatureFlagData) + self.config.project.featureFlags = [featureFlag] + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should return holdout decision + XCTAssertNotNil(decision, "Decision should not be nil") + XCTAssertEqual(decision?.experiment?.id, holdout.id, "Should return holdout experiment") + XCTAssertEqual(decision?.variation.key, "holdout_a", "Should return holdout variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue, "Source should be holdout") + } + + func testGetVariationForFeatureExperiment_HoldoutExcludedFlag() { + // Modify holdout to exclude the feature flag + var modifiedHoldoutData = sampleHoldout + modifiedHoldoutData["includedFlags"] = [] + modifiedHoldoutData["excludedFlags"] = ["flag_id_1234"] + let excludedHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout + self.config.project.holdouts = [excludedHoldout] + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should skip holdout and bucket into experiment + XCTAssertNotNil(decision, "Decision should not be nil") + XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") + XCTAssertEqual(decision?.variation.key, kVariationKeyD, "Should return experiment variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should Westhill") + } + + func testGetVariationForFeatureExperiment_MultipleHoldoutsWithOrdering() { + // Setup multiple holdouts: global, included, excluded + let tfAllocationRange = 1500 + var globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout + globalHoldout.trafficAllocation[0].endOfRange = tfAllocationRange + + var includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout + includedHoldout.trafficAllocation[0].endOfRange = tfAllocationRange + var excludedHoldout = try! OTUtils.model(from: sampleHoldoutExcluded) as Holdout + excludedHoldout.trafficAllocation[0].endOfRange = tfAllocationRange + + self.config.project.holdouts = [globalHoldout, includedHoldout, excludedHoldout] + + // Mock bucketer to bucket into the first valid holdout (global) + let mockBucketer = MockBucketer(mockBucketValue: 1000) // Within all holdout ranges + let mockDecisionService = MockDecisionService(bucketer: mockBucketer) + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should select global holdout first (ordering: global > included) + XCTAssertNotNil(decision) + XCTAssertEqual(decision?.experiment?.id, globalHoldout.id, "Should select global holdout first") + XCTAssertEqual(decision?.variation.key, "global_variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue) + } + + + func testGetVariationForFeatureExperiment_GlobalHoldoutFailsThenIncluded() { + // Setup multiple holdouts + let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout + let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout + self.config.project.holdouts = [globalHoldout, includedHoldout] + + // Mock bucketer to fail global holdout bucketing, succeed for included + let mockBucketer = MockBucketer(mockBucketValue: 700) // Outside global range, within included range + mockDecisionService = MockDecisionService(bucketer: mockBucketer) + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Global holdout fails bucketing, should select included holdout + XCTAssertNotNil(decision) + XCTAssertEqual(decision?.experiment?.id, includedHoldout.id, "Should select included holdout") + XCTAssertEqual(decision?.variation.key, "included_variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue) + } + + func testGetVariationForFeatureExperiment_AllHoldoutsFailThenExperiment() { + // Setup multiple holdouts + let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout + let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout + let excludedHoldout = try! OTUtils.model(from: sampleHoldoutExcluded) as Holdout + self.config.project.holdouts = [globalHoldout, includedHoldout, excludedHoldout] + + // Mock bucketer to fail all holdout bucketing + let mockBucketer = MockBucketer(mockBucketValue: 1500) // Outside all holdout ranges + mockDecisionService = MockDecisionService(bucketer: mockBucketer) + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // All holdouts fail, should fall back to experiment + XCTAssertNotNil(decision) + XCTAssertEqual(decision?.experiment?.id, kExperimentId) + XCTAssertEqual(decision?.variation.key, kVariationKeyD) + XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue) + } + + func testGetVariationForFeatureExperiment_HoldoutWithNoTrafficAllocation() { + // Setup holdout with no traffic allocation + var modifiedHoldoutData = sampleHoldoutGlobal + modifiedHoldoutData["trafficAllocation"] = [] + let noTrafficHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout + self.config.project.holdouts = [noTrafficHoldout] + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Holdout has no traffic allocation, should fall back to experiment + XCTAssertNotNil(decision) + XCTAssertEqual(decision?.experiment?.id, kExperimentId) + XCTAssertEqual(decision?.variation.key, kVariationKeyD) + XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue) + } + + func testGetVariationForFeatureExperiment_MixedAudienceAndBucketingFailures() { + // Setup multiple holdouts with different audience conditions + var globalHoldoutData = sampleHoldoutGlobal + globalHoldoutData["audienceIds"] = [kAudienceIdAge] // Requires age > 17 + let globalHoldout = try! OTUtils.model(from: globalHoldoutData) as Holdout + + let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout // Requires country: "us" + + self.config.project.holdouts = [globalHoldout, includedHoldout] + + // Mock bucketer to fail included holdout bucketing + let mockBucketer = MockBucketer(mockBucketValue: 1500) // Outside included holdout range + mockDecisionService = MockDecisionService(bucketer: mockBucketer) + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) + ).result + + // Global holdout passes audience (age not specified, defaults to true), but fails bucketing + // Included holdout fails audience (country: "ca") + // Falls back to experiment, but experiment also fails audience + XCTAssertNil(decision) + } + + func testGetVariationForFeatureExperiment_EmptyVariationsInHoldout() { + // Setup holdout with no variations + var modifiedHoldoutData = sampleHoldoutGlobal + modifiedHoldoutData["variations"] = [] + let noVariationsHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout + self.config.project.holdouts = [noVariationsHoldout] + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Holdout has no variations, should fall back to experiment + XCTAssertNotNil(decision) + XCTAssertEqual(decision?.experiment?.id, kExperimentId) + XCTAssertEqual(decision?.variation.key, kVariationKeyD) + XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue) + } + + func testGetVariationForFeatureExperiment_CacheConsistency() { + // Setup multiple holdouts + let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout + let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout + self.config.project.holdouts = [globalHoldout, includedHoldout] + + // First call + let decision1 = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Second call with same inputs + let decision2 = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should consistently return global holdout + XCTAssertNotNil(decision1) + XCTAssertNotNil(decision2) + XCTAssertEqual(decision1?.experiment?.id, includedHoldout.id) + XCTAssertEqual(decision2?.experiment?.id, includedHoldout.id) + XCTAssertEqual(decision1?.variation.key, "included_variation") + XCTAssertEqual(decision2?.variation.key, "included_variation") + XCTAssertEqual(decision1?.source, Constants.DecisionSource.holdout.rawValue) + XCTAssertEqual(decision2?.source, Constants.DecisionSource.holdout.rawValue) + } +} diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift new file mode 100644 index 00000000..9820f6cd --- /dev/null +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift @@ -0,0 +1,564 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { + let kUserId = "tester" + var optimizely: OptimizelyClient! + var eventDispatcher = MockEventDispatcher() + + var kAttributesCountryMatch: [String: Any] = ["country": "US"] + var kAttributesCountryNotMatch: [String: Any] = ["country": "ca"] + + var sampleHoldout: [String: Any] { + return [ + "status": "Running", + "id": "id_holdout", + "key": "key_holdout", + "layerId": "10420273888", + "trafficAllocation": [ + ["entityId": "id_holdout_variation", "endOfRange": 500] + ], + "audienceIds": [], + "variations": [ + [ + "variables": [], + "id": "id_holdout_variation", + "key": "key_holdout_variation" + ] + ], + "includedFlags": [], + "excludedFlags": [] + ] + } + + override func setUp() { + super.setUp() + + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, + eventDispatcher: eventDispatcher, + userProfileService: OTUtils.createClearUserProfileService()) + + try! optimizely.start(datafile: OTUtils.loadJSONDatafile("decide_datafile")!) + } + + func test_decide_with_global_holdout_audience_matched() { + let featureKey = "feature_1" + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + // Audience "13389130056" requires "country" = "US" + holdout.audienceIds = ["13389130056"] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + optimizely.config!.project.holdouts = [holdout] + + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + // Call decide with reasons + let decision = user.decide(key: featureKey) + + XCTAssert(decision == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected, + ruleKey: "key_holdout", + flagKey: featureKey, + userContext: user, + reasons: [])) + + + } + + func test_decide_with_gloabl_holdout_audience_mis_matched() { + let featureKey = "feature_2" + let featureKeys = [featureKey] + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + // Audience "13389130056" requires "country" = "US" + holdout.audienceIds = ["13389130056"] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + optimizely.config!.project.holdouts = [holdout] + + + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) + let decisions = user.decide(keys: featureKeys) + + XCTAssert(decisions.count == 1) + let decision = decisions[featureKey]! + + let expDecision = OptimizelyDecision(variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected, + ruleKey: "exp_no_audience", + flagKey: featureKey, + userContext: user, + reasons: []) + XCTAssertEqual(decision, expDecision) + } + + func testDecide_ForNullVariation() { + let featureKey = "feature_2" + let featureKeys = [featureKey] + var null_Variation_json = sampleHoldout + null_Variation_json["variations"] = [] + + let holdout = try! OTUtils.model(from: null_Variation_json) as Holdout + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + optimizely.config!.project.holdouts = [holdout] + + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) + let decisions = user.decide(keys: featureKeys) + + XCTAssert(decisions.count == 1) + let decision = decisions[featureKey]! + + let expDecision = OptimizelyDecision(variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected, + ruleKey: "exp_no_audience", + flagKey: featureKey, + userContext: user, + reasons: []) + XCTAssertEqual(decision, expDecision) + } + + + func testDecide_with_holdout_options_excludeVariables() { + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + + let featureKey = "feature_1" + + let user = optimizely.createUserContext(userId: kUserId) + let decision = user.decide(key: featureKey,options: [.excludeVariables]) + XCTAssertEqual(decision.variationKey, "key_holdout_variation") + XCTAssertFalse(decision.enabled) + XCTAssertTrue(decision.variables.isEmpty) + } + + func testDecide_defaultDecideOption() { + let featureKey = "feature_2" + let feature_id = "4482920078" + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedFlags = [feature_id] + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) + + var user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + var decision = user.decide(key: featureKey) + + XCTAssert(decision == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected, + ruleKey: "key_holdout", + flagKey: featureKey, + userContext: user, + reasons: [])) + + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, + defaultDecideOptions: [.excludeVariables]) + + try! optimizely.start(datafile: OTUtils.loadJSONDatafile("decide_datafile")!) + optimizely.config!.project.holdouts = [holdout] + + user = optimizely.createUserContext(userId: kUserId) + decision = user.decide(key: featureKey) + + XCTAssertTrue(decision.variables.isEmpty) + + } + + func test_decide_with_holdout_included_flags() { + let featureKey1 = "feature_1" + let feature1_Id = "4482920077" + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedFlags = [feature1_Id] + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let user = optimizely.createUserContext(userId: kUserId) + + let decision1 = user.decide(key: featureKey1) + + XCTAssert(decision1 == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected1, + ruleKey: "key_holdout", + flagKey: featureKey1, + userContext: user, + reasons: [])) + } + + func test_decide_for_keys_with_holdout_included_flags() { + let featureKey1 = "feature_1" + let feature1_Id = "4482920077" + let featureKey2 = "feature_2" + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedFlags = [feature1_Id] + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) + let user = optimizely.createUserContext(userId: kUserId) + + let decisions = user.decide(keys: [featureKey1, featureKey2]) + + XCTAssert(decisions.count == 2) + + XCTAssert(decisions[featureKey1]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected1, + ruleKey: "key_holdout", + flagKey: featureKey1, + userContext: user, + reasons: [])) + + XCTAssert(decisions[featureKey2]! == OptimizelyDecision(variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected2, + ruleKey: "exp_no_audience", + flagKey: featureKey2, + userContext: user, + reasons: [])) + } +} + +// MARK:- Decide All + +extension OptimizelyUserContextTests_Decide_Holdouts { + func testDecideAll_with_global_holdout() { + let featureKey1 = "feature_1" + let featureKey2 = "feature_2" + let featureKey3 = "feature_3" + + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) + let variablesExpected3 = OptimizelyJSON.createEmpty() + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + let decisions = user.decideAll() + + XCTAssert(decisions.count == 3) + + XCTAssert(decisions[featureKey1]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected1, + ruleKey: "key_holdout", + flagKey: featureKey1, + userContext: user, + reasons: [])) + XCTAssert(decisions[featureKey2]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected2, + ruleKey: "key_holdout", + flagKey: featureKey2, + userContext: user, + reasons: [])) + XCTAssert(decisions[featureKey3]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected3, + ruleKey: "key_holdout", + flagKey: featureKey3, + userContext: user, + reasons: [])) + } + + func testDecideAll_with_holdout_included_flags() { + let featureKey1 = "feature_1" + let featureKey2 = "feature_2" + let feature2_id = "4482920078" + let featureKey3 = "feature_3" + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedFlags = [feature2_id] + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) + let variablesExpected3 = OptimizelyJSON.createEmpty() + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + let decisions = user.decideAll() + + XCTAssert(decisions.count == 3) + + XCTAssert(decisions[featureKey1]! == OptimizelyDecision(variationKey: "a", + enabled: true, + variables: variablesExpected1, + ruleKey: "exp_with_audience", + flagKey: featureKey1, + userContext: user, + reasons: [])) + XCTAssert(decisions[featureKey2]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected2, + ruleKey: "key_holdout", + flagKey: featureKey2, + userContext: user, + reasons: [])) + XCTAssert(decisions[featureKey3]! == OptimizelyDecision(variationKey: nil, + enabled: false, + variables: variablesExpected3, + ruleKey: nil, + flagKey: featureKey3, + userContext: user, + reasons: [])) + } + + func testDecideAll_with_holdout_excluded_flags() { + let featureKey1 = "feature_1" + let featureKey2 = "feature_2" + let feature2_id = "4482920078" + let featureKey3 = "feature_3" + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.excludedFlags = [feature2_id] + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) + let variablesExpected3 = OptimizelyJSON.createEmpty() + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + let decisions = user.decideAll() + + XCTAssert(decisions.count == 3) + + XCTAssert(decisions[featureKey1]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected1, + ruleKey: "key_holdout", + flagKey: featureKey1, + userContext: user, + reasons: [])) + XCTAssert(decisions[featureKey2]! == OptimizelyDecision(variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected2, + ruleKey: "exp_no_audience", + flagKey: featureKey2, + userContext: user, + reasons: [])) + XCTAssert(decisions[featureKey3]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected3, + ruleKey: "key_holdout", + flagKey: featureKey3, + userContext: user, + reasons: [])) + } + + func testDecideAll_with_multiple_holdouts() { + let feature1 = (key: "feature_1", id: "4482920077") + let feature2 = (key: "feature_2", id: "4482920078") + let feature3 = (key: "feature_3", id: "44829230000") + + /// Applicable to feature (1, 2, 3) + let gHoldout = try! OTUtils.model(from: sampleHoldout) as Holdout + + var includedHoldout = gHoldout + includedHoldout.id = "holdout_id_included" + includedHoldout.key = "holdout_key_included" + includedHoldout.trafficAllocation[0].endOfRange = 2000 + /// Applicable to feature 2 + includedHoldout.includedFlags = [feature2.id] + + var excludedHoldout = gHoldout + excludedHoldout.id = "holdout_id_excluded" + excludedHoldout.key = "holdout_key_excluded" + /// Applicable to feature 3 + excludedHoldout.excludedFlags = [feature1.id, feature2.id] + excludedHoldout.trafficAllocation[0].endOfRange = 2000 + + optimizely.config!.project.holdouts = [gHoldout, includedHoldout, excludedHoldout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 1000)) + optimizely.decisionService = mockDecisionService + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: feature1.key, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: feature2.key, userId: kUserId) + let variablesExpected3 = OptimizelyJSON.createEmpty() + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + let decisions = user.decideAll() + + XCTAssert(decisions.count == 3) + + XCTAssert(decisions[feature1.key]! == OptimizelyDecision(variationKey: "a", + enabled: true, + variables: variablesExpected1, + ruleKey: "exp_with_audience", + flagKey: feature1.key, + userContext: user, + reasons: [])) + XCTAssert(decisions[feature2.key]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected2, + ruleKey: "holdout_key_included", + flagKey: feature2.key, + userContext: user, + reasons: [])) + XCTAssert(decisions[feature3.key]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected3, + ruleKey: "holdout_key_excluded", + flagKey: feature3.key, + userContext: user, + reasons: [])) + } + + func testDecideAll_with_holdouts_options_enabledFlagsOnly() { + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + let decisions = user.decideAll(options: [.enabledFlagsOnly]) + + XCTAssert(decisions.count == 0) + } +} + +// MARK: - impression events + +extension OptimizelyUserContextTests_Decide_Holdouts { + func testDecide_sendImpression() { + let featureKey = "feature_2" + + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: kUserId) + let decision = user.decide(key: featureKey) + + optimizely.eventLock.sync{} + + XCTAssertEqual(decision.variationKey, "key_holdout_variation") + XCTAssertFalse(decision.enabled) + XCTAssertFalse(eventDispatcher.events.isEmpty) + + let eventSent = eventDispatcher.events.first! + let event = try! JSONDecoder().decode(BatchEvent.self, from: eventSent.body) + let eventDecision: Decision = event.visitors[0].snapshots[0].decisions![0] + let metadata = eventDecision.metaData + + let desc = eventSent.description + XCTAssert(desc.contains("campaign_activated")) + + XCTAssertEqual(eventDecision.experimentID, "id_holdout") + XCTAssertEqual(eventDecision.variationID, "id_holdout_variation") + + XCTAssertEqual(metadata.flagKey, "feature_2") + XCTAssertEqual(metadata.ruleKey, "key_holdout") + XCTAssertEqual(metadata.ruleType, "holdout") + XCTAssertEqual(metadata.variationKey, "key_holdout_variation") + XCTAssertEqual(metadata.enabled, false) + } + + func testDecideError_doNotSendImpression() { + let featureKey = "invalid" // invalid flag + + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: kUserId) + let decision = user.decide(key: featureKey) + + optimizely.eventLock.sync{} + + XCTAssertNil(decision.variationKey) + XCTAssertFalse(decision.enabled) + XCTAssert(eventDispatcher.events.isEmpty) + } + + func testDecide_sendImpression_with_disable_tracking() { + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let featureKey = "feature_2" + + let user = optimizely.createUserContext(userId: kUserId) + let decision = user.decide(key: featureKey, options: [.disableDecisionEvent]) + XCTAssertEqual(decision.variationKey, "key_holdout_variation") + XCTAssertFalse(decision.enabled) + optimizely.eventLock.sync{} + XCTAssert(eventDispatcher.events.isEmpty) + } + + func testDecide_sendImpression_withSendFlagDecisionsOff() { + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + optimizely.config?.project.sendFlagDecisions = false + + let featureKey = "feature_2" + + let user = optimizely.createUserContext(userId: kUserId) + let decision = user.decide(key: featureKey) + XCTAssertEqual(decision.variationKey, "key_holdout_variation") + XCTAssertFalse(decision.enabled) + optimizely.eventLock.sync{} + XCTAssert(eventDispatcher.events.isEmpty) + } +} diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift index a08ba0e5..7ece916e 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift @@ -17,7 +17,6 @@ import XCTest class OptimizelyUserContextTests_Decide_Reasons: XCTestCase { - let kUserId = "tester" var optimizely: OptimizelyClient! diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift new file mode 100644 index 00000000..ff93122f --- /dev/null +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift @@ -0,0 +1,199 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { + let kUserId = "tester" + var optimizely: OptimizelyClient! + + var kAttributesCountryMatch: [String: Any] = ["country": "US"] + var kAttributesCountryNotMatch: [String: Any] = ["country": "ca"] + + var sampleHoldout: [String: Any] { + return [ + "status": "Running", + "id": "id_holdout", + "key": "key_holdout", + "layerId": "10420273888", + "trafficAllocation": [ + ["entityId": "id_holdout_variation", "endOfRange": 500] + ], + "audienceIds": [], + "variations": [ + [ + "variables": [], + "id": "id_holdout_variation", + "key": "key_holdout_variation" + ] + ], + "includedFlags": [], + "excludedFlags": [] + ] + } + + override func setUp() { + super.setUp() + + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, + userProfileService: OTUtils.createClearUserProfileService()) + + try! optimizely.start(datafile: OTUtils.loadJSONDatafile("decide_datafile")!) + } + + /// Test when user is bucketed into the global holdout + func testDecideReasons_userBucketedIntoGlobalHoldout() { + let featureKey = "feature_1" + + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: kUserId) + // Call decide with reasons + let decision = user.decide(key: featureKey, options: [.includeReasons]) + // Assertions + XCTAssertEqual(decision.flagKey, "feature_1", "Expected flagKey to be 'feature_1'") + XCTAssertEqual(decision.variationKey, "key_holdout_variation", "Expected variationKey to be 'key_holdout_variation'") + XCTAssertFalse(decision.enabled, "Feature should be disabled in holdout") + XCTAssert(decision.reasons.contains(LogMessage.userBucketedIntoVariationInHoldout(kUserId, "key_holdout", "key_holdout_variation").reason)) + } + + /// Test when user is bucketed into the included flags holdout for feature_1 + func testDecideReasons_userBucketedIntoIncludedHoldout() { + let featureKey = "feature_1" + let featureId = "4482920077" + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedFlags = [featureId] + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: kUserId) + // Call decide with reasons + let decision = user.decide(key: featureKey, options: [.includeReasons]) + // Assertions + XCTAssertEqual(decision.flagKey, "feature_1", "Expected flagKey to be 'feature_1'") + XCTAssertEqual(decision.variationKey, "key_holdout_variation", "Expected variationKey to be 'key_holdout_variation'") + XCTAssertFalse(decision.enabled, "Feature should be disabled in holdout") + XCTAssert(decision.reasons.contains(LogMessage.userBucketedIntoVariationInHoldout(kUserId, "key_holdout", "key_holdout_variation").reason)) + } + + /// Test when user is not bucketed into any holdout for feature_2 (excluded) + func testDecideReasons_userNotBucketedIntoExcludedHoldout() { + // Global holdout with 5% traffice + let holdout1 = try! OTUtils.model(from: sampleHoldout) as Holdout + + let featureKey_2 = "feature_2" + let featureId_2 = "4482920078" + + var holdout2 = holdout1 + holdout2.id = "id_holdout_2" + holdout2.key = "key_holdout_2" + + // Global holdout with 10% traffice (featureId_2 excluded) + holdout2.trafficAllocation[0].endOfRange = 1000 + holdout2.excludedFlags = [featureId_2] + + // Bucket valud outside global holdout range but inside second holdout range + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 600)) + optimizely.decisionService = mockDecisionService + optimizely.config!.project.holdouts = [holdout1, holdout2] + + let user = optimizely.createUserContext(userId: kUserId) + // Call decide with reasons + let decision = user.decide(key: featureKey_2, options: [.includeReasons]) + + // Assertions + XCTAssertEqual(decision.flagKey, "feature_2", "Expected flagKey to be 'feature_2'") + XCTAssert(decision.reasons.contains(LogMessage.userNotBucketedIntoHoldoutVariation(kUserId).reason)) + } + + /// Test when holdout is not running + func testDecideReasons_holdoutNotRunning() { + let featureKey = "feature_1" + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.status = .draft + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: kUserId) + + // Call decide with reasons + let decision = user.decide(key: featureKey, options: [.includeReasons]) + + /// Doesn't get holdout decision, because holdout isn't running + /// Get decision for feature flag 1 + XCTAssertEqual(decision.flagKey, "feature_1", "Expected flagKey to be 'feature_1'") + XCTAssertEqual(decision.ruleKey, "18322080788") + XCTAssertEqual(decision.variationKey, "18257766532") + XCTAssertTrue(decision.enabled) + XCTAssert(decision.reasons.contains(LogMessage.holdoutNotRunning("key_holdout").reason)) + } + + + /// Test when user meets audience conditions for holdout + func testDecideReasons_userDoesMeetConditionsForHoldout() { + let featureKey = "feature_1" + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + // Audience "13389130056" requires "country" = "US" + holdout.audienceIds = ["13389130056"] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + optimizely.config!.project.holdouts = [holdout] + + + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + // Call decide with reasons + let decision = user.decide(key: featureKey, options: [.includeReasons]) + + // Assertions + XCTAssertEqual(decision.flagKey, "feature_1", "Expected flagKey to be 'feature_1'") + XCTAssertEqual(decision.variationKey, "key_holdout_variation", "Expected variationKey to be 'key_holdout_variation'") + XCTAssertFalse(decision.enabled, "Feature should be disabled in holdout") + XCTAssert(decision.reasons.contains(LogMessage.userBucketedIntoVariationInHoldout(kUserId, "key_holdout", "key_holdout_variation").reason)) + XCTAssert(decision.reasons.contains(LogMessage.userMeetsConditionsForHoldout(kUserId, "key_holdout").reason)) + } + + /// Test when user does not meet audience conditions for holdout + func testDecideReasons_userDoesntMeetConditionsForHoldout() { + let featureKey = "feature_1" + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + // Audience "13389130056" requires "country" = "US" + holdout.audienceIds = ["13389130056"] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + optimizely.config!.project.holdouts = [holdout] + + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) + // Call decide with reasons + let decision = user.decide(key: featureKey, options: [.includeReasons]) + + XCTAssertEqual(decision.flagKey, "feature_1", "Expected flagKey to be 'feature_1'") + XCTAssertNotEqual(decision.variationKey, "key_holdout_variation", "Expected variationKey not to be 'key_holdout_variation'") + XCTAssertFalse(decision.reasons.contains(LogMessage.userBucketedIntoVariationInHoldout(kUserId, "key_holdout", "key_holdout_variation").reason)) + XCTAssert(decision.reasons.contains(LogMessage.userDoesntMeetConditionsForHoldout(kUserId, "key_holdout").reason)) + } +} diff --git a/Tests/OptimizelyTests-DataModel/HoldoutTests.swift b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift index 8e5f6e3a..aba7150d 100644 --- a/Tests/OptimizelyTests-DataModel/HoldoutTests.swift +++ b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift @@ -21,9 +21,8 @@ import XCTest class HoldoutTests: XCTestCase { static var variationData: [String: Any] = ["id": "553339214", - "key": "house", - "featureEnabled": true, - "variables": [["id": "553339214", "value": "100"]]] + "key": "house", + "featureEnabled": true] static var trafficAllocationData: [String: Any] = ["entityId": "553339214", "endOfRange": 5000] diff --git a/Tests/TestUtils/MockBucketer.swift b/Tests/TestUtils/MockBucketer.swift new file mode 100644 index 00000000..e8724407 --- /dev/null +++ b/Tests/TestUtils/MockBucketer.swift @@ -0,0 +1,40 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + + +// MARK: - Helper for mocking bucketer + +class MockBucketer: DefaultBucketer { + var mockBucketValue: Int + + init(mockBucketValue: Int) { + self.mockBucketValue = mockBucketValue + super.init() + } + + override func generateBucketValue(bucketingId: String) -> Int { + return mockBucketValue + } +} + +// MARK: - Mock Decision Service + +class MockDecisionService: DefaultDecisionService { + init(bucketer: OPTBucketer, userProfileService: OPTUserProfileService = DefaultUserProfileService()) { + super.init(userProfileService: userProfileService, bucketer: bucketer) + } +} + From deb76cd5ffe87be90e1f4e492a0b4aec6556ac1a Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Thu, 15 May 2025 12:38:12 +0600 Subject: [PATCH 26/37] [FSSDK-11374] add test cases for impression event and decision listener with holdout support (#588) Add test cases for impression event and decision listener --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 6 + Sources/Optimizely/OptimizelyClient.swift | 6 +- .../BatchEventBuilderTests_Events.swift | 191 +++++++++++- .../DecisionListenerTest_Holdouts.swift | 290 ++++++++++++++++++ ...zelyUserContextTests_Decide_Holdouts.swift | 2 +- .../HoldoutTests.swift | 2 +- 6 files changed, 493 insertions(+), 4 deletions(-) create mode 100644 Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index db67fedf..e58a3625 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2046,6 +2046,8 @@ 989428BC2DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; 989428BD2DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; 989428BE2DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428C02DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428BF2DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift */; }; + 989428C12DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428BF2DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift */; }; 98AC97E22DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; 98AC97E32DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; 98AC97E42DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; @@ -2516,6 +2518,7 @@ 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileTracker.swift; sourceTree = ""; }; 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 989428B22DBFA431008BA1C8 /* MockBucketer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBucketer.swift; sourceTree = ""; }; + 989428BF2DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecisionListenerTest_Holdouts.swift; sourceTree = ""; }; 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutConfig.swift; sourceTree = ""; }; 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutConfigTests.swift; sourceTree = ""; }; 98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketTests_HoldoutToVariation.swift; sourceTree = ""; }; @@ -3033,6 +3036,7 @@ 6E75199622C5211100B2B157 /* DatafileHandlerTests.swift */, 6E75199822C5211100B2B157 /* DataStoreTests.swift */, 6E75199022C5211100B2B157 /* DecisionListenerTests.swift */, + 989428BF2DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift */, 6E981FC1232C363300FADDD6 /* DecisionListenerTests_Datafile.swift */, 6E27ECBD266FD78600B4A6D4 /* DecisionReasonsTests.swift */, 6E75198022C5211100B2B157 /* DecisionServiceTests_Experiments.swift */, @@ -4910,6 +4914,7 @@ 6EC6DD4C24ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */, 8464087D28130D3200CCF97D /* Integration.swift in Sources */, 6E7517E922C520D400B2B157 /* DefaultDecisionService.swift in Sources */, + 989428C02DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift in Sources */, 6E5D12282638DDF4000ABFC3 /* MockEventDispatcher.swift in Sources */, 6E9B116A22C5487100C22D81 /* BucketTests_Base.swift in Sources */, 6E9B115F22C5487100C22D81 /* MurmurTests.swift in Sources */, @@ -5192,6 +5197,7 @@ 6E7517CB22C520D400B2B157 /* DefaultBucketer.swift in Sources */, 845945C1287758A000D13E11 /* OdpConfig.swift in Sources */, 8464087528130D3200CCF97D /* Integration.swift in Sources */, + 989428C12DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift in Sources */, 6E5D12202638DDF4000ABFC3 /* MockEventDispatcher.swift in Sources */, 6E9B115022C5486E00C22D81 /* BucketTests_Base.swift in Sources */, 6E9B114522C5486E00C22D81 /* MurmurTests.swift in Sources */, diff --git a/Sources/Optimizely/OptimizelyClient.swift b/Sources/Optimizely/OptimizelyClient.swift index 15905a5c..7c7179a4 100644 --- a/Sources/Optimizely/OptimizelyClient.swift +++ b/Sources/Optimizely/OptimizelyClient.swift @@ -801,7 +801,11 @@ extension OptimizelyClient { func shouldSendDecisionEvent(source: String, decision: FeatureDecision?) -> Bool { guard let config = self.config else { return false } - return (source == Constants.DecisionSource.featureTest.rawValue && decision?.variation != nil) || config.sendFlagDecisions + let allowedSources: [String] = [ + Constants.DecisionSource.featureTest.rawValue, + Constants.DecisionSource.holdout.rawValue + ] + return (allowedSources.contains(source) && decision?.variation != nil) || config.sendFlagDecisions } func sendImpressionEvent(experiment: ExperimentCore?, diff --git a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift index f0d6f345..516f9ea3 100644 --- a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift +++ b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift @@ -27,6 +27,26 @@ class BatchEventBuilderTests_Events: XCTestCase { var project: Project! let datafile = OTUtils.loadJSONDatafile("api_datafile")! + var sampleHoldout: [String: Any] { + return [ + "status": "Running", + "id": "holdout_4444444", + "key": "holdout_key", + "layerId": "10420273888", + "trafficAllocation": [ + ["entityId": "holdout_variation_a11", "endOfRange": 10000] // 100% traffic allocation + ], + "audienceIds": [], + "variations": [ + [ + "variables": [], + "id": "holdout_variation_a11", + "key": "holdout_a" + ] + ] + ] + } + override func setUp() { eventDispatcher = MockEventDispatcher() optimizely = OTUtils.createOptimizely(datafileName: "audience_targeting", @@ -38,6 +58,10 @@ class BatchEventBuilderTests_Events: XCTestCase { override func tearDown() { Utils.sdkVersion = OPTIMIZELYSDKVERSION Utils.swiftSdkClientName = "swift-sdk" + optimizely?.close() + optimizely = nil + optimizely?.eventDispatcher = nil + super.tearDown() } func testCreateImpressionEvent() { @@ -461,6 +485,164 @@ extension BatchEventBuilderTests_Events { } } +// MARK:- Holdouts + +extension BatchEventBuilderTests_Events { + func testImpressionEvent_UserInHoldout() { + let eventDispatcher2 = MockEventDispatcher() + var optimizely: OptimizelyClient! = OptimizelyClient(sdkKey: "12345", eventDispatcher: eventDispatcher2) + + try! optimizely.start(datafile: datafile) + + let holdout: Holdout = try! OTUtils.model(from: sampleHoldout) + optimizely.config?.project.holdouts = [holdout] + + let exp = expectation(description: "Wait for event to dispatch") + let user = optimizely.createUserContext(userId: userId) + _ = user.decide(key: featureKey) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + exp.fulfill() + } + + let result = XCTWaiter.wait(for: [exp], timeout: 0.2) + if result == XCTWaiter.Result.completed { + let event = getFirstEventJSON(client: optimizely)! + let visitor = (event["visitors"] as! Array>)[0] + let snapshot = (visitor["snapshots"] as! Array>)[0] + let decision = (snapshot["decisions"] as! Array>)[0] + + let metaData = decision["metadata"] as! Dictionary + XCTAssertEqual(metaData["rule_type"] as! String, Constants.DecisionSource.holdout.rawValue) + XCTAssertEqual(metaData["rule_key"] as! String, "holdout_key") + XCTAssertEqual(metaData["flag_key"] as! String, "feature_1") + XCTAssertEqual(metaData["variation_key"] as! String, "holdout_a") + XCTAssertFalse(metaData["enabled"] as! Bool) + } else { + XCTFail("No event found") + } + + } + + func testImpressionEvent_UserInHoldout_IncludedFlags() { + let eventDispatcher2 = MockEventDispatcher() + var optimizely: OptimizelyClient! = OptimizelyClient(sdkKey: "12345", eventDispatcher: eventDispatcher2) + + try! optimizely.start(datafile: datafile) + + var holdout: Holdout = try! OTUtils.model(from: sampleHoldout) + holdout.includedFlags = ["4482920077"] + optimizely.config?.project.holdouts = [holdout] + + let exp = expectation(description: "Wait for event to dispatch") + + let user = optimizely.createUserContext(userId: userId) + _ = user.decide(key: featureKey) + + + // Add a delay before evaluating getFirstEventJSON + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + exp.fulfill() // Fulfill the expectation after the delay + } + + let result = XCTWaiter.wait(for: [exp], timeout: 0.2) + if result == XCTWaiter.Result.completed { + let event = getFirstEventJSON(client: optimizely)! + let visitor = (event["visitors"] as! Array>)[0] + let snapshot = (visitor["snapshots"] as! Array>)[0] + let decision = (snapshot["decisions"] as! Array>)[0] + + let metaData = decision["metadata"] as! Dictionary + XCTAssertEqual(metaData["rule_type"] as! String, Constants.DecisionSource.holdout.rawValue) + XCTAssertEqual(metaData["rule_key"] as! String, "holdout_key") + XCTAssertEqual(metaData["flag_key"] as! String, "feature_1") + XCTAssertEqual(metaData["variation_key"] as! String, "holdout_a") + XCTAssertFalse(metaData["enabled"] as! Bool) + } else { + XCTFail("No event found") + } + optimizely = nil + + } + + func testImpressionEvent_UserNotInHoldout_ExcludedFlags() { + let eventDispatcher2 = MockEventDispatcher() + var optimizely: OptimizelyClient! = OptimizelyClient(sdkKey: "123456", eventDispatcher: eventDispatcher2) + + try! optimizely.start(datafile: datafile) + + var holdout: Holdout = try! OTUtils.model(from: sampleHoldout) + holdout.excludedFlags = ["4482920077"] + optimizely.config?.project.holdouts = [holdout] + + let exp = expectation(description: "Wait for event to dispatch") + + let user = optimizely.createUserContext(userId: userId) + _ = user.decide(key: featureKey) + + // Add a delay before evaluating getFirstEventJSON + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + exp.fulfill() // Fulfill the expectation after the delay + } + + let result = XCTWaiter.wait(for: [exp], timeout: 0.2) + if result == XCTWaiter.Result.completed { + let event = getFirstEventJSON(client: optimizely)! + let visitor = (event["visitors"] as! Array>)[0] + let snapshot = (visitor["snapshots"] as! Array>)[0] + let decision = (snapshot["decisions"] as! Array>)[0] + + let metaData = decision["metadata"] as! Dictionary + XCTAssertEqual(metaData["rule_type"] as! String, Constants.DecisionSource.featureTest.rawValue) + XCTAssertEqual(metaData["rule_key"] as! String, "exp_with_audience") + XCTAssertEqual(metaData["flag_key"] as! String, "feature_1") + XCTAssertEqual(metaData["variation_key"] as! String, "a") + XCTAssertTrue(metaData["enabled"] as! Bool) + } else { + XCTFail("No event found") + } + } + + func testImpressionEvent_UserNotInHoldout_MissesTrafficAllocation() { + let eventDispatcher2 = MockEventDispatcher() + var optimizely: OptimizelyClient! = OptimizelyClient(sdkKey: "123457", eventDispatcher: eventDispatcher2) + + try! optimizely.start(datafile: datafile) + + var holdout: Holdout = try! OTUtils.model(from: sampleHoldout) + /// Set traffic allocation to gero + holdout.trafficAllocation[0].endOfRange = 0 + holdout.includedFlags = ["4482920077"] + optimizely.config?.project.holdouts = [holdout] + + let exp = expectation(description: "Wait for event to dispatch") + + let user = optimizely.createUserContext(userId: userId) + _ = user.decide(key: featureKey) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + exp.fulfill() // Fulfill the expectation after the delay + } + + let result = XCTWaiter.wait(for: [exp], timeout: 0.2) + if result == XCTWaiter.Result.completed { + let event = getFirstEventJSON(client: optimizely)! + let visitor = (event["visitors"] as! Array>)[0] + let snapshot = (visitor["snapshots"] as! Array>)[0] + let decision = (snapshot["decisions"] as! Array>)[0] + + let metaData = decision["metadata"] as! Dictionary + XCTAssertEqual(metaData["rule_type"] as! String, Constants.DecisionSource.featureTest.rawValue) + XCTAssertEqual(metaData["rule_key"] as! String, "exp_with_audience") + XCTAssertEqual(metaData["flag_key"] as! String, "feature_1") + XCTAssertEqual(metaData["variation_key"] as! String, "a") + XCTAssertTrue(metaData["enabled"] as! Bool) + } else { + XCTFail("No event found") + } + } +} + // MARK: - Utils extension BatchEventBuilderTests_Events { @@ -477,7 +659,14 @@ extension BatchEventBuilderTests_Events { return json } - func getEventJSON(data: Data) -> [String: Any]? { + func getFirstEventJSON(client: OptimizelyClient) -> [String: Any]? { + guard let event = getFirstEvent(dispatcher: client.eventDispatcher as! MockEventDispatcher) else { return nil } + + let json = try! JSONSerialization.jsonObject(with: event.body, options: .allowFragments) as! [String: Any] + return json + } + + func getEventJSON(data: Data) -> [String: Any]? { let json = try! JSONSerialization.jsonObject(with: data, options: .allowFragments) as! [String: Any] return json } diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift new file mode 100644 index 00000000..e62905fb --- /dev/null +++ b/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift @@ -0,0 +1,290 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class DecisionListenerTests_Holdouts: XCTestCase { + let kUserId = "11111" + var optimizely: OptimizelyClient! + var notificationCenter: OPTNotificationCenter! + var eventDispatcher = MockEventDispatcher() + + var kAttributesCountryMatch: [String: Any] = ["country": "US"] + var kAttributesCountryNotMatch: [String: Any] = ["country": "ca"] + + let kFeatureKey = "feature_1" + let kFeatureId = "4482920077" + + let kVariableKeyString = "s_foo" + let kVariableKeyInt = "i_42" + let kVariableKeyDouble = "d_4_2" + let kVariableKeyBool = "b_true" + let kVariableKeyJSON = "j_1" + + let kVariableValueString = "foo" + let kVariableValueInt = 42 + let kVariableValueDouble = 4.2 + let kVariableValueBool = true + + var sampleHoldout: [String: Any] { + return [ + "status": "Running", + "id": "id_holdout", + "key": "key_holdout", + "layerId": "10420273888", + "trafficAllocation": [ + ["entityId": "id_holdout_variation", "endOfRange": 500] + ], + "audienceIds": [], + "variations": [ + [ + "variables": [], + "id": "id_holdout_variation", + "key": "key_holdout_variation" + ] + ], + "includedFlags": [], + "excludedFlags": [] + ] + } + + override func setUp() { + super.setUp() + + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, + eventDispatcher: eventDispatcher, + userProfileService: OTUtils.createClearUserProfileService()) + + try! optimizely.start(datafile: OTUtils.loadJSONDatafile("decide_datafile")!) + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + // Audience "13389130056" requires "country" = "US" + holdout.audienceIds = ["13389130056"] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + optimizely.config!.project.holdouts = [holdout] + + self.notificationCenter = self.optimizely.notificationCenter! + } + + func testDecisionListenerDecideWithUserInHoldout() { + let exp = expectation(description: "x") + + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + + notificationCenter.clearAllNotificationListeners() + _ = notificationCenter.addDecisionNotificationListener { (type, userId, attributes, decisionInfo) in + XCTAssertEqual(type, Constants.DecisionType.flag.rawValue) + XCTAssertEqual(userId, user.userId) + XCTAssertEqual(attributes!["country"] as! String, "US") + + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.flagKey] as! String, self.kFeatureKey) + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.enabled] as! Bool, false) + + XCTAssertNotNil(decisionInfo[Constants.DecisionInfoKeys.variables]) + let variableValues = decisionInfo[Constants.DecisionInfoKeys.variables] as! [String: Any] + + XCTAssertEqual(variableValues[self.kVariableKeyString] as! String, "foo") + XCTAssertEqual(variableValues[self.kVariableKeyInt] as! Int, self.kVariableValueInt) + XCTAssertEqual(variableValues[self.kVariableKeyDouble] as! Double, self.kVariableValueDouble) + XCTAssertEqual(variableValues[self.kVariableKeyBool] as! Bool, self.kVariableValueBool) + let jsonMap = (variableValues[self.kVariableKeyJSON] as! [String: Any]) + XCTAssertEqual(jsonMap["value"] as! Int, 1) + + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.variationKey] as! String, "key_holdout_variation") + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.ruleKey] as! String, "key_holdout") + XCTAssertNotNil(decisionInfo[Constants.DecisionInfoKeys.reasons]) + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.decisionEventDispatched] as! Bool, true) + exp.fulfill() + } + + _ = user.decide(key: self.kFeatureKey) + wait(for: [exp], timeout: 1) + } + + func testDecisionListenerDecideWithIncludedFlags() { + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedFlags = [kFeatureId] + optimizely.config!.project.holdouts = [holdout] + + let exp = expectation(description: "x") + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + + notificationCenter.clearAllNotificationListeners() + _ = notificationCenter.addDecisionNotificationListener { (type, userId, attributes, decisionInfo) in + XCTAssertEqual(type, Constants.DecisionType.flag.rawValue) + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.flagKey] as! String, self.kFeatureKey) + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.enabled] as! Bool, false) + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.variationKey] as! String, "key_holdout_variation") + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.ruleKey] as! String, "key_holdout") + exp.fulfill() + } + + _ = user.decide(key: kFeatureKey) + wait(for: [exp], timeout: 1) + } + + func testDecisionListenerDecideWithExcludedFlags() { + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.excludedFlags = [kFeatureId] + optimizely.config!.project.holdouts = [holdout] + + let exp = expectation(description: "x") + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + + notificationCenter.clearAllNotificationListeners() + _ = notificationCenter.addDecisionNotificationListener { (type, userId, attributes, decisionInfo) in + XCTAssertEqual(type, Constants.DecisionType.flag.rawValue) + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.flagKey] as! String, self.kFeatureKey) + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.enabled] as! Bool, true) + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.variationKey] as! String, "3324490633") + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.ruleKey] as! String, "3332020515") + exp.fulfill() + } + + _ = user.decide(key: kFeatureKey) + wait(for: [exp], timeout: 1) + } + + func testDecisionListenerDecideWithMultipleHoldouts() { + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.excludedFlags = [kFeatureId] + + var holdout_2 = holdout + holdout_2.key = "holdout_key_2" + holdout_2.id = "holdout_id_2" + holdout_2.includedFlags = [kFeatureId] + + optimizely.config!.project.holdouts = [holdout, holdout_2] + + let exp = expectation(description: "x") + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + + notificationCenter.clearAllNotificationListeners() + _ = notificationCenter.addDecisionNotificationListener { (type, userId, attributes, decisionInfo) in + XCTAssertEqual(type, Constants.DecisionType.flag.rawValue) + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.flagKey] as! String, self.kFeatureKey) + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.enabled] as! Bool, false) + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.variationKey] as! String, "key_holdout_variation") + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.ruleKey] as! String, "holdout_key_2") + exp.fulfill() + } + + _ = user.decide(key: kFeatureKey) + wait(for: [exp], timeout: 1) + } + + func testDecisionListener_DecisionEventDispatched_withSendFlagDecisions() { + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + + // (1) sendFlagDecision = false. feature-test. + + optimizely.config?.project.sendFlagDecisions = false + + var exp = expectation(description: "x") + notificationCenter.clearAllNotificationListeners() + _ = notificationCenter.addDecisionNotificationListener { (type, userId, attributes, decisionInfo) in + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.decisionEventDispatched] as! Bool, true) + exp.fulfill() + } + _ = user.decide(key: kFeatureKey) + wait(for: [exp], timeout: 1) + + // (2) sendFlagDecision = true + + optimizely.config?.project.sendFlagDecisions = true + + exp = expectation(description: "x") + notificationCenter.clearAllNotificationListeners() + _ = notificationCenter.addDecisionNotificationListener { (type, userId, attributes, decisionInfo) in + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.decisionEventDispatched] as! Bool, true) + exp.fulfill() + } + _ = user.decide(key: kFeatureKey) + wait(for: [exp], timeout: 1) + } + + func testDecisionListenerDecide_disableDecisionEvent() { + let user = optimizely.createUserContext(userId: kUserId, attributes:["country": "US"]) + + // (1) default (send-decision-event) + + var exp = expectation(description: "x") + + notificationCenter.clearAllNotificationListeners() + _ = notificationCenter.addDecisionNotificationListener { (type, userId, attributes, decisionInfo) in + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.decisionEventDispatched] as! Bool, true) + exp.fulfill() + } + _ = user.decide(key: kFeatureKey) + wait(for: [exp], timeout: 1) + + // (2) disable-decision-event) + + exp = expectation(description: "x") + + notificationCenter.clearAllNotificationListeners() + _ = notificationCenter.addDecisionNotificationListener { (type, userId, attributes, decisionInfo) in + XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.decisionEventDispatched] as! Bool, false) + exp.fulfill() + } + _ = user.decide(key: kFeatureKey, options: [.disableDecisionEvent]) + wait(for: [exp], timeout: 1) + } + + func testDecisionListenerDecideForKeys() { + let user = optimizely.createUserContext(userId: kUserId, attributes:["country": "US"]) + + var count = 0 + notificationCenter.clearAllNotificationListeners() + _ = notificationCenter.addDecisionNotificationListener { (type, userId, attributes, decisionInfo) in + XCTAssertEqual(type, Constants.DecisionType.flag.rawValue) + XCTAssertEqual(userId, user.userId) + XCTAssertEqual(attributes!["country"] as! String, "US") + + XCTAssertNotNil(decisionInfo[Constants.DecisionInfoKeys.flagKey]) + XCTAssertNotNil(decisionInfo[Constants.DecisionInfoKeys.enabled]) + count += 1 + } + + _ = user.decide(keys: [kFeatureKey, kFeatureKey, kFeatureKey, kFeatureKey]) + sleep(1) + + XCTAssertEqual(count, 4) + } + + func testDecisionListenerDecideAll() { + let user = optimizely.createUserContext(userId: kUserId, attributes:["country": "US"]) + + var count = 0 + notificationCenter.clearAllNotificationListeners() + _ = notificationCenter.addDecisionNotificationListener { (type, userId, attributes, decisionInfo) in + XCTAssertEqual(type, Constants.DecisionType.flag.rawValue) + XCTAssertEqual(userId, user.userId) + XCTAssertEqual(attributes!["country"] as! String, "US") + + XCTAssertNotNil(decisionInfo[Constants.DecisionInfoKeys.flagKey]) + XCTAssertNotNil(decisionInfo[Constants.DecisionInfoKeys.enabled]) + count += 1 + } + + _ = user.decideAll() + sleep(1) + + XCTAssertEqual(count, 3) + } +} diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift index 9820f6cd..7f2228e8 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift @@ -559,6 +559,6 @@ extension OptimizelyUserContextTests_Decide_Holdouts { XCTAssertEqual(decision.variationKey, "key_holdout_variation") XCTAssertFalse(decision.enabled) optimizely.eventLock.sync{} - XCTAssert(eventDispatcher.events.isEmpty) + XCTAssertFalse(eventDispatcher.events.isEmpty) } } diff --git a/Tests/OptimizelyTests-DataModel/HoldoutTests.swift b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift index aba7150d..df815ce1 100644 --- a/Tests/OptimizelyTests-DataModel/HoldoutTests.swift +++ b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift @@ -22,7 +22,7 @@ import XCTest class HoldoutTests: XCTestCase { static var variationData: [String: Any] = ["id": "553339214", "key": "house", - "featureEnabled": true] + "featureEnabled": false] static var trafficAllocationData: [String: Any] = ["entityId": "553339214", "endOfRange": 5000] From 4dffe6df38e6cc538d5653a1cb704d17f132a6c9 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Tue, 27 May 2025 19:27:15 +0600 Subject: [PATCH 27/37] Experiment id and variation id added into decision notification payload (#589) --- Sources/Implementation/DecisionInfo.swift | 10 ++++++- Sources/Utils/Constants.swift | 2 ++ .../DecisionListenerTests_Datafile.swift | 27 ++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Sources/Implementation/DecisionInfo.swift b/Sources/Implementation/DecisionInfo.swift index fcb30eb2..38de950c 100644 --- a/Sources/Implementation/DecisionInfo.swift +++ b/Sources/Implementation/DecisionInfo.swift @@ -140,9 +140,17 @@ struct DecisionInfo { decisionInfo[Constants.DecisionInfoKeys.variationKey] = variation?.key ?? NSNull() // keep key in the map even with nil value decisionInfo[Constants.DecisionInfoKeys.ruleKey] = ruleKey ?? NSNull() // decisionInfo[Constants.DecisionInfoKeys.reasons] = reasons - decisionInfo[Constants.DecisionInfoKeys.decisionEventDispatched] = decisionEventDispatched } + decisionInfo[Constants.DecisionInfoKeys.decisionEventDispatched] = decisionEventDispatched + + if let expId = experiment?.id { + decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] = expId + } + + if let varId = variation?.id { + decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] = varId + } return decisionInfo } diff --git a/Sources/Utils/Constants.swift b/Sources/Utils/Constants.swift index 2623f49b..0c106b2a 100644 --- a/Sources/Utils/Constants.swift +++ b/Sources/Utils/Constants.swift @@ -89,7 +89,9 @@ struct Constants { } struct ExperimentDecisionInfoKeys { + static let experimentId = "experimentId" static let experiment = "experimentKey" + static let variationId = "variationId" static let variation = "variationKey" } diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTests_Datafile.swift b/Tests/OptimizelyTests-Common/DecisionListenerTests_Datafile.swift index 708f8217..85f25b65 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTests_Datafile.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTests_Datafile.swift @@ -37,12 +37,16 @@ class DecisionListenerTests_Datafile: XCTestCase { var notificationVariation: String? var notificationExperiment: String? var notificationType: String? + var expId: String? + var varId: String? let exp = expectation(description: "x") _ = notificationCenter.addDecisionNotificationListener(decisionListener: { (type, _, _, decisionInfo) in notificationExperiment = decisionInfo[Constants.ExperimentDecisionInfoKeys.experiment] as? String notificationVariation = decisionInfo[Constants.ExperimentDecisionInfoKeys.variation] as? String + expId = decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] as? String + varId = decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] as? String notificationType = type exp.fulfill() }) @@ -56,6 +60,8 @@ class DecisionListenerTests_Datafile: XCTestCase { XCTAssertEqual(variation, "all_traffic_variation") XCTAssertEqual(notificationExperiment, "ab_running_exp_audience_combo_empty_conditions") XCTAssertEqual(notificationVariation, "all_traffic_variation") + XCTAssertEqual(expId, "10390977723") + XCTAssertEqual(varId, "10416523170") XCTAssertEqual(notificationType, Constants.DecisionType.abTest.rawValue) } @@ -63,12 +69,18 @@ class DecisionListenerTests_Datafile: XCTestCase { var notificationVariation: String? var notificationExperiment: String? var notificationType: String? + var expId: String? + var varId: String? let exp = expectation(description: "x") _ = notificationCenter.addDecisionNotificationListener(decisionListener: { (type, _, _, decisionInfo) in notificationExperiment = decisionInfo[Constants.ExperimentDecisionInfoKeys.experiment] as? String notificationVariation = decisionInfo[Constants.ExperimentDecisionInfoKeys.variation] as? String + expId = decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] as? String + varId = decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] as? String + expId = decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] as? String + varId = decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] as? String notificationType = type exp.fulfill() }) @@ -81,6 +93,8 @@ class DecisionListenerTests_Datafile: XCTestCase { XCTAssertEqual(notificationExperiment, "ab_running_exp_audience_combo_exact_foo_or_true__and__42_or_4_2") XCTAssertEqual(notificationVariation, nil) + XCTAssertEqual(expId, "10390977714") + XCTAssertEqual(varId, nil) XCTAssertEqual(notificationType, Constants.DecisionType.abTest.rawValue) notificationCenter.clearAllNotificationListeners() } @@ -93,12 +107,16 @@ class DecisionListenerTests_Datafile: XCTestCase { var notificationVariation: String? var notificationExperiment: String? var notificationType: String? + var expId: String? + var varId: String? let exp = expectation(description: "x") _ = notificationCenter.addDecisionNotificationListener(decisionListener: { (type, _, _, decisionInfo) in notificationExperiment = decisionInfo[Constants.ExperimentDecisionInfoKeys.experiment] as? String notificationVariation = decisionInfo[Constants.ExperimentDecisionInfoKeys.variation] as? String + expId = decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] as? String + varId = decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] as? String notificationType = type exp.fulfill() }) @@ -111,6 +129,8 @@ class DecisionListenerTests_Datafile: XCTestCase { XCTAssertEqual(notificationExperiment, "ab_running_exp_audience_combo_empty_conditions") XCTAssertEqual(notificationVariation, "all_traffic_variation") XCTAssertEqual(notificationType, Constants.DecisionType.abTest.rawValue) + XCTAssertEqual(expId, "10390977723") + XCTAssertEqual(varId, "10416523170") notificationCenter.clearAllNotificationListeners() } @@ -118,12 +138,15 @@ class DecisionListenerTests_Datafile: XCTestCase { var notificationVariation: String? var notificationExperiment: String? var notificationType: String? - + var expId: String? + var varId: String? let exp = expectation(description: "x") _ = notificationCenter.addDecisionNotificationListener(decisionListener: { (type, _, _, decisionInfo) in notificationExperiment = decisionInfo[Constants.ExperimentDecisionInfoKeys.experiment] as? String notificationVariation = decisionInfo[Constants.ExperimentDecisionInfoKeys.variation] as? String + expId = decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] as? String + varId = decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] as? String notificationType = type exp.fulfill() }) @@ -133,6 +156,8 @@ class DecisionListenerTests_Datafile: XCTestCase { XCTAssertEqual(notificationExperiment, "ab_running_exp_audience_combo_exact_foo_or_true__and__42_or_4_2") XCTAssertEqual(notificationVariation, nil) + XCTAssertEqual(expId, "10390977714") + XCTAssertEqual(varId, nil) XCTAssertEqual(notificationType, Constants.DecisionType.abTest.rawValue) notificationCenter.clearAllNotificationListeners() } From 55d698e249d121b89bd3a812a2ee201ba6822352 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Wed, 4 Jun 2025 08:57:38 +0600 Subject: [PATCH 28/37] [FSSDK-11465] chore: prepare for release 5.1.1 (#594) * Update swift.yml * Add exp id an variation id into decision notiifcation payload * Update doc for release 5.1.1 * update yml * ci(git-action): auto release prep for 5.1.1 (#595) Co-authored-by: optibot --------- Co-authored-by: optibot Co-authored-by: optibot --- .github/workflows/swift.yml | 4 ++-- CHANGELOG.md | 6 ++++++ OptimizelySwiftSDK.podspec | 2 +- Package.swift | 2 +- README.md | 2 +- Sources/Utils/SDKVersion.swift | 2 +- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 9652d5d1..44387a32 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -17,7 +17,7 @@ on: description: release env: - VERSION: 5.1.0 + VERSION: 5.1.1 jobs: @@ -97,4 +97,4 @@ jobs: COCOAPODS_VERSION: '1.15.2' run: | gem install cocoapods -v $COCOAPODS_VERSION - Scripts/run_release.sh + Scripts/run_release.sh \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c9af189..fae41347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Optimizely Swift SDK Changelog +## 5.1.1 +Jun 2th, 2025 + +### Functionality Enhancement +* Add experiment id and variation id added into decision notification payload ([#589](https://github.com/optimizely/swift-sdk/pull/589)). + ## 5.1.0 February 4th, 2025 diff --git a/OptimizelySwiftSDK.podspec b/OptimizelySwiftSDK.podspec index 14d38f88..22790261 100644 --- a/OptimizelySwiftSDK.podspec +++ b/OptimizelySwiftSDK.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "OptimizelySwiftSDK" s.module_name = "Optimizely" - s.version = "5.1.0" + s.version = "5.1.1" s.summary = "Optimizely experiment framework for iOS/tvOS/watchOS" s.homepage = "https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs" s.license = { :type => "Apache License, Version 2.0", :file => "LICENSE" } diff --git a/Package.swift b/Package.swift index 22653f4e..24b0467a 100644 --- a/Package.swift +++ b/Package.swift @@ -24,5 +24,5 @@ let package = Package( resources: [.copy("Supporting Files/PrivacyInfo.xcprivacy")] ) ], - swiftLanguageVersions: [.v5, .version("5.9")] + swiftLanguageVersions: [.v5] ) diff --git a/README.md b/README.md index 826c3385..d068f006 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ If you have a name conflict with other swift packages when you add the Optimizel #### CocoaPods 1. Add the following lines to the _Podfile_:
 ```use_frameworks!```
-```pod 'OptimizelySwiftSDK', '~> 5.1.0'```
+```pod 'OptimizelySwiftSDK', '~> 5.1.1'```
 
2. Run the following command:
``` pod install ```
diff --git a/Sources/Utils/SDKVersion.swift b/Sources/Utils/SDKVersion.swift index 1caaec32..298c7227 100644 --- a/Sources/Utils/SDKVersion.swift +++ b/Sources/Utils/SDKVersion.swift @@ -17,4 +17,4 @@ /// Do not edit this field. /// - It is auto updated (Scripts/updated_version.sh) to reflect the current version /// - Do not put underscores in the name (Swiftlint can modify unexpectedly) -let OPTIMIZELYSDKVERSION = "5.1.0" +let OPTIMIZELYSDKVERSION = "5.1.1" From 075cacda2a607b9a91e5af8d667d99011025d0ad Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Wed, 4 Jun 2025 23:41:41 +0600 Subject: [PATCH 29/37] [FSSDK-11465] fix: add exp id and variation id only for flag decision payload (#596) --- Sources/Implementation/DecisionInfo.swift | 11 +++----- .../DecisionListenerTests.swift | 4 +++ .../DecisionListenerTests_Datafile.swift | 26 ------------------- 3 files changed, 7 insertions(+), 34 deletions(-) diff --git a/Sources/Implementation/DecisionInfo.swift b/Sources/Implementation/DecisionInfo.swift index 38de950c..f631da18 100644 --- a/Sources/Implementation/DecisionInfo.swift +++ b/Sources/Implementation/DecisionInfo.swift @@ -140,17 +140,12 @@ struct DecisionInfo { decisionInfo[Constants.DecisionInfoKeys.variationKey] = variation?.key ?? NSNull() // keep key in the map even with nil value decisionInfo[Constants.DecisionInfoKeys.ruleKey] = ruleKey ?? NSNull() // decisionInfo[Constants.DecisionInfoKeys.reasons] = reasons + decisionInfo[Constants.DecisionInfoKeys.decisionEventDispatched] = decisionEventDispatched + decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] = experiment?.id ?? NSNull() + decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] = variation?.id ?? NSNull() } - decisionInfo[Constants.DecisionInfoKeys.decisionEventDispatched] = decisionEventDispatched - if let expId = experiment?.id { - decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] = expId - } - - if let varId = variation?.id { - decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] = varId - } return decisionInfo } diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift index e7ca6a33..157bed45 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift @@ -891,6 +891,8 @@ extension DecisionListenerTests { XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.variationKey] as! String, "a") //exp = "exp_with_audience" XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.ruleKey] as! String, "exp_with_audience") + XCTAssertEqual(decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] as! String, "10390977673") //exp = "exp_with_audience" + XCTAssertEqual(decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] as! String, "10389729780") XCTAssertNotNil(decisionInfo[Constants.DecisionInfoKeys.reasons]) XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.decisionEventDispatched] as! Bool, true) exp.fulfill() @@ -925,6 +927,8 @@ extension DecisionListenerTests { XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.variationKey] as! String, "a") //exp = "exp_with_audience" XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.ruleKey] as! String, "exp_with_audience") + XCTAssertEqual(decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] as! String, "10390977673") //exp = "exp_with_audience" + XCTAssertEqual(decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] as! String, "10389729780") XCTAssertNotNil(decisionInfo[Constants.DecisionInfoKeys.reasons]) XCTAssertEqual(decisionInfo[Constants.DecisionInfoKeys.decisionEventDispatched] as! Bool, true) exp.fulfill() diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTests_Datafile.swift b/Tests/OptimizelyTests-Common/DecisionListenerTests_Datafile.swift index 85f25b65..e988cab8 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTests_Datafile.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTests_Datafile.swift @@ -37,16 +37,12 @@ class DecisionListenerTests_Datafile: XCTestCase { var notificationVariation: String? var notificationExperiment: String? var notificationType: String? - var expId: String? - var varId: String? let exp = expectation(description: "x") _ = notificationCenter.addDecisionNotificationListener(decisionListener: { (type, _, _, decisionInfo) in notificationExperiment = decisionInfo[Constants.ExperimentDecisionInfoKeys.experiment] as? String notificationVariation = decisionInfo[Constants.ExperimentDecisionInfoKeys.variation] as? String - expId = decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] as? String - varId = decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] as? String notificationType = type exp.fulfill() }) @@ -60,8 +56,6 @@ class DecisionListenerTests_Datafile: XCTestCase { XCTAssertEqual(variation, "all_traffic_variation") XCTAssertEqual(notificationExperiment, "ab_running_exp_audience_combo_empty_conditions") XCTAssertEqual(notificationVariation, "all_traffic_variation") - XCTAssertEqual(expId, "10390977723") - XCTAssertEqual(varId, "10416523170") XCTAssertEqual(notificationType, Constants.DecisionType.abTest.rawValue) } @@ -69,18 +63,12 @@ class DecisionListenerTests_Datafile: XCTestCase { var notificationVariation: String? var notificationExperiment: String? var notificationType: String? - var expId: String? - var varId: String? let exp = expectation(description: "x") _ = notificationCenter.addDecisionNotificationListener(decisionListener: { (type, _, _, decisionInfo) in notificationExperiment = decisionInfo[Constants.ExperimentDecisionInfoKeys.experiment] as? String notificationVariation = decisionInfo[Constants.ExperimentDecisionInfoKeys.variation] as? String - expId = decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] as? String - varId = decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] as? String - expId = decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] as? String - varId = decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] as? String notificationType = type exp.fulfill() }) @@ -93,8 +81,6 @@ class DecisionListenerTests_Datafile: XCTestCase { XCTAssertEqual(notificationExperiment, "ab_running_exp_audience_combo_exact_foo_or_true__and__42_or_4_2") XCTAssertEqual(notificationVariation, nil) - XCTAssertEqual(expId, "10390977714") - XCTAssertEqual(varId, nil) XCTAssertEqual(notificationType, Constants.DecisionType.abTest.rawValue) notificationCenter.clearAllNotificationListeners() } @@ -107,16 +93,12 @@ class DecisionListenerTests_Datafile: XCTestCase { var notificationVariation: String? var notificationExperiment: String? var notificationType: String? - var expId: String? - var varId: String? let exp = expectation(description: "x") _ = notificationCenter.addDecisionNotificationListener(decisionListener: { (type, _, _, decisionInfo) in notificationExperiment = decisionInfo[Constants.ExperimentDecisionInfoKeys.experiment] as? String notificationVariation = decisionInfo[Constants.ExperimentDecisionInfoKeys.variation] as? String - expId = decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] as? String - varId = decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] as? String notificationType = type exp.fulfill() }) @@ -129,8 +111,6 @@ class DecisionListenerTests_Datafile: XCTestCase { XCTAssertEqual(notificationExperiment, "ab_running_exp_audience_combo_empty_conditions") XCTAssertEqual(notificationVariation, "all_traffic_variation") XCTAssertEqual(notificationType, Constants.DecisionType.abTest.rawValue) - XCTAssertEqual(expId, "10390977723") - XCTAssertEqual(varId, "10416523170") notificationCenter.clearAllNotificationListeners() } @@ -138,15 +118,11 @@ class DecisionListenerTests_Datafile: XCTestCase { var notificationVariation: String? var notificationExperiment: String? var notificationType: String? - var expId: String? - var varId: String? let exp = expectation(description: "x") _ = notificationCenter.addDecisionNotificationListener(decisionListener: { (type, _, _, decisionInfo) in notificationExperiment = decisionInfo[Constants.ExperimentDecisionInfoKeys.experiment] as? String notificationVariation = decisionInfo[Constants.ExperimentDecisionInfoKeys.variation] as? String - expId = decisionInfo[Constants.ExperimentDecisionInfoKeys.experimentId] as? String - varId = decisionInfo[Constants.ExperimentDecisionInfoKeys.variationId] as? String notificationType = type exp.fulfill() }) @@ -156,8 +132,6 @@ class DecisionListenerTests_Datafile: XCTestCase { XCTAssertEqual(notificationExperiment, "ab_running_exp_audience_combo_exact_foo_or_true__and__42_or_4_2") XCTAssertEqual(notificationVariation, nil) - XCTAssertEqual(expId, "10390977714") - XCTAssertEqual(varId, nil) XCTAssertEqual(notificationType, Constants.DecisionType.abTest.rawValue) notificationCenter.clearAllNotificationListeners() } From f0c90a979f3d38f40137ae2a2c74d29ecdce5d6c Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Tue, 17 Jun 2025 17:46:17 +0600 Subject: [PATCH 30/37] LRUCache: add remove method with test cases coverages (#597) --- Sources/ODP/LruCache.swift | 10 ++++ .../LruCacheTests.swift | 60 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/Sources/ODP/LruCache.swift b/Sources/ODP/LruCache.swift index fc6f9554..ceecb077 100644 --- a/Sources/ODP/LruCache.swift +++ b/Sources/ODP/LruCache.swift @@ -89,6 +89,16 @@ class LruCache { } } + func remove(key: K) { + if maxSize <= 0 { return } + queue.async(flags: .barrier) { + if var item = self.map[key] { + self.removeFromLink(item) + self.map[key] = nil + } + } + } + // read cache contents without order update func peek(key: K) -> V? { if maxSize <= 0 { return nil } diff --git a/Tests/OptimizelyTests-Common/LruCacheTests.swift b/Tests/OptimizelyTests-Common/LruCacheTests.swift index ddcfa299..ede0d260 100644 --- a/Tests/OptimizelyTests-Common/LruCacheTests.swift +++ b/Tests/OptimizelyTests-Common/LruCacheTests.swift @@ -108,6 +108,37 @@ class LruCacheTests: XCTestCase { XCTAssertEqual(700, cache.peek(key: 2)) } + func testRemoveKey() { + let cache = LruCache(size: 3, timeoutInSecs: 1000) + + // Save a few items + cache.save(key: 1, value: 100) // [1] + cache.save(key: 2, value: 200) // [1, 2] + cache.save(key: 3, value: 300) // [1, 2, 3] + + // Ensure they are saved + XCTAssertEqual(cache.peek(key: 1), 100) + XCTAssertEqual(cache.peek(key: 2), 200) + XCTAssertEqual(cache.peek(key: 3), 300) + + // Remove one key + cache.remove(key: 2) + + // Check it's gone + XCTAssertNil(cache.peek(key: 2)) + XCTAssertNil(cache.lookup(key: 2)) + + // Ensure others still exist + XCTAssertEqual(cache.peek(key: 1), 100) + XCTAssertEqual(cache.peek(key: 3), 300) + + // Remove non-existent key (should not crash or affect others) + cache.remove(key: 999) + XCTAssertEqual(cache.peek(key: 1), 100) + XCTAssertEqual(cache.peek(key: 3), 300) + } + + func testSize_zero() { let cache = LruCache(size: 0, timeoutInSecs: 1000) @@ -132,6 +163,35 @@ class LruCacheTests: XCTestCase { } XCTAssertTrue(result, "Concurrent tasks timed out") } + + func testRemoveIsThreadSafe() { + let numThreads = 50 + let numIterations = 100 + + let cache = LruCache(size: 100, timeoutInSecs: 1000) + + // Pre-fill the cache with keys + for i in 0..<(numThreads * numIterations) { + cache.save(key: i, value: i * 10) + } + + let result = OTUtils.runConcurrent(count: numThreads, timeoutInSecs: 10) { threadIndex in + let base = threadIndex * numIterations + for i in 0.. Date: Tue, 17 Jun 2025 23:00:42 +0600 Subject: [PATCH 31/37] wip: add cmab property to experiment (#599) --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 40 ++++++++++++++++ Sources/Data Model/Cmab.swift | 22 +++++++++ Sources/Data Model/Experiment.swift | 6 ++- Sources/Data Model/ProjectConfig.swift | 14 ++++++ .../OptimizelyTests-DataModel/CmabTests.swift | 48 +++++++++++++++++++ .../ExperimentTests.swift | 18 +++++++ .../ProjectConfigTests.swift | 19 +++++++- 7 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 Sources/Data Model/Cmab.swift create mode 100644 Tests/OptimizelyTests-DataModel/CmabTests.swift diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index e58a3625..62dee985 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2074,6 +2074,24 @@ 98AC985F2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */; }; 98D5AE842DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */; }; 98D5AE852DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */; }; + 98F28A1D2E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A1E2E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A1F2E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A202E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A212E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A222E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A232E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A242E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A252E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A262E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A272E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A282E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A292E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A2A2E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A2B2E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A2C2E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; + 98F28A2E2E01968000A86546 /* CmabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A2D2E01968000A86546 /* CmabTests.swift */; }; + 98F28A3E2E01AC0700A86546 /* CmabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A2D2E01968000A86546 /* CmabTests.swift */; }; BD1C3E8524E4399C0084B4DA /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B97DD93249D327F003DE606 /* SemanticVersion.swift */; }; BD64853C2491474500F30986 /* Optimizely.h in Headers */ = {isa = PBXBuildFile; fileRef = 6E75167A22C520D400B2B157 /* Optimizely.h */; settings = {ATTRIBUTES = (Public, ); }; }; BD64853E2491474500F30986 /* Audience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E75169822C520D400B2B157 /* Audience.swift */; }; @@ -2525,6 +2543,8 @@ 98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecisionServiceTests_Holdouts.swift; sourceTree = ""; }; 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift; sourceTree = ""; }; 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Holdouts.swift; sourceTree = ""; }; + 98F28A1C2E01940500A86546 /* Cmab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cmab.swift; sourceTree = ""; }; + 98F28A2D2E01968000A86546 /* CmabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabTests.swift; sourceTree = ""; }; BD6485812491474500F30986 /* Optimizely.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Optimizely.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyJSON.swift; sourceTree = ""; }; C78CAF652446DB91009FE876 /* OptimizelyClientTests_OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_OptimizelyJSON.swift; sourceTree = ""; }; @@ -2907,6 +2927,7 @@ 6E75169222C520D400B2B157 /* Project.swift */, 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */, 6E75169322C520D400B2B157 /* Experiment.swift */, + 98F28A1C2E01940500A86546 /* Cmab.swift */, 980CC9072D833F2800E07D24 /* ExperimentCore.swift */, 980CC8F62D833F0D00E07D24 /* Holdout.swift */, 6E75169422C520D400B2B157 /* FeatureFlag.swift */, @@ -3109,6 +3130,7 @@ 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */, 6E7519AC22C5211100B2B157 /* ExperimentTests.swift */, 6E7519AD22C5211100B2B157 /* EventTests.swift */, + 98F28A2D2E01968000A86546 /* CmabTests.swift */, 6E7519AE22C5211100B2B157 /* ConditionHolderTests.swift */, 6E7519AF22C5211100B2B157 /* TrafficAllocationTests.swift */, 6E7519B022C5211100B2B157 /* ProjectTests.swift */, @@ -4319,6 +4341,7 @@ 6E14CD892423F9A100010234 /* ConditionLeaf.swift in Sources */, 6E14CD9F2423F9C300010234 /* ArrayEventForDispatch+Extension.swift in Sources */, 6E14CD9C2423F9C300010234 /* OPTDecisionService.swift in Sources */, + 98F28A202E01940500A86546 /* Cmab.swift in Sources */, 6E14CD8A2423F9A100010234 /* ConditionHolder.swift in Sources */, 6E14CD9E2423F9C300010234 /* OPTBucketer.swift in Sources */, 6E14CD742423F97200010234 /* OptimizelyConfig.swift in Sources */, @@ -4397,6 +4420,7 @@ 984FE5112CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 845945C2287758A000D13E11 /* OdpConfig.swift in Sources */, 6E424D1426324B620081004A /* OPTNotificationCenter.swift in Sources */, + 98F28A212E01940500A86546 /* Cmab.swift in Sources */, 6E424D5026324C4D0081004A /* OptimizelyDecideOption.swift in Sources */, 6E424D5126324C4D0081004A /* OptimizelyDecision.swift in Sources */, 6E424D1526324B620081004A /* DataStoreQueueStack.swift in Sources */, @@ -4496,6 +4520,7 @@ 6E75179722C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, 6E7518DD22C520D400B2B157 /* ConditionLeaf.swift in Sources */, 6E75187D22C520D400B2B157 /* TrafficAllocation.swift in Sources */, + 98F28A252E01940500A86546 /* Cmab.swift in Sources */, C78CAFA524486E0A009FE876 /* OptimizelyJSON+ObjC.swift in Sources */, 6E75185922C520D400B2B157 /* FeatureVariable.swift in Sources */, 6E4544AB270E67C800F2CEBC /* NetworkReachability.swift in Sources */, @@ -4637,6 +4662,7 @@ 6E7517DC22C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E75178622C520D400B2B157 /* ArrayEventForDispatch+Extension.swift in Sources */, 6E75171A22C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, + 98F28A2C2E01940500A86546 /* Cmab.swift in Sources */, 6ECB60D2234D5D9C00016D41 /* OptimizelyConfig+ObjC.swift in Sources */, 6E75192C22C520D500B2B157 /* DataStoreQueueStack.swift in Sources */, 6E7517AA22C520D400B2B157 /* Array+Extension.swift in Sources */, @@ -4676,6 +4702,7 @@ 6E75174622C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 6E75181422C520D400B2B157 /* DataStoreQueueStackImpl.swift in Sources */, 6E593FB625BB9C5500EC72BC /* OptimizelyClientTests_Decide.swift in Sources */, + 98F28A292E01940500A86546 /* Cmab.swift in Sources */, 6E7516C222C520D400B2B157 /* DefaultEventDispatcher.swift in Sources */, 980CC8FC2D833F0D00E07D24 /* Holdout.swift in Sources */, 848617F22863E21200B7F41B /* OdpEventApiManager.swift in Sources */, @@ -4810,6 +4837,7 @@ 6E75176122C520D400B2B157 /* AtomicProperty.swift in Sources */, 6E75180B22C520D400B2B157 /* DataStoreFile.swift in Sources */, 6E6522E9278E4F3800954EA1 /* OdpManager.swift in Sources */, + 98F28A272E01940500A86546 /* Cmab.swift in Sources */, 6EF8DE2324BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E7517C322C520D400B2B157 /* DefaultDatafileHandler.swift in Sources */, 6E75190722C520D500B2B157 /* Attribute.swift in Sources */, @@ -4944,6 +4972,7 @@ 6E75180122C520D400B2B157 /* DataStoreUserDefaults.swift in Sources */, 98AC98472DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */, 6E75175722C520D400B2B157 /* LogMessage.swift in Sources */, + 98F28A242E01940500A86546 /* Cmab.swift in Sources */, 6E7516EB22C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E75188522C520D400B2B157 /* TrafficAllocation.swift in Sources */, 6E75176F22C520D400B2B157 /* Utils.swift in Sources */, @@ -5082,6 +5111,7 @@ 6E86CEAE24FDC84A005DAFED /* OptimizelyUserContext+ObjC.swift in Sources */, 6E9B119922C5488300C22D81 /* UserAttributeTests_Evaluate.swift in Sources */, 6E9B11A422C5488300C22D81 /* ProjectTests.swift in Sources */, + 98F28A3E2E01AC0700A86546 /* CmabTests.swift in Sources */, 6E9B119622C5488300C22D81 /* AudienceTests.swift in Sources */, 6E7518B622C520D400B2B157 /* Group.swift in Sources */, 6E7516D422C520D400B2B157 /* OPTLogger.swift in Sources */, @@ -5104,6 +5134,7 @@ 8464087E28130D3200CCF97D /* Integration.swift in Sources */, 6E9B119722C5488300C22D81 /* ConditionLeafTests.swift in Sources */, 6E75184A22C520D400B2B157 /* Event.swift in Sources */, + 98F28A262E01940500A86546 /* Cmab.swift in Sources */, 6E75191622C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, 848617D62863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, 6E9B11A522C5488300C22D81 /* ConditionHolderTests_Evaluate.swift in Sources */, @@ -5227,6 +5258,7 @@ 6E7517EF22C520D400B2B157 /* DataStoreMemory.swift in Sources */, 6E75194B22C520D500B2B157 /* OPTDatafileHandler.swift in Sources */, 6E75195722C520D500B2B157 /* OPTBucketer.swift in Sources */, + 98F28A2A2E01940500A86546 /* Cmab.swift in Sources */, 6E75181322C520D400B2B157 /* DataStoreQueueStackImpl.swift in Sources */, 6EC6DD6924AE94820017D296 /* OptimizelyUserContextTests.swift in Sources */, 6E75171522C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, @@ -5363,6 +5395,7 @@ 6E75182122C520D400B2B157 /* BatchEventBuilder.swift in Sources */, 6E86CEA924FDC847005DAFED /* OptimizelyUserContext+ObjC.swift in Sources */, 6E9B118322C5488100C22D81 /* UserAttributeTests_Evaluate.swift in Sources */, + 98F28A1D2E01940500A86546 /* Cmab.swift in Sources */, 6E9B118E22C5488100C22D81 /* ProjectTests.swift in Sources */, 6E9B118022C5488100C22D81 /* AudienceTests.swift in Sources */, 6E7518B122C520D400B2B157 /* Group.swift in Sources */, @@ -5372,6 +5405,7 @@ 84640881281320F000CCF97D /* IntegrationTests.swift in Sources */, 6E9B118222C5488100C22D81 /* AudienceTests_Evaluate.swift in Sources */, 6E75191D22C520D500B2B157 /* OPTNotificationCenter.swift in Sources */, + 98F28A2E2E01968000A86546 /* CmabTests.swift in Sources */, 6E75176B22C520D400B2B157 /* Utils.swift in Sources */, 6E7516DB22C520D400B2B157 /* OPTUserProfileService.swift in Sources */, 6E34A61C2319EBB800BAE302 /* Notifications.swift in Sources */, @@ -5469,6 +5503,7 @@ 6EC6DD3924ABF6990017D296 /* OptimizelyClient+Decide.swift in Sources */, 6E75179C22C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, 6E7516D022C520D400B2B157 /* OPTLogger.swift in Sources */, + 98F28A2B2E01940500A86546 /* Cmab.swift in Sources */, 6E7518B222C520D400B2B157 /* Group.swift in Sources */, 6EF8DE3924BF7D69008B9488 /* DecisionReasons.swift in Sources */, 6E424BE6263228E90081004A /* AtomicArray.swift in Sources */, @@ -5575,6 +5610,7 @@ 6EC6DD3E24ABF6990017D296 /* OptimizelyClient+Decide.swift in Sources */, 6E7517A122C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, 6E7516D522C520D400B2B157 /* OPTLogger.swift in Sources */, + 98F28A1E2E01940500A86546 /* Cmab.swift in Sources */, 6E7518B722C520D400B2B157 /* Group.swift in Sources */, 6EF8DE3E24BF7D69008B9488 /* DecisionReasons.swift in Sources */, 6E424BEB263228E90081004A /* AtomicArray.swift in Sources */, @@ -5709,6 +5745,7 @@ 6E7518D022C520D400B2B157 /* AttributeValue.swift in Sources */, 6E75181C22C520D400B2B157 /* BatchEventBuilder.swift in Sources */, 6E7518DC22C520D400B2B157 /* ConditionLeaf.swift in Sources */, + 98F28A1F2E01940500A86546 /* Cmab.swift in Sources */, C78CAFA424486E0A009FE876 /* OptimizelyJSON+ObjC.swift in Sources */, 6E7517F822C520D400B2B157 /* DataStoreUserDefaults.swift in Sources */, 6E4544AA270E67C800F2CEBC /* NetworkReachability.swift in Sources */, @@ -5850,6 +5887,7 @@ 6E7517D622C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E75178022C520D400B2B157 /* ArrayEventForDispatch+Extension.swift in Sources */, 6E75171422C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, + 98F28A232E01940500A86546 /* Cmab.swift in Sources */, 6ECB60CC234D5D9C00016D41 /* OptimizelyConfig+ObjC.swift in Sources */, 6E75192622C520D500B2B157 /* DataStoreQueueStack.swift in Sources */, 6E7517A422C520D400B2B157 /* Array+Extension.swift in Sources */, @@ -5925,6 +5963,7 @@ 75C71A2925E454460084187E /* ProjectConfig.swift in Sources */, 75C71A2A25E454460084187E /* FeatureVariable.swift in Sources */, 75C71A2B25E454460084187E /* Rollout.swift in Sources */, + 98F28A282E01940500A86546 /* Cmab.swift in Sources */, 984FE51B2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E424BFF263228FD0081004A /* AtomicDictionary.swift in Sources */, 75C71A2C25E454460084187E /* Variation.swift in Sources */, @@ -6011,6 +6050,7 @@ BD6485582491474500F30986 /* AttributeValue.swift in Sources */, BD6485592491474500F30986 /* BatchEventBuilder.swift in Sources */, BD64855A2491474500F30986 /* ConditionLeaf.swift in Sources */, + 98F28A222E01940500A86546 /* Cmab.swift in Sources */, BD64855B2491474500F30986 /* OptimizelyJSON+ObjC.swift in Sources */, BD64855C2491474500F30986 /* DataStoreUserDefaults.swift in Sources */, 6E4544AC270E67C800F2CEBC /* NetworkReachability.swift in Sources */, diff --git a/Sources/Data Model/Cmab.swift b/Sources/Data Model/Cmab.swift new file mode 100644 index 00000000..cf767068 --- /dev/null +++ b/Sources/Data Model/Cmab.swift @@ -0,0 +1,22 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct Cmab: Codable, Equatable { + var trafficAllocation: Int + var attributeIds: [String] +} diff --git a/Sources/Data Model/Experiment.swift b/Sources/Data Model/Experiment.swift index ab75730e..87cecd9e 100644 --- a/Sources/Data Model/Experiment.swift +++ b/Sources/Data Model/Experiment.swift @@ -35,9 +35,10 @@ struct Experiment: Codable, ExperimentCore { var audienceConditions: ConditionHolder? // datafile spec defines this as [String: Any]. Supposed to be [ExperimentKey: VariationKey] var forcedVariations: [String: String] + var cmab: Cmab? enum CodingKeys: String, CodingKey { - case id, key, status, layerId, variations, trafficAllocation, audienceIds, audienceConditions, forcedVariations + case id, key, status, layerId, variations, trafficAllocation, audienceIds, audienceConditions, forcedVariations, cmab } // MARK: - OptimizelyConfig @@ -57,7 +58,8 @@ extension Experiment: Equatable { lhs.trafficAllocation == rhs.trafficAllocation && lhs.audienceIds == rhs.audienceIds && lhs.audienceConditions == rhs.audienceConditions && - lhs.forcedVariations == rhs.forcedVariations + lhs.forcedVariations == rhs.forcedVariations && + lhs.cmab == rhs.cmab } } diff --git a/Sources/Data Model/ProjectConfig.swift b/Sources/Data Model/ProjectConfig.swift index 28474a77..a2cd3bf2 100644 --- a/Sources/Data Model/ProjectConfig.swift +++ b/Sources/Data Model/ProjectConfig.swift @@ -34,6 +34,7 @@ class ProjectConfig { var experimentFeatureMap = [String: [String]]() var eventKeyMap = [String: Event]() var attributeKeyMap = [String: Attribute]() + var attributeIdMap = [String: Attribute]() var featureFlagKeyMap = [String: FeatureFlag]() var featureFlagKeys = [String]() var rolloutIdMap = [String: Rollout]() @@ -113,6 +114,12 @@ class ProjectConfig { return map }() + self.attributeIdMap = { + var map = [String: Attribute]() + project.attributes.forEach { map[$0.id] = $0 } + return map + }() + self.featureFlagKeyMap = { var map = [String: FeatureFlag]() project.featureFlags.forEach { map[$0.key] = $0 } @@ -306,6 +313,13 @@ extension ProjectConfig { return attributeKeyMap[key] } + /** + * Get an attribute for a given id. + */ + func getAttribute(id: String) -> Attribute? { + return attributeIdMap[id] + } + /** * Get an attribute Id for a given key. **/ diff --git a/Tests/OptimizelyTests-DataModel/CmabTests.swift b/Tests/OptimizelyTests-DataModel/CmabTests.swift new file mode 100644 index 00000000..42fb1180 --- /dev/null +++ b/Tests/OptimizelyTests-DataModel/CmabTests.swift @@ -0,0 +1,48 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class CmabTests: XCTestCase { + static var sampleData: [String: Any] = ["trafficAllocation": 10000, "attributeIds": ["id_1", "id_2"]] + + func testDecodeSuccessValidJson() { + let data = Self.sampleData + let cmab: Cmab = try! OTUtils.model(from: data) + XCTAssertEqual(cmab.attributeIds, ["id_1", "id_2"]) + XCTAssertEqual(cmab.trafficAllocation, 10000) + } + + func testDecodeSuccessEmptyIds() { + var data = Self.sampleData + data["attributeIds"] = [] + let cmab: Cmab = try! OTUtils.model(from: data) + XCTAssertEqual(cmab.attributeIds, []) + XCTAssertEqual(cmab.trafficAllocation, 10000) + } + + func testDecodFailedWithoutTrafficAllocation() { + let data = ["attributeIds": ["id_1", "id_2"]] + let cmab: Cmab? = try? OTUtils.model(from: data) + XCTAssertNil(cmab) + } + + func testDecodFailedWithoutAttributeIds() { + let data = ["trafficAllocation": 10000] + let cmab: Cmab? = try? OTUtils.model(from: data) + XCTAssertNil(cmab) + } +} diff --git a/Tests/OptimizelyTests-DataModel/ExperimentTests.swift b/Tests/OptimizelyTests-DataModel/ExperimentTests.swift index b47d41b5..665d6036 100644 --- a/Tests/OptimizelyTests-DataModel/ExperimentTests.swift +++ b/Tests/OptimizelyTests-DataModel/ExperimentTests.swift @@ -56,6 +56,24 @@ extension ExperimentTests { XCTAssert(model.audienceIds == ["33333"]) XCTAssert(model.audienceConditions == (try! OTUtils.model(from: ConditionHolderTests.sampleData))) XCTAssert(model.forcedVariations == ["12345": "1234567890"]) + XCTAssert(model.cmab == nil) + } + + func testDecodeSuccessWithCmab() { + var data: [String: Any] = ExperimentTests.sampleData + data["cmab"] = ["trafficAllocation": 5000, "attributeIds": ["id_1", "id_2"]] + let model: Experiment = try! OTUtils.model(from: data) + + XCTAssert(model.id == "11111") + XCTAssert(model.key == "background") + XCTAssert(model.status == .running) + XCTAssert(model.layerId == "22222") + XCTAssert(model.variations == [try! OTUtils.model(from: VariationTests.sampleData)]) + XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: TrafficAllocationTests.sampleData)]) + XCTAssert(model.audienceIds == ["33333"]) + XCTAssert(model.audienceConditions == (try! OTUtils.model(from: ConditionHolderTests.sampleData))) + XCTAssert(model.forcedVariations == ["12345": "1234567890"]) + XCTAssert(model.cmab == (try? OTUtils.model(from: ["trafficAllocation": 5000, "attributeIds": ["id_1", "id_2"]]))) } func testDecodeSuccessWithMissingAudienceConditions() { diff --git a/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift b/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift index 9faa1046..e9384acb 100644 --- a/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift +++ b/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift @@ -259,5 +259,22 @@ extension ProjectConfigTests { variation = config.getForcedVariation(experimentKey: experimentKey, userId: userId).result! XCTAssertEqual(variation.key, "b") } - + + func testGetAttributeByKey() { + var projectData = ProjectTests.sampleData + let model: Project = try! OTUtils.model(from: projectData) + let projectConfig = ProjectConfig() + projectConfig.project = model + XCTAssert(projectConfig.getAttribute(key: "house") == (try? OTUtils.model(from: ["id": "553339214", "key": "house"]))) + + } + + func testGetAttributeById() { + var projectData = ProjectTests.sampleData + let model: Project = try! OTUtils.model(from: projectData) + let projectConfig = ProjectConfig() + projectConfig.project = model + XCTAssert(projectConfig.getAttribute(id: "553339214") == (try? OTUtils.model(from: ["id": "553339214", "key": "house"]))) + + } } From 993701ce28eaf9784a20f30f692d3a4e5e83cb44 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Mon, 23 Jun 2025 23:41:44 +0600 Subject: [PATCH 32/37] [FSSDK-11145] chore: implement cmab client (#600) --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 48 +++ Sources/CMAB/CmabClient.swift | 194 ++++++++++++ .../CMABClientTests.swift | 278 ++++++++++++++++++ 3 files changed, 520 insertions(+) create mode 100644 Sources/CMAB/CmabClient.swift create mode 100644 Tests/OptimizelyTests-Common/CMABClientTests.swift diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index 62dee985..bc8c718c 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2092,6 +2092,24 @@ 98F28A2C2E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; 98F28A2E2E01968000A86546 /* CmabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A2D2E01968000A86546 /* CmabTests.swift */; }; 98F28A3E2E01AC0700A86546 /* CmabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A2D2E01968000A86546 /* CmabTests.swift */; }; + 98F28A412E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A422E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A432E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A442E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A452E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A462E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A472E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A482E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A492E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4A2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4B2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4C2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4D2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4E2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4F2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A502E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A522E02E81500A86546 /* CMABClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A512E02E81500A86546 /* CMABClientTests.swift */; }; + 98F28A532E02E81500A86546 /* CMABClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A512E02E81500A86546 /* CMABClientTests.swift */; }; BD1C3E8524E4399C0084B4DA /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B97DD93249D327F003DE606 /* SemanticVersion.swift */; }; BD64853C2491474500F30986 /* Optimizely.h in Headers */ = {isa = PBXBuildFile; fileRef = 6E75167A22C520D400B2B157 /* Optimizely.h */; settings = {ATTRIBUTES = (Public, ); }; }; BD64853E2491474500F30986 /* Audience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E75169822C520D400B2B157 /* Audience.swift */; }; @@ -2545,6 +2563,8 @@ 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Holdouts.swift; sourceTree = ""; }; 98F28A1C2E01940500A86546 /* Cmab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cmab.swift; sourceTree = ""; }; 98F28A2D2E01968000A86546 /* CmabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabTests.swift; sourceTree = ""; }; + 98F28A402E02DD6D00A86546 /* CmabClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabClient.swift; sourceTree = ""; }; + 98F28A512E02E81500A86546 /* CMABClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMABClientTests.swift; sourceTree = ""; }; BD6485812491474500F30986 /* Optimizely.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Optimizely.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyJSON.swift; sourceTree = ""; }; C78CAF652446DB91009FE876 /* OptimizelyClientTests_OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_OptimizelyJSON.swift; sourceTree = ""; }; @@ -2781,6 +2801,7 @@ 6E75165D22C520D400B2B157 /* Sources */ = { isa = PBXGroup; children = ( + 98F28A3F2E02DD4D00A86546 /* CMAB */, 6E75166622C520D400B2B157 /* Optimizely */, 6EC6DD3F24ABF8180017D296 /* Optimizely+Decide */, 6E75165E22C520D400B2B157 /* Customization */, @@ -3076,6 +3097,7 @@ 84861810286D0B8900B7F41B /* OdpEventManagerTests.swift */, 8486180E286D0B8900B7F41B /* OdpManagerTests.swift */, 8486180D286D0B8900B7F41B /* OdpSegmentManagerTests.swift */, + 98F28A512E02E81500A86546 /* CMABClientTests.swift */, 8486180F286D0B8900B7F41B /* VuidManagerTests.swift */, 84861819286D188B00B7F41B /* OdpSegmentApiManagerTests.swift */, 8486181A286D188B00B7F41B /* OdpEventApiManagerTests.swift */, @@ -3244,6 +3266,14 @@ name = Frameworks; sourceTree = ""; }; + 98F28A3F2E02DD4D00A86546 /* CMAB */ = { + isa = PBXGroup; + children = ( + 98F28A402E02DD6D00A86546 /* CmabClient.swift */, + ); + path = CMAB; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -4315,6 +4345,7 @@ 6E14CD842423F9A100010234 /* BatchEventBuilder.swift in Sources */, 6E14CD6E2423F85E00010234 /* EventDispatcherTests_Batch.swift in Sources */, 6E14CDA92423F9C300010234 /* Utils.swift in Sources */, + 98F28A472E02DD6D00A86546 /* CmabClient.swift in Sources */, 6EF8DE1F24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E14CD882423F9A100010234 /* AttributeValue.swift in Sources */, 84E2E9492852A378001114AB /* VuidManager.swift in Sources */, @@ -4381,6 +4412,7 @@ 6E424CF726324B620081004A /* DefaultDecisionService.swift in Sources */, 6E424CF826324B620081004A /* DecisionReasons.swift in Sources */, 6E424CF926324B620081004A /* DecisionResponse.swift in Sources */, + 98F28A412E02DD6D00A86546 /* CmabClient.swift in Sources */, 84E2E9782855875E001114AB /* OdpEventManager.swift in Sources */, 6E424CFA26324B620081004A /* DataStoreMemory.swift in Sources */, 6E424CFB26324B620081004A /* DataStoreUserDefaults.swift in Sources */, @@ -4519,6 +4551,7 @@ 6E75177322C520D400B2B157 /* SDKVersion.swift in Sources */, 6E75179722C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, 6E7518DD22C520D400B2B157 /* ConditionLeaf.swift in Sources */, + 98F28A482E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75187D22C520D400B2B157 /* TrafficAllocation.swift in Sources */, 98F28A252E01940500A86546 /* Cmab.swift in Sources */, C78CAFA524486E0A009FE876 /* OptimizelyJSON+ObjC.swift in Sources */, @@ -4636,6 +4669,7 @@ 6E75195C22C520D500B2B157 /* OPTBucketer.swift in Sources */, 6E7518E422C520D400B2B157 /* ConditionLeaf.swift in Sources */, 6E7518F022C520D500B2B157 /* ConditionHolder.swift in Sources */, + 98F28A442E02DD6D00A86546 /* CmabClient.swift in Sources */, 6EF8DE2424BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E75183022C520D400B2B157 /* BatchEvent.swift in Sources */, 84E2E94E2852A378001114AB /* VuidManager.swift in Sources */, @@ -4692,6 +4726,7 @@ 6E7517CC22C520D400B2B157 /* DefaultBucketer.swift in Sources */, 6E75178E22C520D400B2B157 /* OptimizelyClient+Extension.swift in Sources */, 6E75172E22C520D400B2B157 /* Constants.swift in Sources */, + 98F28A422E02DD6D00A86546 /* CmabClient.swift in Sources */, 84E7ABC327D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E9B11E022C548A200C22D81 /* OptimizelyClientTests_Group.swift in Sources */, 6E75187422C520D400B2B157 /* Variation.swift in Sources */, @@ -4819,6 +4854,7 @@ 6E75175522C520D400B2B157 /* LogMessage.swift in Sources */, C78CAF602445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, 6E623F0B253F9045000617D0 /* DecisionInfo.swift in Sources */, + 98F28A462E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75193722C520D500B2B157 /* OPTDataStore.swift in Sources */, 6E75191322C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, 84E7ABC627D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, @@ -4951,6 +4987,7 @@ 6E7516AF22C520D400B2B157 /* DefaultLogger.swift in Sources */, 6EF8DE2524BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 98D5AE852DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */, + 98F28A532E02E81500A86546 /* CMABClientTests.swift in Sources */, 6E75194522C520D500B2B157 /* OPTDecisionService.swift in Sources */, 6E75185522C520D400B2B157 /* ProjectConfig.swift in Sources */, 84F6BAB427FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift in Sources */, @@ -5002,6 +5039,7 @@ 6E7518CD22C520D400B2B157 /* Audience.swift in Sources */, 980CC90C2D833F2800E07D24 /* ExperimentCore.swift in Sources */, 84E2E96E28540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, + 98F28A4E2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E9B117322C5487100C22D81 /* BatchEventBuilderTests_Attributes.swift in Sources */, 6E9B11B622C5489600C22D81 /* OTUtils.swift in Sources */, 6E75183122C520D400B2B157 /* BatchEvent.swift in Sources */, @@ -5188,6 +5226,7 @@ 6E7516F822C520D400B2B157 /* OptimizelyError.swift in Sources */, 84B4D75E27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */, 848617E82863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, + 98F28A4F2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E424C09263228FD0081004A /* AtomicDictionary.swift in Sources */, 6E75189E22C520D400B2B157 /* Experiment.swift in Sources */, 6E75178822C520D400B2B157 /* ArrayEventForDispatch+Extension.swift in Sources */, @@ -5237,6 +5276,7 @@ 6E75182B22C520D400B2B157 /* BatchEvent.swift in Sources */, 6EF8DE1E24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 98D5AE842DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */, + 98F28A522E02E81500A86546 /* CMABClientTests.swift in Sources */, 6E75190322C520D500B2B157 /* Attribute.swift in Sources */, 6E75192722C520D500B2B157 /* DataStoreQueueStack.swift in Sources */, 6E7516F122C520D400B2B157 /* OptimizelyError.swift in Sources */, @@ -5288,6 +5328,7 @@ 6E7517BF22C520D400B2B157 /* DefaultDatafileHandler.swift in Sources */, 6E9B115922C5486E00C22D81 /* BatchEventBuilderTests_Attributes.swift in Sources */, 6E9B11AA22C5489200C22D81 /* OTUtils.swift in Sources */, + 98F28A432E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E7518D322C520D400B2B157 /* AttributeValue.swift in Sources */, 6E0A72D426C5B9AE00FF92B5 /* OptimizelyUserContextTests_ForcedDecisions.swift in Sources */, 6EF41A332522BE1900EAADF1 /* OptimizelyUserContextTests_Decide.swift in Sources */, @@ -5414,6 +5455,7 @@ 6E5D12242638DDF4000ABFC3 /* MockEventDispatcher.swift in Sources */, 6E75183922C520D400B2B157 /* EventForDispatch.swift in Sources */, 6E9B118A22C5488100C22D81 /* ExperimentTests.swift in Sources */, + 98F28A492E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E7516E722C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E75181522C520D400B2B157 /* DataStoreQueueStackImpl.swift in Sources */, 6EF8DE2124BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, @@ -5522,6 +5564,7 @@ 6E7517E622C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 984FE51F2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171822C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, + 98F28A452E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75174822C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 84E2E94C2852A378001114AB /* VuidManager.swift in Sources */, 6E7518FA22C520D500B2B157 /* UserAttribute.swift in Sources */, @@ -5629,6 +5672,7 @@ 6E7517EB22C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 984FE51D2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171D22C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, + 98F28A4A2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75174D22C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 84E2E9512852A378001114AB /* VuidManager.swift in Sources */, 6E7518FF22C520D500B2B157 /* UserAttribute.swift in Sources */, @@ -5744,6 +5788,7 @@ 6E75188822C520D400B2B157 /* Project.swift in Sources */, 6E7518D022C520D400B2B157 /* AttributeValue.swift in Sources */, 6E75181C22C520D400B2B157 /* BatchEventBuilder.swift in Sources */, + 98F28A4C2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E7518DC22C520D400B2B157 /* ConditionLeaf.swift in Sources */, 98F28A1F2E01940500A86546 /* Cmab.swift in Sources */, C78CAFA424486E0A009FE876 /* OptimizelyJSON+ObjC.swift in Sources */, @@ -5861,6 +5906,7 @@ 6E75188A22C520D400B2B157 /* Project.swift in Sources */, 6E75195622C520D500B2B157 /* OPTBucketer.swift in Sources */, 6E7518DE22C520D400B2B157 /* ConditionLeaf.swift in Sources */, + 98F28A4B2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6EF8DE1D24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E7518EA22C520D400B2B157 /* ConditionHolder.swift in Sources */, 84E2E9462852A378001114AB /* VuidManager.swift in Sources */, @@ -5993,6 +6039,7 @@ 75C71A4125E454460084187E /* MurmurHash3.swift in Sources */, 848617ED2863E21200B7F41B /* OdpEventApiManager.swift in Sources */, 848617FE286CF33700B7F41B /* OdpEvent.swift in Sources */, + 98F28A502E02DD6D00A86546 /* CmabClient.swift in Sources */, 75C71A4225E454460084187E /* HandlerRegistryService.swift in Sources */, 75C71A4325E454460084187E /* LogMessage.swift in Sources */, 75C71A4425E454460084187E /* AtomicProperty.swift in Sources */, @@ -6049,6 +6096,7 @@ BD6485572491474500F30986 /* Project.swift in Sources */, BD6485582491474500F30986 /* AttributeValue.swift in Sources */, BD6485592491474500F30986 /* BatchEventBuilder.swift in Sources */, + 98F28A4D2E02DD6D00A86546 /* CmabClient.swift in Sources */, BD64855A2491474500F30986 /* ConditionLeaf.swift in Sources */, 98F28A222E01940500A86546 /* Cmab.swift in Sources */, BD64855B2491474500F30986 /* OptimizelyJSON+ObjC.swift in Sources */, diff --git a/Sources/CMAB/CmabClient.swift b/Sources/CMAB/CmabClient.swift new file mode 100644 index 00000000..3444cf6e --- /dev/null +++ b/Sources/CMAB/CmabClient.swift @@ -0,0 +1,194 @@ +// +// Copyright 2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum CmabClientError: Error, Equatable { + case fetchFailed(String) + case invalidResponse + + var message: String { + switch self { + case .fetchFailed(let message): + return message + case .invalidResponse: + return "Invalid response from CMA-B server" + + } + } +} + +struct CmabRetryConfig { + var maxRetries: Int = 3 + var initialBackoff: TimeInterval = 0.1 // seconds + var maxBackoff: TimeInterval = 10.0 // seconds + var backoffMultiplier: Double = 2.0 +} + +protocol CmabClient { + func fetchDecision( + ruleId: String, + userId: String, + attributes: [String: Any], + cmabUUID: String, + completion: @escaping (Result) -> Void + ) +} + +class DefaultCmabClient: CmabClient { + let session: URLSession + let retryConfig: CmabRetryConfig + let maxWaitTime: TimeInterval + let cmabQueue = DispatchQueue(label: "com.optimizley.cmab") + let logger = OPTLoggerFactory.getLogger() + + init(session: URLSession = .shared, + retryConfig: CmabRetryConfig = CmabRetryConfig(), + maxWaitTime: TimeInterval = 10.0 + ) { + self.session = session + self.retryConfig = retryConfig + self.maxWaitTime = maxWaitTime + } + + func fetchDecision( + ruleId: String, + userId: String, + attributes: [String: Any], + cmabUUID: String, + completion: @escaping (Result) -> Void + ) { + let urlString = "https://prediction.cmab.optimizely.com/predict/\(ruleId)" + guard let url = URL(string: urlString) else { + completion(.failure(CmabClientError.fetchFailed("Invalid URL"))) + return + } + let attrType = "custom_attribute" + let cmabAttributes = attributes.map { (key, value) in + ["id": key, "value": value, "type": attrType] + } + + let requestBody: [String: Any] = [ + "instances": [[ + "visitorId": userId, + "experimentId": ruleId, + "attributes": cmabAttributes, + "cmabUUID": cmabUUID + ]] + ] + + doFetchWithRetry( + url: url, + requestBody: requestBody, + timeout: maxWaitTime, + completion: completion + ) + } + + private func doFetchWithRetry( + url: URL, + requestBody: [String: Any], + timeout: TimeInterval, + completion: @escaping (Result) -> Void + ) { + var attempt = 0 + var backoff = retryConfig.initialBackoff + + func attemptFetch() { + doFetch(url: url, requestBody: requestBody, timeout: timeout) { result in + switch result { + case .success(let variationId): + completion(.success(variationId)) + case .failure(let error): + self.logger.e((error as? CmabClientError)?.message ?? "") + if let cmabError = error as? CmabClientError { + if case .invalidResponse = cmabError { + // Don't retry on invalid response + completion(.failure(cmabError)) + return + } + } + if attempt < self.retryConfig.maxRetries { + attempt += 1 + self.cmabQueue.asyncAfter(deadline: .now() + backoff) { + backoff = min(backoff * pow(self.retryConfig.backoffMultiplier, Double(attempt)), self.retryConfig.maxBackoff) + attemptFetch() + } + } else { + completion(.failure(CmabClientError.fetchFailed("Exhausted all retries for CMAB request. Last error: \(error)"))) + } + } + } + } + attemptFetch() + } + + private func doFetch( + url: URL, + requestBody: [String: Any], + timeout: TimeInterval, + completion: @escaping (Result) -> Void + ) { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = timeout + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + guard let httpBody = try? JSONSerialization.data(withJSONObject: requestBody, options: []) else { + completion(.failure(CmabClientError.fetchFailed("Failed to encode request body"))) + return + } + request.httpBody = httpBody + + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(CmabClientError.fetchFailed(error.localizedDescription))) + return + } + guard let httpResponse = response as? HTTPURLResponse, let data = data, (200...299).contains(httpResponse.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + completion(.failure(CmabClientError.fetchFailed("HTTP error code: \(code)"))) + return + } + do { + if + let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + self.validateResponse(body: json), + let predictions = json["predictions"] as? [[String: Any]], + let variationId = predictions.first?["variation_id"] as? String + { + completion(.success(variationId)) + } else { + completion(.failure(CmabClientError.invalidResponse)) + } + } catch { + completion(.failure(CmabClientError.invalidResponse)) + } + } + task.resume() + } + + private func validateResponse(body: [String: Any]) -> Bool { + if + let predictions = body["predictions"] as? [[String: Any]], + predictions.count > 0, + predictions.first?["variation_id"] != nil + { + return true + } + return false + } +} diff --git a/Tests/OptimizelyTests-Common/CMABClientTests.swift b/Tests/OptimizelyTests-Common/CMABClientTests.swift new file mode 100644 index 00000000..6a98502e --- /dev/null +++ b/Tests/OptimizelyTests-Common/CMABClientTests.swift @@ -0,0 +1,278 @@ +// +// Copyright 2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class DefaultCmabClientTests: XCTestCase { + var client: DefaultCmabClient! + var mockSession: MockURLSession! + var shortRetryConfig: CmabRetryConfig! + + override func setUp() { + super.setUp() + mockSession = MockURLSession() + shortRetryConfig = CmabRetryConfig(maxRetries: 2, initialBackoff: 0.01, maxBackoff: 0.05, backoffMultiplier: 1.0) + client = DefaultCmabClient(session: mockSession, retryConfig: shortRetryConfig) + } + + override func tearDown() { + client = nil + mockSession = nil + shortRetryConfig = nil + super.tearDown() + } + + // MARK: - Helpers + + func makeSuccessResponse(variationId: String) -> (Data, URLResponse, Error?) { + let json: [String: Any] = [ + "predictions": [ + ["variation_id": variationId] + ] + ] + let data = try! JSONSerialization.data(withJSONObject: json, options: []) + let response = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 200, httpVersion: nil, headerFields: nil)! + return (data, response, nil) + } + + func makeFailureResponse() -> (Data, URLResponse, Error?) { + let response = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data(), response, nil) + } + + // MARK: - Test Cases + + func testFetchDecision_SuccessOnFirstTry() { + let (successData, successResponse, _) = makeSuccessResponse(variationId: "variation-123") + mockSession.responses = [(successData, successResponse, nil)] + + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + if case let .success(variationId) = result { + XCTAssertEqual(variationId, "variation-123") + XCTAssertEqual(self.mockSession.callCount, 1) + } else { + XCTFail("Expected success result") + } + self.verifyRequest(ruleId: "abc", userId: "user1", attributes: ["foo": "bar"], cmabUUID: "uuid") + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + func testFetchDecision_SuccessOnSecondTry() { + let (successData, successResponse, _) = makeSuccessResponse(variationId: "variation-retry") + let fail = makeFailureResponse() + mockSession.responses = [fail, (successData, successResponse, nil)] + + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + if case let .success(variationId) = result { + XCTAssertEqual(variationId, "variation-retry") + XCTAssertEqual(self.mockSession.callCount, 2) + } else { + XCTFail("Expected success after retry") + } + self.verifyRequest(ruleId: "abc", userId: "user1", attributes: ["foo": "bar"], cmabUUID: "uuid") + expectation.fulfill() + } + waitForExpectations(timeout: 2) + } + + func testFetchDecision_SuccessOnThirdTry() { + let (successData, successResponse, _) = makeSuccessResponse(variationId: "success-third") + let fail = makeFailureResponse() + mockSession.responses = [fail, fail, (successData, successResponse, nil)] + + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + if case let .success(variationId) = result { + XCTAssertEqual(variationId, "success-third") + XCTAssertEqual(self.mockSession.callCount, 3) + } else { + XCTFail("Expected success after two retries") + } + self.verifyRequest(ruleId: "abc", userId: "user1", attributes: ["foo": "bar"], cmabUUID: "uuid") + expectation.fulfill() + } + waitForExpectations(timeout: 2) + } + + func testFetchDecision_ExhaustsAllRetries() { + let fail = makeFailureResponse() + mockSession.responses = [fail, fail, fail] + + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + if case let .failure(error) = result { + XCTAssertTrue("\(error)".contains("Exhausted all retries")) + XCTAssertEqual(self.mockSession.callCount, 3) + } else { + XCTFail("Expected failure after all retries") + } + self.verifyRequest(ruleId: "abc", userId: "user1", attributes: ["foo": "bar"], cmabUUID: "uuid") + expectation.fulfill() + } + waitForExpectations(timeout: 2) + } + + func testFetchDecision_HttpError() { + mockSession.responses = [ + (Data(), HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 500, httpVersion: nil, headerFields: nil), nil) + ] + + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + if case let .failure(error) = result { + XCTAssertTrue("\(error)".contains("HTTP error code")) + } else { + XCTFail("Expected failure on HTTP error") + } + self.verifyRequest(ruleId: "abc", userId: "user1", attributes: ["foo": "bar"], cmabUUID: "uuid") + expectation.fulfill() + } + waitForExpectations(timeout: 2) + } + + func testFetchDecision_InvalidJson() { + mockSession.responses = [ + (Data("not a json".utf8), HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 200, httpVersion: nil, headerFields: nil), nil) + ] + + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + if case let .failure(error) = result { + XCTAssertTrue(error is CmabClientError) + XCTAssertEqual(self.mockSession.callCount, 1) + } else { + XCTFail("Expected failure on invalid JSON") + } + self.verifyRequest(ruleId: "abc", userId: "user1", attributes: ["foo": "bar"], cmabUUID: "uuid") + expectation.fulfill() + } + waitForExpectations(timeout: 2) + } + + func testFetchDecision_Invalid_Response_Structure() { + let responseJSON: [String: Any] = [ "not_predictions": [] ] + let responseData = try! JSONSerialization.data(withJSONObject: responseJSON, options: []) + mockSession.responses = [ + (responseData, HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 200, httpVersion: nil, headerFields: nil), nil) + ] + + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid-1234" + ) { result in + if case let .failure(error) = result { + XCTAssertEqual(error as? CmabClientError, .invalidResponse) + XCTAssertEqual(self.mockSession.callCount, 1) + } else { + XCTFail("Expected failure on invalid response structure") + } + self.verifyRequest(ruleId: "abc", userId: "user1", attributes: ["foo": "bar"], cmabUUID: "uuid-1234") + expectation.fulfill() + } + waitForExpectations(timeout: 2) + + } + + private func verifyRequest(ruleId: String, userId: String, attributes: [String: Any], cmabUUID: String) { + // Assert request body + guard let request = mockSession.lastRequest else { + XCTFail("No request was sent") + return + } + guard let body = request.httpBody else { + XCTFail("No HTTP body in request") + return + } + + let json = try! JSONSerialization.jsonObject(with: body, options: []) as! [String: Any] + let instances = json["instances"] as? [[String: Any]] + XCTAssertNotNil(instances) + let instance = instances?.first + XCTAssertEqual(instance?["visitorId"] as? String, userId) + XCTAssertEqual(instance?["experimentId"] as? String, ruleId) + XCTAssertEqual(instance?["cmabUUID"] as? String, cmabUUID) + // You can add further assertions for the attributes, e.g.: + let payloadAttributes = instance?["attributes"] as? [[String: Any]] + XCTAssertEqual(payloadAttributes?.first?["id"] as? String, attributes.keys.first) + XCTAssertEqual(payloadAttributes?.first?["value"] as? String, attributes.values.first as? String) + XCTAssertEqual(payloadAttributes?.first?["type"] as? String, "custom_attribute") + } + +} + +// MARK: - MockURLSession for ordered responses + +extension DefaultCmabClientTests { + class MockURLSessionDataTask: URLSessionDataTask { + private let closure: () -> Void + override var state: URLSessionTask.State { .completed } + init(closure: @escaping () -> Void) { self.closure = closure } + override func resume() { closure() } + } + + class MockURLSession: URLSession { + typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void + var responses: [(Data?, URLResponse?, Error?)] = [] + var callCount = 0 + var lastRequest: URLRequest? + + override func dataTask( + with request: URLRequest, + completionHandler: @escaping CompletionHandler + ) -> URLSessionDataTask { + self.lastRequest = request + let idx = callCount + callCount += 1 + let tuple = idx < responses.count ? responses[idx] : (nil, nil, nil) + return MockURLSessionDataTask { completionHandler(tuple.0, tuple.1, tuple.2) } + } + } +} From 123a19899c5345c40f2d745766a87a535f1af81a Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Wed, 2 Jul 2025 19:56:54 +0600 Subject: [PATCH 33/37] [FSSDK-11163] feat: add cmab service (#601) --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 40 ++ Sources/CMAB/CmabClient.swift | 4 +- Sources/CMAB/CmabService.swift | 157 ++++++ .../OptimizelyDecideOption.swift | 9 + .../CMABClientTests.swift | 2 +- .../CmabServiceTests.swift | 464 ++++++++++++++++++ 6 files changed, 673 insertions(+), 3 deletions(-) create mode 100644 Sources/CMAB/CmabService.swift create mode 100644 Tests/OptimizelyTests-Common/CmabServiceTests.swift diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index bc8c718c..ae0344f1 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2110,6 +2110,24 @@ 98F28A502E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; 98F28A522E02E81500A86546 /* CMABClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A512E02E81500A86546 /* CMABClientTests.swift */; }; 98F28A532E02E81500A86546 /* CMABClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A512E02E81500A86546 /* CMABClientTests.swift */; }; + 98F28A562E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A572E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A582E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A592E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A5A2E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A5B2E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A5C2E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A5D2E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A5E2E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A5F2E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A602E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A612E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A622E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A632E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A642E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A652E0451CC00A86546 /* CmabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A552E0451CC00A86546 /* CmabService.swift */; }; + 98F28A672E05220300A86546 /* CmabServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A662E05220300A86546 /* CmabServiceTests.swift */; }; + 98F28A682E05220300A86546 /* CmabServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A662E05220300A86546 /* CmabServiceTests.swift */; }; BD1C3E8524E4399C0084B4DA /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B97DD93249D327F003DE606 /* SemanticVersion.swift */; }; BD64853C2491474500F30986 /* Optimizely.h in Headers */ = {isa = PBXBuildFile; fileRef = 6E75167A22C520D400B2B157 /* Optimizely.h */; settings = {ATTRIBUTES = (Public, ); }; }; BD64853E2491474500F30986 /* Audience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E75169822C520D400B2B157 /* Audience.swift */; }; @@ -2565,6 +2583,8 @@ 98F28A2D2E01968000A86546 /* CmabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabTests.swift; sourceTree = ""; }; 98F28A402E02DD6D00A86546 /* CmabClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabClient.swift; sourceTree = ""; }; 98F28A512E02E81500A86546 /* CMABClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMABClientTests.swift; sourceTree = ""; }; + 98F28A552E0451CC00A86546 /* CmabService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabService.swift; sourceTree = ""; }; + 98F28A662E05220300A86546 /* CmabServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabServiceTests.swift; sourceTree = ""; }; BD6485812491474500F30986 /* Optimizely.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Optimizely.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyJSON.swift; sourceTree = ""; }; C78CAF652446DB91009FE876 /* OptimizelyClientTests_OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_OptimizelyJSON.swift; sourceTree = ""; }; @@ -3098,6 +3118,7 @@ 8486180E286D0B8900B7F41B /* OdpManagerTests.swift */, 8486180D286D0B8900B7F41B /* OdpSegmentManagerTests.swift */, 98F28A512E02E81500A86546 /* CMABClientTests.swift */, + 98F28A662E05220300A86546 /* CmabServiceTests.swift */, 8486180F286D0B8900B7F41B /* VuidManagerTests.swift */, 84861819286D188B00B7F41B /* OdpSegmentApiManagerTests.swift */, 8486181A286D188B00B7F41B /* OdpEventApiManagerTests.swift */, @@ -3270,6 +3291,7 @@ isa = PBXGroup; children = ( 98F28A402E02DD6D00A86546 /* CmabClient.swift */, + 98F28A552E0451CC00A86546 /* CmabService.swift */, ); path = CMAB; sourceTree = ""; @@ -4343,6 +4365,7 @@ 6E14CD752423F97600010234 /* OptimizelyConfig+ObjC.swift in Sources */, 6E14CD712423F96800010234 /* OptimizelyClient.swift in Sources */, 6E14CD842423F9A100010234 /* BatchEventBuilder.swift in Sources */, + 98F28A5E2E0451CC00A86546 /* CmabService.swift in Sources */, 6E14CD6E2423F85E00010234 /* EventDispatcherTests_Batch.swift in Sources */, 6E14CDA92423F9C300010234 /* Utils.swift in Sources */, 98F28A472E02DD6D00A86546 /* CmabClient.swift in Sources */, @@ -4416,6 +4439,7 @@ 84E2E9782855875E001114AB /* OdpEventManager.swift in Sources */, 6E424CFA26324B620081004A /* DataStoreMemory.swift in Sources */, 6E424CFB26324B620081004A /* DataStoreUserDefaults.swift in Sources */, + 98F28A652E0451CC00A86546 /* CmabService.swift in Sources */, 6E6522E4278E4F3800954EA1 /* OdpManager.swift in Sources */, 6E424CFC26324B620081004A /* DataStoreFile.swift in Sources */, 6E424CFD26324B620081004A /* DataStoreQueueStackImpl.swift in Sources */, @@ -4574,6 +4598,7 @@ C78CAF592445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, 848617C92863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, 6E7518E922C520D400B2B157 /* ConditionHolder.swift in Sources */, + 98F28A622E0451CC00A86546 /* CmabService.swift in Sources */, 6E75184122C520D400B2B157 /* Event.swift in Sources */, 6E7517C922C520D400B2B157 /* DefaultBucketer.swift in Sources */, 6E75181D22C520D400B2B157 /* BatchEventBuilder.swift in Sources */, @@ -4667,6 +4692,7 @@ 6E7516DE22C520D400B2B157 /* OPTUserProfileService.swift in Sources */, 6E75189022C520D400B2B157 /* Project.swift in Sources */, 6E75195C22C520D500B2B157 /* OPTBucketer.swift in Sources */, + 98F28A572E0451CC00A86546 /* CmabService.swift in Sources */, 6E7518E422C520D400B2B157 /* ConditionLeaf.swift in Sources */, 6E7518F022C520D500B2B157 /* ConditionHolder.swift in Sources */, 98F28A442E02DD6D00A86546 /* CmabClient.swift in Sources */, @@ -4830,6 +4856,7 @@ C78CAF7424482C86009FE876 /* OptimizelyClientTests_OptimizelyJSON.swift in Sources */, 6EC6DD3724ABF6990017D296 /* OptimizelyClient+Decide.swift in Sources */, 6E7516E622C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, + 98F28A612E0451CC00A86546 /* CmabService.swift in Sources */, 6EF8DE3724BF7D69008B9488 /* DecisionReasons.swift in Sources */, 6EC6DD4724ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */, 6E7518F822C520D500B2B157 /* UserAttribute.swift in Sources */, @@ -4904,6 +4931,7 @@ 6E75188F22C520D400B2B157 /* Project.swift in Sources */, 6E75195B22C520D500B2B157 /* OPTBucketer.swift in Sources */, 6E7518E322C520D400B2B157 /* ConditionLeaf.swift in Sources */, + 98F28A5D2E0451CC00A86546 /* CmabService.swift in Sources */, 6E7518EF22C520D400B2B157 /* ConditionHolder.swift in Sources */, 6E75182F22C520D400B2B157 /* BatchEvent.swift in Sources */, 6E75191F22C520D500B2B157 /* OPTNotificationCenter.swift in Sources */, @@ -4959,6 +4987,7 @@ 6E9B117422C5487100C22D81 /* DecisionServiceTests_Others.swift in Sources */, 6E9B116E22C5487100C22D81 /* LoggerTests.swift in Sources */, 84F6BADE27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift in Sources */, + 98F28A5C2E0451CC00A86546 /* CmabService.swift in Sources */, 6E75180D22C520D400B2B157 /* DataStoreFile.swift in Sources */, 6E75178722C520D400B2B157 /* ArrayEventForDispatch+Extension.swift in Sources */, 6E75179F22C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, @@ -5007,6 +5036,7 @@ 6E75192D22C520D500B2B157 /* DataStoreQueueStack.swift in Sources */, 6E7516D322C520D400B2B157 /* OPTLogger.swift in Sources */, 6E75180122C520D400B2B157 /* DataStoreUserDefaults.swift in Sources */, + 98F28A672E05220300A86546 /* CmabServiceTests.swift in Sources */, 98AC98472DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */, 6E75175722C520D400B2B157 /* LogMessage.swift in Sources */, 98F28A242E01940500A86546 /* Cmab.swift in Sources */, @@ -5153,6 +5183,7 @@ 6E9B119622C5488300C22D81 /* AudienceTests.swift in Sources */, 6E7518B622C520D400B2B157 /* Group.swift in Sources */, 6E7516D422C520D400B2B157 /* OPTLogger.swift in Sources */, + 98F28A602E0451CC00A86546 /* CmabService.swift in Sources */, 6E75183222C520D400B2B157 /* BatchEvent.swift in Sources */, 6E7518DA22C520D400B2B157 /* AttributeValue.swift in Sources */, 84640882281320F000CCF97D /* IntegrationTests.swift in Sources */, @@ -5248,6 +5279,7 @@ 6E9B115422C5486E00C22D81 /* LoggerTests.swift in Sources */, 6E7518DF22C520D400B2B157 /* ConditionLeaf.swift in Sources */, 6E75172D22C520D400B2B157 /* Constants.swift in Sources */, + 98F28A5A2E0451CC00A86546 /* CmabService.swift in Sources */, 6E75172122C520D400B2B157 /* OptimizelyResult.swift in Sources */, 6E75186722C520D400B2B157 /* Rollout.swift in Sources */, 6E424C01263228FD0081004A /* AtomicDictionary.swift in Sources */, @@ -5296,6 +5328,7 @@ 6E75193322C520D500B2B157 /* OPTDataStore.swift in Sources */, 84861811286D0B8900B7F41B /* OdpSegmentManagerTests.swift in Sources */, 6E7517EF22C520D400B2B157 /* DataStoreMemory.swift in Sources */, + 98F28A682E05220300A86546 /* CmabServiceTests.swift in Sources */, 6E75194B22C520D500B2B157 /* OPTDatafileHandler.swift in Sources */, 6E75195722C520D500B2B157 /* OPTBucketer.swift in Sources */, 98F28A2A2E01940500A86546 /* Cmab.swift in Sources */, @@ -5437,6 +5470,7 @@ 6E86CEA924FDC847005DAFED /* OptimizelyUserContext+ObjC.swift in Sources */, 6E9B118322C5488100C22D81 /* UserAttributeTests_Evaluate.swift in Sources */, 98F28A1D2E01940500A86546 /* Cmab.swift in Sources */, + 98F28A582E0451CC00A86546 /* CmabService.swift in Sources */, 6E9B118E22C5488100C22D81 /* ProjectTests.swift in Sources */, 6E9B118022C5488100C22D81 /* AudienceTests.swift in Sources */, 6E7518B122C520D400B2B157 /* Group.swift in Sources */, @@ -5563,6 +5597,7 @@ 6E7517DA22C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E7517E622C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 984FE51F2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, + 98F28A5B2E0451CC00A86546 /* CmabService.swift in Sources */, 6E75171822C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 98F28A452E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75174822C520D400B2B157 /* HandlerRegistryService.swift in Sources */, @@ -5671,6 +5706,7 @@ 6E7517DF22C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E7517EB22C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 984FE51D2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, + 98F28A562E0451CC00A86546 /* CmabService.swift in Sources */, 6E75171D22C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 98F28A4A2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75174D22C520D400B2B157 /* HandlerRegistryService.swift in Sources */, @@ -5811,6 +5847,7 @@ C78CAF582445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, 848617C82863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, 6E75170622C520D400B2B157 /* OptimizelyClient.swift in Sources */, + 98F28A642E0451CC00A86546 /* CmabService.swift in Sources */, 6E7518A022C520D400B2B157 /* FeatureFlag.swift in Sources */, 6E75174222C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 6E75187022C520D400B2B157 /* Variation.swift in Sources */, @@ -5904,6 +5941,7 @@ 6E7517E222C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 6E7516D822C520D400B2B157 /* OPTUserProfileService.swift in Sources */, 6E75188A22C520D400B2B157 /* Project.swift in Sources */, + 98F28A5F2E0451CC00A86546 /* CmabService.swift in Sources */, 6E75195622C520D500B2B157 /* OPTBucketer.swift in Sources */, 6E7518DE22C520D400B2B157 /* ConditionLeaf.swift in Sources */, 98F28A4B2E02DD6D00A86546 /* CmabClient.swift in Sources */, @@ -6017,6 +6055,7 @@ 75C71A2E25E454460084187E /* Project.swift in Sources */, 75C71A2F25E454460084187E /* Experiment.swift in Sources */, 75C71A3025E454460084187E /* FeatureFlag.swift in Sources */, + 98F28A592E0451CC00A86546 /* CmabService.swift in Sources */, 75C71A3125E454460084187E /* Group.swift in Sources */, 75C71A3225E454460084187E /* Variable.swift in Sources */, 848617DD2863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, @@ -6119,6 +6158,7 @@ BD6485662491474500F30986 /* OptimizelyJSON.swift in Sources */, 848617CA2863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, BD6485672491474500F30986 /* OptimizelyClient.swift in Sources */, + 98F28A632E0451CC00A86546 /* CmabService.swift in Sources */, BD6485682491474500F30986 /* FeatureFlag.swift in Sources */, BD6485692491474500F30986 /* HandlerRegistryService.swift in Sources */, BD64856A2491474500F30986 /* Variation.swift in Sources */, diff --git a/Sources/CMAB/CmabClient.swift b/Sources/CMAB/CmabClient.swift index 3444cf6e..1e69c3d9 100644 --- a/Sources/CMAB/CmabClient.swift +++ b/Sources/CMAB/CmabClient.swift @@ -42,7 +42,7 @@ protocol CmabClient { func fetchDecision( ruleId: String, userId: String, - attributes: [String: Any], + attributes: [String: Any?], cmabUUID: String, completion: @escaping (Result) -> Void ) @@ -67,7 +67,7 @@ class DefaultCmabClient: CmabClient { func fetchDecision( ruleId: String, userId: String, - attributes: [String: Any], + attributes: [String: Any?], cmabUUID: String, completion: @escaping (Result) -> Void ) { diff --git a/Sources/CMAB/CmabService.swift b/Sources/CMAB/CmabService.swift new file mode 100644 index 00000000..e0eb9580 --- /dev/null +++ b/Sources/CMAB/CmabService.swift @@ -0,0 +1,157 @@ +// +// Copyright 2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct CmabDecision { + let variationId: String + let cmabUUID: String +} + +struct CmabCacheValue { + let attributesHash: String + let variationId: String + let cmabUUID: String +} + +typealias CmabDecisionCompletionHandler = (Result) -> Void + +protocol CmabService { + func getDecision(config: ProjectConfig, + userContext: OptimizelyUserContext, + ruleId: String, + options: [OptimizelyDecideOption], + completion: @escaping CmabDecisionCompletionHandler) +} + +class DefaultCmabService { + typealias UserAttributes = [String : Any?] + + private let cmabClient: CmabClient + private let cmabCache: LruCache + private let logger = OPTLoggerFactory.getLogger() + + init(cmabClient: CmabClient, cmabCache: LruCache) { + self.cmabClient = cmabClient + self.cmabCache = cmabCache + } + + func getDecision(config: ProjectConfig, + userContext: OptimizelyUserContext, + ruleId: String, + options: [OptimizelyDecideOption], + completion: @escaping CmabDecisionCompletionHandler) { + + let filteredAttributes = filterAttributes(config: config, attributes: userContext.attributes, ruleId: ruleId) + + let userId = userContext.userId + + if options.contains(.ignoreCmabCache) { + self.logger.i("Ignoring CMAB cache.") + fetchDecision(ruleId: ruleId, userId: userId, attributes: filteredAttributes, completion: completion) + return + } + + if options.contains(.resetCmabCache) { + self.logger.i("Resetting CMAB cache.") + cmabCache.reset() + } + + let cacheKey = getCacheKey(userId: userId, ruleId: ruleId) + + if options.contains(.invalidateUserCmabCache) { + self.logger.i("Invalidating user CMAB cache.") + self.cmabCache.remove(key: cacheKey) + } + + let attributesHash = hashAttributes(filteredAttributes) + + if let cachedValue = cmabCache.lookup(key: cacheKey), cachedValue.attributesHash == attributesHash { + let decision = CmabDecision(variationId: cachedValue.variationId, cmabUUID: cachedValue.cmabUUID) + self.logger.i("Returning cached CMAB decision.") + completion(.success(decision)) + return + } else { + self.logger.i("CMAB decision not found in cache.") + cmabCache.remove(key: cacheKey) + } + + fetchDecision(ruleId: ruleId, userId: userId, attributes: filteredAttributes) { result in + if case .success(let decision) = result { + let cacheValue = CmabCacheValue( + attributesHash: attributesHash, + variationId: decision.variationId, + cmabUUID: decision.cmabUUID + ) + self.logger.i("Featched CMAB decision and cached it.") + self.cmabCache.save(key: cacheKey, value: cacheValue) + } + completion(result) + } + } + + private func fetchDecision(ruleId: String, + userId: String, + attributes: UserAttributes, + completion: @escaping CmabDecisionCompletionHandler) { + let cmabUUID = UUID().uuidString + cmabClient.fetchDecision(ruleId: ruleId, userId: userId, attributes: attributes, cmabUUID: cmabUUID) { result in + switch result { + case .success(let variaitonId): + self.logger.i("Fetched CMAB decision: \(variaitonId)") + let decision = CmabDecision(variationId: variaitonId, cmabUUID: cmabUUID) + completion(.success(decision)) + case .failure(let error): + self.logger.e("Failed to fetch CMAB decision: \(error)") + completion(.failure(error)) + } + } + } + + func getCacheKey(userId: String, ruleId: String) -> String { + return "\(userId.count)-\(userId)-\(ruleId)" + } + + func hashAttributes(_ attributes: UserAttributes) -> String { + // Sort and serialize as array of [key, value] pairs for deterministic output + let sortedPairs = attributes.sorted { $0.key < $1.key } + .map { [$0.key, $0.value] } + guard let data = try? JSONSerialization.data(withJSONObject: sortedPairs, options: []) else { + return "" + } + let hash = MurmurHash3.hash32Bytes(key: [UInt8](data), maxBytes: data.count) + return String(format: "%08x", hash) + } + + private func filterAttributes(config: ProjectConfig, + attributes: UserAttributes, + ruleId: String) -> UserAttributes { + let userAttributes = attributes + var filteredUserAttributes: UserAttributes = [:] + + guard let experiment = config.getExperiment(id: ruleId), let cmab = experiment.cmab else { + return filteredUserAttributes + } + + let cmabAttributeIds = cmab.attributeIds + for attributeId in cmabAttributeIds { + if let attribute = config.getAttribute(id: attributeId), let value = userAttributes[attribute.key] { + filteredUserAttributes[attribute.key] = value + } + } + return filteredUserAttributes + } +} diff --git a/Sources/Optimizely+Decide/OptimizelyDecideOption.swift b/Sources/Optimizely+Decide/OptimizelyDecideOption.swift index 763460a3..f8661986 100644 --- a/Sources/Optimizely+Decide/OptimizelyDecideOption.swift +++ b/Sources/Optimizely+Decide/OptimizelyDecideOption.swift @@ -32,4 +32,13 @@ import Foundation /// exclude variable values from the decision result. case excludeVariables + + /// ignoreCmabCache instructs the SDK to ignore the CMAB cache and make a fresh request + case ignoreCmabCache + + /// resetCmabCache instructs the SDK to reset the entire CMAB cache + case resetCmabCache + + /// InvalidateUserCMABCache instructs the SDK to invalidate CMAB cache entries for the current user + case invalidateUserCmabCache } diff --git a/Tests/OptimizelyTests-Common/CMABClientTests.swift b/Tests/OptimizelyTests-Common/CMABClientTests.swift index 6a98502e..31094a25 100644 --- a/Tests/OptimizelyTests-Common/CMABClientTests.swift +++ b/Tests/OptimizelyTests-Common/CMABClientTests.swift @@ -218,7 +218,6 @@ class DefaultCmabClientTests: XCTestCase { expectation.fulfill() } waitForExpectations(timeout: 2) - } private func verifyRequest(ruleId: String, userId: String, attributes: [String: Any], cmabUUID: String) { @@ -268,6 +267,7 @@ extension DefaultCmabClientTests { with request: URLRequest, completionHandler: @escaping CompletionHandler ) -> URLSessionDataTask { + self.lastRequest = request let idx = callCount callCount += 1 diff --git a/Tests/OptimizelyTests-Common/CmabServiceTests.swift b/Tests/OptimizelyTests-Common/CmabServiceTests.swift new file mode 100644 index 00000000..10c042cc --- /dev/null +++ b/Tests/OptimizelyTests-Common/CmabServiceTests.swift @@ -0,0 +1,464 @@ +// +// Copyright 2015, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +fileprivate class MockCmabClient: CmabClient { + var fetchDecisionResult: Result = .success("variation-1") + var fetchDecisionCalled = false + var lastRuleId: String? + var lastUserId: String? + var lastAttributes: [String: Any?]? + var lastCmabUUID: String? + + func fetchDecision( + ruleId: String, + userId: String, + attributes: [String: Any?], + cmabUUID: String, + completion: @escaping (Result) -> Void + ) { + fetchDecisionCalled = true + lastRuleId = ruleId + lastUserId = userId + lastAttributes = attributes + lastCmabUUID = cmabUUID + completion(fetchDecisionResult) + } + + func reset() { + fetchDecisionCalled = false + lastRuleId = nil + lastUserId = nil + lastAttributes = nil + lastCmabUUID = nil + } +} + +fileprivate class MockProjectConfig: ProjectConfig { + override init() { + super.init() + let data: [String: Any] = ["id": "11111", + "key": "empty", + "status": "Running", + "layerId": "22222", + "variations": [], + "trafficAllocation": [], + "audienceIds": [], + "forcedVariations": ["12345": "1234567890"]] + + let cmab = Cmab(trafficAllocation: 1000, attributeIds: ["attr1", "attr2"]) + + var model1: Experiment = try! OTUtils.model(from: data) + model1.id = "exp-123" + model1.cmab = cmab + + var model2: Experiment = try! OTUtils.model(from: data) + model2.id = "exp-124" + + allExperiments = [model1, model2] + updateProjectDependentProps() + + } + + override func updateProjectDependentProps() { + self.experimentKeyMap = { + var map = [String: Experiment]() + allExperiments.forEach { exp in + map[exp.key] = exp + } + return map + }() + + self.experimentIdMap = { + var map = [String: Experiment]() + allExperiments.forEach { map[$0.id] = $0 } + return map + }() + + let attribute1 = Attribute(id: "attr1", key: "age") + let attribute2 = Attribute(id: "attr2", key: "location") + + attributeIdMap["attr1"] = attribute1 + attributeIdMap["attr2"] = attribute2 + attributeKeyMap["age"] = attribute1 + attributeIdMap["location"] = attribute2 + } + +} + +class MockUserContext: OptimizelyUserContext { + convenience init(userId: String, attributes: [String: Any?]) { + let client = OptimizelyClient(sdkKey: "sdk-key-123") + self.init(optimizely: client, userId: userId, attributes: attributes) + } +} + + +class DefaultCmabServiceTests: XCTestCase { + fileprivate var cmabClient: MockCmabClient! + fileprivate var config: MockProjectConfig! + var cmabCache: LruCache! + var cmabService: DefaultCmabService! + var userContext: OptimizelyUserContext! + let userAttributes: [String: Any] = ["age": 25, "location": "San Francisco"] + + override func setUp() { + super.setUp() + config = MockProjectConfig() + cmabClient = MockCmabClient() + cmabCache = LruCache(size: 10, timeoutInSecs: 10) + cmabService = DefaultCmabService(cmabClient: cmabClient, cmabCache: cmabCache) + // Set up user context + userContext = MockUserContext(userId: "test-user", attributes: userAttributes) + } + + override func tearDown() { + cmabClient = nil + cmabCache = nil + cmabService = nil + config = nil + userContext = nil + super.tearDown() + } + + func testHashAttributesDeterminism() { + // Different order, same attributes + let attributes1: [String: Any?] = ["c": 3, "a": 1, "b": 2] + let attributes2: [String: Any?] = ["a": 1, "b": 2, "c": 3] + + // Access private method for testing + let hash1 = cmabService.hashAttributes(attributes1) + let hash2 = cmabService.hashAttributes(attributes2) + + XCTAssertEqual(hash1, hash2, "Hashes should be deterministic regardless of attribute order") + + // Different attributes should have different hashes + let attributes3: [String: Any?] = ["a": 1, "b": 2, "c": 4] // Changed value + let hash3 = cmabService.hashAttributes(attributes3) + + XCTAssertNotEqual(hash1, hash3, "Different attributes should have different hashes") + } + + func testFilterAttributes() { + // Set up the user attributes that include both relevant and irrelevant ones + let userAttributes: [String: Any?] = [ + "age": 25, + "country": "USA", + "irrelevant": "value" + ] + + userContext = MockUserContext(userId: "test-user", attributes: userAttributes) + + let expectation = self.expectation(description: "fetchDecision") + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [], + completion: { _ in + // Check that only the relevant attributes were passed to the client + XCTAssertEqual(self.cmabClient.lastAttributes?.count, 1) // Only 'age' is found in the config + XCTAssertEqual(self.cmabClient.lastAttributes?["age"] as? Int, 25) + XCTAssertNil(self.cmabClient.lastAttributes?["irrelevant"] ?? nil) + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } + + func testGetCacheKey() { + let userId = "test-user" + let ruleId = "exp-123" + + let cacheKey = cmabService.getCacheKey(userId: userId, ruleId: ruleId) + + XCTAssertEqual(cacheKey, "9-test-user-exp-123") + + // Test with a different user + let cacheKey2 = cmabService.getCacheKey(userId: "other-user", ruleId: ruleId) + + XCTAssertEqual(cacheKey2, "10-other-user-exp-123") + } + + + func testFetchDecision() { + let expectation = self.expectation(description: "fetchDecision") + + cmabClient.fetchDecisionResult = .success("variation-123") + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [], + completion: { result in + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "variation-123") + XCTAssertEqual(self.cmabClient.lastRuleId, "exp-123") + XCTAssertEqual(self.cmabClient.lastUserId, "test-user") + // We expect only the 'age' attribute as that's what's configured in the experiment + XCTAssertEqual(self.cmabClient.lastAttributes?.count, 2) + XCTAssertEqual(self.cmabClient.lastAttributes?["age"] as? Int, 25) + XCTAssertEqual(self.cmabClient.lastAttributes?["location"] as? String, "San Francisco") + + // Verify it was cached + let cacheKey = "9-test-user-exp-123" + XCTAssertNotNil(self.cmabCache.lookup(key: cacheKey)) + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } + + func testCachedDecision() { + // First, put something in the cache + let attributesHash = cmabService.hashAttributes(["age": 25, "location": "San Francisco"]) + let cacheKey = "9-test-user-exp-123" + + let cacheValue = CmabCacheValue(attributesHash: attributesHash, + variationId: "cached-variation", + cmabUUID: "cached-uuid") + + cmabCache.save(key: cacheKey, value: cacheValue) + + let expectation = self.expectation(description: "fetchDecision") + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [], + completion: { result in + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "cached-variation") + XCTAssertEqual(decision.cmabUUID, "cached-uuid") + XCTAssertFalse(self.cmabClient.fetchDecisionCalled, "Should not call API when cache hit") + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } + + func testCacheInvalidationWithChangedAttributes() { + // First, put something in the cache + let attributesHash = cmabService.hashAttributes(["age": 25, "location": "San Francisco"]) + let cacheKey = "9-test-user-exp-123" + let cacheValue = CmabCacheValue(attributesHash: attributesHash, + variationId: "cached-variation", + cmabUUID: "cached-uuid") + cmabCache.save(key: cacheKey, value: cacheValue) + + // When attributes change, the hash should be different and the cache should be invalid + userContext = MockUserContext(userId: "test-user", attributes: ["age": 25]) + + let expectation = self.expectation(description: "fetchDecision") + + cmabClient.fetchDecisionResult = .success("new-variation") + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [], + completion: { result in + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "new-variation") + XCTAssertTrue(self.cmabClient.fetchDecisionCalled, "Should call API when attributes change") + + // Verify cache was updated + let newCacheValue = self.cmabCache.lookup(key: cacheKey) + XCTAssertNotNil(newCacheValue) + XCTAssertEqual(newCacheValue?.variationId, "new-variation") + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } + + + func testIgnoreCmabCacheOption() { + // First, put something in the cache + let attributesHash = cmabService.hashAttributes(["age": 25, "location": "San Francisco"]) + let cacheKey = "9-test-user-exp-123" + let cacheValue = CmabCacheValue(attributesHash: attributesHash, + variationId: "cached-variation", + cmabUUID: "cached-uuid") + cmabCache.save(key: cacheKey, value: cacheValue) + + let expectation = self.expectation(description: "fetchDecision") + + cmabClient.fetchDecisionResult = .success("new-variation") + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [.ignoreCmabCache], + completion: { result in + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "new-variation") + XCTAssertTrue(self.cmabClient.fetchDecisionCalled, "Should always call API when ignoreCmabCache option is set") + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } + + func testResetCmabCacheOption() { + // First, put something in the cache + let attributesHash = cmabService.hashAttributes(["age": 25, "location": "San Francisco"]) + let cacheKey = "9-test-user-exp-123" + let cacheValue = CmabCacheValue(attributesHash: attributesHash, + variationId: "cached-variation", + cmabUUID: "cached-uuid") + cmabCache.save(key: cacheKey, value: cacheValue) + + // Also add another item to the cache to verify it's cleared too + let otherCacheKey = "other-key" + cmabCache.save(key: otherCacheKey, value: cacheValue) + + let expectation = self.expectation(description: "fetchDecision") + cmabClient.fetchDecisionResult = .success("new-variation") + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [.resetCmabCache], + completion: { result in + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "new-variation") + XCTAssertTrue(self.cmabClient.fetchDecisionCalled, "Should call API after resetting cache") + + // Verify the entire cache was reset + XCTAssertNil(self.cmabCache.lookup(key: otherCacheKey)) + + // But the new decision should be cached + XCTAssertNotNil(self.cmabCache.lookup(key: cacheKey)) + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } + + func testInvalidateUserCmabCacheOption() { + // First, put something in the cache + let attributesHash = cmabService.hashAttributes(["age": 25, "location": "San Francisco"]) + let userCacheKey = "9-test-user-exp-123" + let cacheValue = CmabCacheValue(attributesHash: attributesHash, + variationId: "cached-variation", + cmabUUID: "cached-uuid") + cmabCache.save(key: userCacheKey, value: cacheValue) + + // Also add another user to the cache to verify it's NOT cleared + let otherUserCacheKey = "other-user-key" + cmabCache.save(key: otherUserCacheKey, value: cacheValue) + + let expectation = self.expectation(description: "fetchDecision") + + cmabClient.fetchDecisionResult = .success("new-variation") + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [.invalidateUserCmabCache], + completion: { result in + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "new-variation") + XCTAssertTrue(self.cmabClient.fetchDecisionCalled, "Should call API after invalidating user cache") + + // Verify only the specific user's cache was invalidated + XCTAssertNotNil(self.cmabCache.lookup(key: otherUserCacheKey), "Other users' cache should remain intact") + + // The new decision should be cached for the current user + XCTAssertNotNil(self.cmabCache.lookup(key: userCacheKey)) + XCTAssertEqual(self.cmabCache.lookup(key: userCacheKey)?.variationId, "new-variation") + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } + + func testFailedFetch() { + let expectation = self.expectation(description: "fetchDecision") + + let testError = CmabClientError.fetchFailed("Test error") + cmabClient.fetchDecisionResult = .failure(testError) + + cmabService.getDecision(config: config, + userContext: userContext, + ruleId: "exp-123", + options: [], + completion: { result in + + switch result { + case .success: + XCTFail("Expected failure but got success") + + case .failure(let error): + XCTAssertEqual((error as? CmabClientError)?.message, "Test error") + + // Verify no caching of failed results + let cacheKey = "9-test-user-exp-123" + XCTAssertNil(self.cmabCache.lookup(key: cacheKey)) + } + + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 1.0) + } +} + From 5baf45ed841c7dfe00e4bdc28fdc4eeee2022754 Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Mon, 14 Jul 2025 16:44:40 +0600 Subject: [PATCH 34/37] [FSSDK-11172] feat: update decision service to handle CMAB (#602) --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 18 + Sources/CMAB/CmabService.swift | 31 +- Sources/Data Model/Experiment.swift | 3 + Sources/Implementation/DefaultBucketer.swift | 126 +++-- .../DefaultDecisionService.swift | 236 ++++++-- .../OptimizelyClient+Decide.swift | 172 ++++-- .../OptimizelyUserContext.swift | 114 ++++ Sources/Optimizely/OptimizelyClient.swift | 15 +- Sources/Utils/LogMessage.swift | 10 + .../BucketTests_BucketToEntity.swift | 189 +++++++ .../CmabServiceTests.swift | 195 +++++++ .../DecisionListenerTests.swift | 4 +- .../DecisionServiceTests_Experiments.swift | 174 ++++++ .../DecisionServiceTests_Features.swift | 40 +- .../DecisionServiceTests_Holdouts.swift | 28 +- ...imizelyUserContextTests_Decide_Async.swift | 511 ++++++++++++++++++ ...timizelyUserContextTests_Decide_CMAB.swift | 287 ++++++++++ 17 files changed, 1997 insertions(+), 156 deletions(-) create mode 100644 Tests/OptimizelyTests-Common/BucketTests_BucketToEntity.swift create mode 100644 Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Async.swift create mode 100644 Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index ae0344f1..a442597c 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2014,6 +2014,12 @@ 98137C572A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */; }; 982C071F2D8C82AE0068B1FF /* HoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */; }; 982C07202D8C82AE0068B1FF /* HoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */; }; + 9841590F2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */; }; + 984159102E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */; }; + 984159122E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */; }; + 984159132E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */; }; + 984159372E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */; }; + 984159382E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */; }; 984E2FDC2B27199B001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; 984E2FDD2B27199C001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; 984E2FDE2B27199D001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; }; @@ -2569,6 +2575,9 @@ 98137C542A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_Init_Async_Await.swift; sourceTree = ""; }; 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP_Aync_Await.swift; sourceTree = ""; }; 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutTests.swift; sourceTree = ""; }; + 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Async.swift; sourceTree = ""; }; + 984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_CMAB.swift; sourceTree = ""; }; + 984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketTests_BucketToEntity.swift; sourceTree = ""; }; 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileTracker.swift; sourceTree = ""; }; 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 989428B22DBFA431008BA1C8 /* MockBucketer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBucketer.swift; sourceTree = ""; }; @@ -3093,6 +3102,7 @@ 6E75198F22C5211100B2B157 /* BucketTests_BucketVariation.swift */, 6E75198C22C5211100B2B157 /* BucketTests_ExpToVariation.swift */, 6E75198322C5211100B2B157 /* BucketTests_GroupToExp.swift */, + 984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */, 98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */, 6E75198422C5211100B2B157 /* BucketTests_Others.swift */, 6E75199622C5211100B2B157 /* DatafileHandlerTests.swift */, @@ -3126,6 +3136,8 @@ 6E75198122C5211100B2B157 /* OptimizelyErrorTests.swift */, 6EC6DD6824AE94820017D296 /* OptimizelyUserContextTests.swift */, 6E2D34B8250AD14000A0CDFE /* OptimizelyUserContextTests_Decide.swift */, + 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */, + 984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */, 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */, 6E7E9B362523F8BF009E4426 /* OptimizelyUserContextTests_Decide_Reasons.swift */, 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */, @@ -5005,6 +5017,7 @@ 6E7518C122C520D400B2B157 /* Variable.swift in Sources */, 6E75170F22C520D400B2B157 /* OptimizelyClient.swift in Sources */, 6EC6DD4C24ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */, + 984159132E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */, 8464087D28130D3200CCF97D /* Integration.swift in Sources */, 6E7517E922C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 989428C02DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift in Sources */, @@ -5012,6 +5025,7 @@ 6E9B116A22C5487100C22D81 /* BucketTests_Base.swift in Sources */, 6E9B115F22C5487100C22D81 /* MurmurTests.swift in Sources */, 6E9B116022C5487100C22D81 /* DecisionServiceTests_Experiments.swift in Sources */, + 984159382E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */, 6E9B116322C5487100C22D81 /* BucketTests_GroupToExp.swift in Sources */, 6E7516AF22C520D400B2B157 /* DefaultLogger.swift in Sources */, 6EF8DE2524BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, @@ -5025,6 +5039,7 @@ 980CC9012D833F0D00E07D24 /* Holdout.swift in Sources */, 6EF8DE1724BD1BB2008B9488 /* OptimizelyDecision.swift in Sources */, 8428D3D12807337400D0FB0C /* LruCacheTests.swift in Sources */, + 984159102E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */, 84861816286D0B8900B7F41B /* VuidManagerTests.swift in Sources */, 6E7517C522C520D400B2B157 /* DefaultDatafileHandler.swift in Sources */, 6E75190922C520D500B2B157 /* Attribute.swift in Sources */, @@ -5297,6 +5312,7 @@ 84861800286CF33700B7F41B /* OdpEvent.swift in Sources */, 6EC6DD4524ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */, 6E7517CB22C520D400B2B157 /* DefaultBucketer.swift in Sources */, + 984159122E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */, 845945C1287758A000D13E11 /* OdpConfig.swift in Sources */, 8464087528130D3200CCF97D /* Integration.swift in Sources */, 989428C12DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift in Sources */, @@ -5304,6 +5320,7 @@ 6E9B115022C5486E00C22D81 /* BucketTests_Base.swift in Sources */, 6E9B114522C5486E00C22D81 /* MurmurTests.swift in Sources */, 6E9B114622C5486E00C22D81 /* DecisionServiceTests_Experiments.swift in Sources */, + 984159372E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */, 6E9B114922C5486E00C22D81 /* BucketTests_GroupToExp.swift in Sources */, 6E75182B22C520D400B2B157 /* BatchEvent.swift in Sources */, 6EF8DE1E24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, @@ -5317,6 +5334,7 @@ 6E75175D22C520D400B2B157 /* AtomicProperty.swift in Sources */, 6E7516D922C520D400B2B157 /* OPTUserProfileService.swift in Sources */, 6E7516E522C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, + 9841590F2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */, 98AC98462DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */, 6E7E9B552523F8C6009E4426 /* OptimizelyUserContextTests_Decide_Legacy.swift in Sources */, 6E652305278E688B00954EA1 /* LruCache.swift in Sources */, diff --git a/Sources/CMAB/CmabService.swift b/Sources/CMAB/CmabService.swift index e0eb9580..c74205e8 100644 --- a/Sources/CMAB/CmabService.swift +++ b/Sources/CMAB/CmabService.swift @@ -30,6 +30,10 @@ struct CmabCacheValue { typealias CmabDecisionCompletionHandler = (Result) -> Void protocol CmabService { + func getDecision(config: ProjectConfig, + userContext: OptimizelyUserContext, + ruleId: String, + options: [OptimizelyDecideOption]) -> Result func getDecision(config: ProjectConfig, userContext: OptimizelyUserContext, ruleId: String, @@ -37,7 +41,7 @@ protocol CmabService { completion: @escaping CmabDecisionCompletionHandler) } -class DefaultCmabService { +class DefaultCmabService: CmabService { typealias UserAttributes = [String : Any?] private let cmabClient: CmabClient @@ -49,6 +53,22 @@ class DefaultCmabService { self.cmabCache = cmabCache } + func getDecision(config: ProjectConfig, + userContext: OptimizelyUserContext, + ruleId: String, + options: [OptimizelyDecideOption]) -> Result { + var result: Result! + let semaphore = DispatchSemaphore(value: 0) + getDecision(config: config, + userContext: userContext, + ruleId: ruleId, options: options) { _result in + result = _result + semaphore.signal() + } + semaphore.wait() + return result + } + func getDecision(config: ProjectConfig, userContext: OptimizelyUserContext, ruleId: String, @@ -155,3 +175,12 @@ class DefaultCmabService { return filteredUserAttributes } } + +extension DefaultCmabService { + static func createDefault() -> DefaultCmabService { + let DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000 // 30 minutes in milliseconds + let DEFAULT_CMAB_CACHE_SIZE = 1000 + let cache = LruCache(size: DEFAULT_CMAB_CACHE_SIZE, timeoutInSecs: DEFAULT_CMAB_CACHE_TIMEOUT) + return DefaultCmabService(cmabClient: DefaultCmabClient(), cmabCache: cache) + } +} diff --git a/Sources/Data Model/Experiment.swift b/Sources/Data Model/Experiment.swift index 87cecd9e..bfe8418a 100644 --- a/Sources/Data Model/Experiment.swift +++ b/Sources/Data Model/Experiment.swift @@ -71,4 +71,7 @@ extension Experiment { return status == .running } + var isCmab: Bool { + return cmab != nil + } } diff --git a/Sources/Implementation/DefaultBucketer.swift b/Sources/Implementation/DefaultBucketer.swift index 7f616eeb..cdf5a6c1 100644 --- a/Sources/Implementation/DefaultBucketer.swift +++ b/Sources/Implementation/DefaultBucketer.swift @@ -37,43 +37,16 @@ class DefaultBucketer: OPTBucketer { bucketingId: String) -> DecisionResponse { let reasons = DecisionReasons() - var mutexAllowed = true - - // check for mutex - - let group = config.project.groups.filter { $0.getExperiment(id: experiment.id) != nil }.first - - if let group = group { - switch group.policy { - case .overlapping: - break - case .random: - let decisionResponse = bucketToExperiment(config: config, - group: group, - bucketingId: bucketingId) - reasons.merge(decisionResponse.reasons) - if let mutexExperiment = decisionResponse.result { - if mutexExperiment.id == experiment.id { - mutexAllowed = true - - let info = LogMessage.userBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) - logger.i(info) - reasons.addInfo(info) - } else { - mutexAllowed = false - - let info = LogMessage.userNotBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) - logger.i(info) - reasons.addInfo(info) - } - } else { - mutexAllowed = false - - let info = LogMessage.userNotBucketedIntoAnyExperimentInGroup(bucketingId, group.id) - logger.i(info) - reasons.addInfo(info) - } - } + // Check mutex rules + let mutexAllowed = checkMutexRules( + config: config, + experiment: experiment, + bucketingId: bucketingId, + reasons: reasons + ) + + if !mutexAllowed { + return DecisionResponse(result: nil, reasons: reasons) } if !mutexAllowed { return DecisionResponse(result: nil, reasons: reasons) } @@ -120,6 +93,83 @@ class DefaultBucketer: OPTBucketer { return DecisionResponse(result: nil, reasons: reasons) } + /// Checks if an experiment is allowed to run based on mutex rules + /// - Parameters: + /// - config: The project configuration + /// - experiment: The experiment to check + /// - bucketingId: The bucketing ID for the user + /// - reasons: Decision reasons to track the mutex check process + /// - Returns: A boolean indicating if the experiment is allowed to run + private func checkMutexRules( + config: ProjectConfig, + experiment: Experiment, + bucketingId: String, + reasons: DecisionReasons + ) -> Bool { + // Find the group containing this experiment + let group = config.project.groups.filter { $0.getExperiment(id: experiment.id) != nil }.first + + guard let group = group else { + return true // No group found, experiment is allowed + } + + switch group.policy { + case .overlapping: + return true // Overlapping experiments are always allowed + + case .random: + let decisionResponse = bucketToExperiment( + config: config, + group: group, + bucketingId: bucketingId + ) + reasons.merge(decisionResponse.reasons) + + guard let mutexExperiment = decisionResponse.result else { + let info = LogMessage.userNotBucketedIntoAnyExperimentInGroup(bucketingId, group.id) + logger.i(info) + reasons.addInfo(info) + return false + } + + let isAllowed = mutexExperiment.id == experiment.id + let info = isAllowed + ? LogMessage.userBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) + : LogMessage.userNotBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id) + + logger.i(info) + reasons.addInfo(info) + return isAllowed + } + } + + func bucketToEntityId(config: ProjectConfig, + experiment: Experiment, + bucketingId: String, + trafficAllocation: [TrafficAllocation]) -> DecisionResponse { + + let reasons = DecisionReasons() + + // Check mutex rules + let mutexAllowed = checkMutexRules( + config: config, + experiment: experiment, + bucketingId: bucketingId, + reasons: reasons + ) + + if !mutexAllowed { + return DecisionResponse(result: nil, reasons: reasons) + } + + let hashId = makeHashIdFromBucketingId(bucketingId: bucketingId, entityId: experiment.id) + let bucketValue = self.generateBucketValue(bucketingId: hashId) + + let entityId = allocateTraffic(trafficAllocation: trafficAllocation, bucketValue: bucketValue) + + return DecisionResponse(result: entityId, reasons: reasons) + } + func bucketToVariation(experiment: ExperimentCore, bucketingId: String) -> DecisionResponse { let reasons = DecisionReasons() @@ -153,7 +203,7 @@ class DefaultBucketer: OPTBucketer { for bucket in trafficAllocation where bucketValue < bucket.endOfRange { return bucket.entityId } - + return nil } diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 003cc04f..1fd9343d 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -18,8 +18,15 @@ import Foundation struct FeatureDecision { var experiment: ExperimentCore? - let variation: Variation + let variation: Variation? let source: String + var cmabUUID: String? +} + +struct VariationDecision { + var variation: Variation? + var cmabError: Bool = false + var cmabUUID: String? } typealias UserProfile = OPTUserProfileService.UPProfile @@ -27,24 +34,110 @@ typealias UserProfile = OPTUserProfileService.UPProfile class DefaultDecisionService: OPTDecisionService { let bucketer: OPTBucketer let userProfileService: OPTUserProfileService + let cmabService: CmabService + let group: DispatchGroup = DispatchGroup() // thread-safe lazy logger load (after HandlerRegisterService ready) private let threadSafeLogger = ThreadSafeLogger() - // user-profile-service read-modify-write lock for supporting multiple clients static let upsRMWLock = DispatchQueue(label: "ups-rmw") var logger: OPTLogger { return threadSafeLogger.logger } - - init(userProfileService: OPTUserProfileService) { + + init(userProfileService: OPTUserProfileService, + cmabService: CmabService = DefaultCmabService.createDefault()) { self.bucketer = DefaultBucketer() self.userProfileService = userProfileService + self.cmabService = cmabService } - init(userProfileService: OPTUserProfileService, bucketer: OPTBucketer) { + init(userProfileService: OPTUserProfileService, + bucketer: OPTBucketer, + cmabService: CmabService = DefaultCmabService.createDefault()) { self.bucketer = bucketer self.userProfileService = userProfileService + self.cmabService = cmabService + } + + // MARK: - CMAB decision + + /// Get decision for CMAB Experiment + /// - Parameters: + /// - config: The project configuration containing experiment and feature details. + /// - experiment: The CMAB experiment to evaluate. + /// - user: The user context containing user ID and attributes. + /// - bucketingId: User bucketing id + /// - isAsync: Controls synchronous or asynchronous decision. + /// - options: Optional decision options (e.g., ignore user profile service). + /// - Returns: A `CMABDecisionResult` containing the CMAB decisions( variation id, cmabUUID) with reasons + + private func getDecisionForCmabExperiment(config: ProjectConfig, + experiment: Experiment, + user: OptimizelyUserContext, + bucketingId: String, + isAsync: Bool, + options: [OptimizelyDecideOption]?) -> DecisionResponse { + let reasons = DecisionReasons(options: options) + guard let cmab = experiment.cmab else { + logger.e("The experiment isn't a CMAB experiment") + return DecisionResponse(result: nil, reasons: reasons) + } + + // We do not choose the alternative solution (checking and rejecting if on the main thread) + // because it would lead to inconsistent decision results: + // - If the decision is called from the main thread, CMAB logic is skipped and an error is logged. + // - If called from a background thread, CMAB logic is included. + // This means the same API could return different results based on thread context, which is confusing for users. + // Instead, we pass an `isAsync` boolean to ensure that CMAB will be evaluated only for async calls. + + guard isAsync else { + let info = LogMessage.cmabNotSupportedInSyncMode + logger.e(info) + reasons.addInfo(info) + return DecisionResponse(result: nil, reasons: reasons) + } + + let dummyEntityId = "$" + let cmabTrafficAllocation = TrafficAllocation(entityId: dummyEntityId, endOfRange: cmab.trafficAllocation) + let bucketedResponse = (bucketer as? DefaultBucketer)?.bucketToEntityId(config: config, experiment: experiment, bucketingId: bucketingId, trafficAllocation: [cmabTrafficAllocation]) + + if let _reasons = bucketedResponse?.reasons { + reasons.merge(_reasons) + } + + let entityId = bucketedResponse?.result + + // This means the user is not in the cmab experiment + if entityId == nil { + let info = LogMessage.userNotInCmabExperiment(user.userId, experiment.key) + logger.d(info) + reasons.addInfo(info) + return DecisionResponse(result: nil, reasons: reasons) + } + + // Fetch CMAB decision + let response = cmabService.getDecision(config: config, userContext: user, ruleId: experiment.id, options: options ?? []) + var cmabDecision: CmabDecision? + switch response { + case .success(let decision): + cmabDecision = decision + case .failure: + let info = LogMessage.cmabFetchFailed(experiment.key) + self.logger.e(info) + reasons.addInfo(info) + let nilVariation = VariationDecision(variation: nil, cmabError: true, cmabUUID: nil) + return DecisionResponse(result: nilVariation, reasons: reasons) + } + + if let cmabDecision = cmabDecision, + let experiment = config.getExperiment(id: experiment.id), + let bucketedVariation = experiment.getVariation(id: cmabDecision.variationId) { + let variationDecision = VariationDecision(variation: bucketedVariation, cmabUUID: cmabDecision.cmabUUID) + return DecisionResponse(result: variationDecision, reasons: reasons) + } + + return DecisionResponse(result: nil, reasons: reasons) } // MARK: - Experiment Decision @@ -68,13 +161,14 @@ class DefaultDecisionService: OPTDecisionService { profileTracker?.loadUserProfile() } - let response = getVariation(config: config, experiment: experiment, user: user, userProfileTracker: profileTracker) + // isAsync to `false` for backward compatibility + let response = getVariation(config: config, experiment: experiment, user: user, isAsync: false, userProfileTracker: profileTracker) if (!ignoreUPS) { profileTracker?.save() } - return response + return DecisionResponse(result: response.result?.variation, reasons: response.reasons) } /// Determines the variation for a user in an experiment, considering user profile and decision rules. @@ -83,21 +177,20 @@ class DefaultDecisionService: OPTDecisionService { /// - experiment: The experiment to evaluate. /// - user: The user context. /// - options: Optional decision options. + /// - isAsync: Controls synchronous or asynchronous decision. /// - userProfileTracker: Optional tracker for user profile data. /// - Returns: A `DecisionResponse` with the variation (if any) and decision reasons. func getVariation(config: ProjectConfig, experiment: Experiment, user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil, - userProfileTracker: UserProfileTracker?) -> DecisionResponse { + isAsync: Bool, + userProfileTracker: UserProfileTracker?) -> DecisionResponse { let reasons = DecisionReasons(options: options) let userId = user.userId let attributes = user.attributes let experimentId = experiment.id - // Acquire bucketingId . - let bucketingId = getBucketingId(userId: userId, attributes: attributes) - // ---- check if the experiment is running ---- if !experiment.isActivated { let info = LogMessage.experimentNotRunning(experiment.key) @@ -113,7 +206,8 @@ class DefaultDecisionService: OPTDecisionService { if let variationId = decisionResponse.result?.id, let variation = experiment.getVariation(id: variationId) { - return DecisionResponse(result: variation, reasons: reasons) + let variationDecision = VariationDecision(variation: variation) + return DecisionResponse(result: variationDecision, reasons: reasons) } // ---- check to see if user is white-listed for a certain variation ---- @@ -122,7 +216,8 @@ class DefaultDecisionService: OPTDecisionService { let info = LogMessage.forcedVariationFound(variationKey, userId) logger.i(info) reasons.addInfo(info) - return DecisionResponse(result: variation, reasons: reasons) + let variationDecision = VariationDecision(variation: variation) + return DecisionResponse(result: variationDecision, reasons: reasons) } // mapped to invalid variation - ignore and continue for other deciesions @@ -131,7 +226,7 @@ class DefaultDecisionService: OPTDecisionService { reasons.addInfo(info) } - /// Load variation from tracker + // Load variation from tracker if let profile = userProfileTracker?.userProfile, let variationId = getVariationIdFromProfile(profile: profile, experimentId: experimentId), let variation = experiment.getVariation(id: variationId) { @@ -139,10 +234,12 @@ class DefaultDecisionService: OPTDecisionService { let info = LogMessage.gotVariationFromUserProfile(variation.key, experiment.key, userId) logger.i(info) reasons.addInfo(info) - return DecisionResponse(result: variation, reasons: reasons) + let variationDecision = VariationDecision(variation: variation) + return DecisionResponse(result: variationDecision, reasons: reasons) } - var bucketedVariation: Variation? + + var variationDecision: VariationDecision? // ---- check if the user passes audience targeting before bucketing ---- let audienceResponse = doesMeetAudienceConditions(config: config, experiment: experiment, @@ -150,15 +247,30 @@ class DefaultDecisionService: OPTDecisionService { reasons.merge(audienceResponse.reasons) if audienceResponse.result ?? false { - // bucket user into a variation - let decisionResponse = bucketer.bucketExperiment(config: config, - experiment: experiment, - bucketingId: bucketingId) - reasons.merge(decisionResponse.reasons) + // Acquire bucketingId + let bucketingId = getBucketingId(userId: userId, attributes: attributes) - bucketedVariation = decisionResponse.result + if experiment.isCmab { + let cmabDecisionResponse = getDecisionForCmabExperiment(config: config, + experiment: experiment, + user: user, + bucketingId: bucketingId, + isAsync: isAsync, + options: options) + reasons.merge(cmabDecisionResponse.reasons) + variationDecision = cmabDecisionResponse.result + } else { + // bucket user into a variation + let decisionResponse = bucketer.bucketExperiment(config: config, + experiment: experiment, + bucketingId: bucketingId) + reasons.merge(decisionResponse.reasons) + if let variation = decisionResponse.result { + variationDecision = VariationDecision(variation: variation) + } + } - if let variation = bucketedVariation { + if let variation = variationDecision?.variation { let info = LogMessage.userBucketedIntoVariationInExperiment(userId, experiment.key, variation.key) logger.i(info) reasons.addInfo(info) @@ -175,7 +287,7 @@ class DefaultDecisionService: OPTDecisionService { reasons.addInfo(info) } - return DecisionResponse(result: bucketedVariation, reasons: reasons) + return DecisionResponse(result: variationDecision, reasons: reasons) } // MARK: - Feature Flag Decision @@ -191,8 +303,25 @@ class DefaultDecisionService: OPTDecisionService { featureFlag: FeatureFlag, user: OptimizelyUserContext, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + // isAsync to `false` for backward compatibility + self.getVariationForFeature(config: config, featureFlag: featureFlag, user: user, isAsync: false, options: options) + } + + /// Determines the feature decision for a user for a specific feature flag. + /// - Parameters: + /// - config: The project configuration. + /// - featureFlag: The feature flag to evaluate. + /// - user: The user context. + /// - isAsync: Controls synchronous or asynchronous decision. + /// - options: Optional decision options. + /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons. + func getVariationForFeature(config: ProjectConfig, + featureFlag: FeatureFlag, + user: OptimizelyUserContext, + isAsync: Bool, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { - let response = getVariationForFeatureList(config: config, featureFlags: [featureFlag], user: user, options: options).first + let response = getVariationForFeatureList(config: config, featureFlags: [featureFlag], user: user, isAsync: isAsync, options: options).first guard response?.result != nil else { let reasons = response?.reasons ?? DecisionReasons(options: options) @@ -207,11 +336,13 @@ class DefaultDecisionService: OPTDecisionService { /// - config: The project configuration. /// - featureFlags: The list of feature flags to evaluate. /// - user: The user context. + /// - isAsync: Controls synchronous or asynchronous decision /// - options: Optional decision options. /// - Returns: An array of `DecisionResponse` objects, each containing a feature decision and reasons. func getVariationForFeatureList(config: ProjectConfig, featureFlags: [FeatureFlag], user: OptimizelyUserContext, + isAsync: Bool, options: [OptimizelyDecideOption]? = nil) -> [DecisionResponse] { let userId = user.userId @@ -225,7 +356,7 @@ class DefaultDecisionService: OPTDecisionService { var decisions = [DecisionResponse]() for featureFlag in featureFlags { - let flagDecisionResponse = getDecisionForFlag(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker) + let flagDecisionResponse = getDecisionForFlag(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker, isAsync: isAsync, options: options) decisions.append(flagDecisionResponse) } @@ -243,12 +374,14 @@ class DefaultDecisionService: OPTDecisionService { /// - featureFlag: The feature flag to evaluate. /// - user: The user context. /// - userProfileTracker: Optional tracker for user profile data. + /// - isAsync: Controls synchronous or asynchronous decision /// - options: Optional decision options. /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons. func getDecisionForFlag(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, + isAsync: Bool, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) @@ -257,22 +390,23 @@ class DefaultDecisionService: OPTDecisionService { let holdoutDecision = getVariationForHoldout(config: config, flagKey: featureFlag.key, holdout: holdout, - user: user) + user: user, + options: options) reasons.merge(holdoutDecision.reasons) if let variation = holdoutDecision.result { - let featureDicision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue) - return DecisionResponse(result: featureDicision, reasons: reasons) + let featureDecision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue) + return DecisionResponse(result: featureDecision, reasons: reasons) } } - let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker) + let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker, isAsync: isAsync, options: options) reasons.merge(flagExpDecision.reasons) if let decision = flagExpDecision.result { return DecisionResponse(result: decision, reasons: reasons) } - let rolloutDecision = getVariationForFeatureRollout(config: config, featureFlag: featureFlag, user: user) + let rolloutDecision = getVariationForFeatureRollout(config: config, featureFlag: featureFlag, user: user, options: options) reasons.merge(rolloutDecision.reasons) if let decision = rolloutDecision.result { @@ -288,13 +422,15 @@ class DefaultDecisionService: OPTDecisionService { /// - featureFlag: The feature flag to evaluate. /// - user: The user context. /// - userProfileTracker: Optional tracker for user profile data. + /// - isAsync: Controls synchronous or asynchronous decision /// - options: Optional decision options. /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons. func getVariationForFeatureExperiments(config: ProjectConfig, - featureFlag: FeatureFlag, - user: OptimizelyUserContext, - userProfileTracker: UserProfileTracker? = nil, - options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + featureFlag: FeatureFlag, + user: OptimizelyUserContext, + userProfileTracker: UserProfileTracker? = nil, + isAsync: Bool, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) let experimentIds = featureFlag.experimentIds @@ -313,11 +449,19 @@ class DefaultDecisionService: OPTDecisionService { rule: experiment, user: user, userProfileTracker: userProfileTracker, + isAsync: isAsync, options: options) reasons.merge(decisionResponse.reasons) - if let variation = decisionResponse.result { - let featureDecision = FeatureDecision(experiment: experiment, variation: variation, source: Constants.DecisionSource.featureTest.rawValue) - return DecisionResponse(result: featureDecision, reasons: reasons) + if let result = decisionResponse.result { + if result.cmabError { + // For CMAB - we're supposed to get decision from the server. + // If failed, return decision with nil variation, so the client can take care of them. + let featureDecision = FeatureDecision(experiment: experiment, variation: nil, source: Constants.DecisionSource.featureTest.rawValue) + return DecisionResponse(result: featureDecision, reasons: reasons) + } else if let variation = result.variation { + let featureDecision = FeatureDecision(experiment: experiment, variation: variation, source: Constants.DecisionSource.featureTest.rawValue) + return DecisionResponse(result: featureDecision, reasons: reasons) + } } } } @@ -420,7 +564,7 @@ class DefaultDecisionService: OPTDecisionService { let userId = user.userId let attributes = user.attributes - + // Acquire bucketingId . let bucketingId = getBucketingId(userId: userId, attributes: attributes) var bucketedVariation: Variation? @@ -471,7 +615,8 @@ class DefaultDecisionService: OPTDecisionService { rule: Experiment, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker?, - options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + isAsync: Bool, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) // check forced-decision first let forcedDecisionResponse = findValidatedForcedDecision(config: config, @@ -480,16 +625,19 @@ class DefaultDecisionService: OPTDecisionService { reasons.merge(forcedDecisionResponse.reasons) if let variation = forcedDecisionResponse.result { - return DecisionResponse(result: variation, reasons: reasons) + let variationDecision = VariationDecision(variation: variation) + return DecisionResponse(result: variationDecision, reasons: reasons) } let decisionResponse = getVariation(config: config, experiment: rule, user: user, + options: options, + isAsync: isAsync, userProfileTracker: userProfileTracker) - let variation = decisionResponse.result + let variationResult = decisionResponse.result reasons.merge(decisionResponse.reasons) - return DecisionResponse(result: variation, reasons: reasons) + return DecisionResponse(result: variationResult, reasons: reasons) } /// Determines the variation for a delivery rule in a rollout. @@ -509,7 +657,7 @@ class DefaultDecisionService: OPTDecisionService { options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<(Variation?, Bool)> { let reasons = DecisionReasons(options: options) var skipToEveryoneElse = false - + // check forced-decision first let rule = rules[ruleIndex] diff --git a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift index eda0d38a..06b1b45a 100644 --- a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift +++ b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift @@ -48,7 +48,7 @@ extension OptimizelyClient { return createUserContext(userId: userId, attributes: (attributes ?? [:]) as [String: Any]) } - + /// Create a user context to be used internally without sending an ODP identify event. /// /// - Parameters: @@ -62,6 +62,13 @@ extension OptimizelyClient { identify: false) } + /// Returns a decision result for a given flag key + /// + /// - Parameters: + /// - user: The user context for which the decision is being made + /// - key: The feature flag key to evaluate + /// - options: An array of options for decision-making. + /// - Returns: An OptimizelyDecision representing the flag decision func decide(user: OptimizelyUserContext, key: String, options: [OptimizelyDecideOption]? = nil) -> OptimizelyDecision { @@ -73,24 +80,93 @@ extension OptimizelyClient { guard let _ = config.getFeatureFlag(key: key) else { return OptimizelyDecision.errorDecision(key: key, user: user, error: .featureKeyInvalid(key)) } - + var allOptions = defaultDecideOptions + (options ?? []) + // Filtering out `enabledFlagsOnly` to ensure users always get a result. allOptions.removeAll(where: { $0 == .enabledFlagsOnly }) - let decisionMap = decide(user: user, keys: [key], options: allOptions, ignoreDefaultOptions: true) + let decisionMap = decide(user: user, keys: [key], options: allOptions, isAsync: false, ignoreDefaultOptions: true) return decisionMap[key] ?? OptimizelyDecision.errorDecision(key: key, user: user, error: .generic) } + /// Returns a decision result for a given key asynchronously + /// + /// - Parameters: + /// - user: The user context for which the decision is being made + /// - key: The feature flag key to evaluate + /// - options: An array of options for decision-making. + /// - completion: Handler will be called with a OptimizelyDecision + func decideAsync(user: OptimizelyUserContext, + key: String, + options: [OptimizelyDecideOption]? = nil, + completion: @escaping DecideCompletion) { + decisionQueue.async { + guard let config = self.config else { + let decision = OptimizelyDecision.errorDecision(key: key, user: user, error: .sdkNotReady) + completion(decision) + return + } + + guard let _ = config.getFeatureFlag(key: key) else { + let decision = OptimizelyDecision.errorDecision(key: key, user: user, error: .featureKeyInvalid(key)) + completion(decision) + return + } + + var allOptions = self.defaultDecideOptions + (options ?? []) + // Filtering out `enabledFlagsOnly` to ensure users always get a result. + allOptions.removeAll(where: { $0 == .enabledFlagsOnly }) + + let decisionMap = self.decide(user: user, keys: [key], options: allOptions, isAsync: true, ignoreDefaultOptions: true) + let decision = decisionMap[key] ?? OptimizelyDecision.errorDecision(key: key, user: user, error: .generic) + completion(decision) + } + } + + /// Returns a key-map of decision results for multiple flag keys + /// + /// - Parameters: + /// - user: The user context for which the decisions are being made + /// - keys: The feature flag keys to evaluate + /// - options: An array of options for decision-making. + /// - Returns: A dictionary of all decision results, mapped by flag keys. func decide(user: OptimizelyUserContext, keys: [String], options: [OptimizelyDecideOption]? = nil) -> [String: OptimizelyDecision] { - return decide(user: user, keys: keys, options: options, ignoreDefaultOptions: false) + return decide(user: user, keys: keys, options: options, isAsync: false) } - func decide(user: OptimizelyUserContext, - keys: [String], - options: [OptimizelyDecideOption]? = nil, - ignoreDefaultOptions: Bool) -> [String: OptimizelyDecision] { + /// Returns a decision result for a given key asynchronously + /// + /// - Parameters: + /// - user: The user context for which the decision is being made + /// - keys: The feature flag keys to evaluate + /// - options: An array of options for decision-making + /// - completion: Handler will be called with a dictionary mapping feature flag keys to OptimizelyDecision + func decideAsync(user: OptimizelyUserContext, + keys: [String], + options: [OptimizelyDecideOption]? = nil, + completion: @escaping DecideForKeysCompletion) { + decisionQueue.async { + let decisions = self.decide(user: user, keys: keys, options: options, isAsync: true) + completion(decisions) + } + } + + /// Returns a key-map of decision results for multiple flag keys + /// + /// - Parameters: + /// - user: The user context for which to make the decision + /// - keys: Array of feature flag keys to decide upon + /// - options: Optional array of decision options that override default behavior + /// - isAsync: Boolean indicating whether the operation is asynchronous + /// - ignoreDefaultOptions: Boolean indicating whether to ignore default decide options + /// - Returns: A dictionary of all decision results, mapped by flag keys. + private func decide(user: OptimizelyUserContext, + keys: [String], + options: [OptimizelyDecideOption]? = nil, + isAsync: Bool, + ignoreDefaultOptions: Bool = false) -> [String: OptimizelyDecision] { guard let config = self.config else { logger.e(OptimizelyError.sdkNotReady) return [:] @@ -132,7 +208,7 @@ extension OptimizelyClient { } } - let decisionList = (decisionService as? DefaultDecisionService)?.getVariationForFeatureList(config: config, featureFlags: flagsWithoutForceDecision, user: user, options: allOptions) + let decisionList = (decisionService as? DefaultDecisionService)?.getVariationForFeatureList(config: config, featureFlags: flagsWithoutForceDecision, user: user, isAsync: isAsync, options: allOptions) for index in 0.. [String: OptimizelyDecision] { + guard let config = self.config else { + logger.e(OptimizelyError.sdkNotReady) + return [:] + } + + return decide(user: user, keys: config.featureFlagKeys, options: options) + } + + /// Asynchronously evaluates all feature flags and returns the decisions. + /// + /// This method will return decisions for all feature flags in the project. + /// + /// - Parameters: + /// - user: The user context for which to evaluate the feature flags + /// - options: An array of options for decision-making. Default is nil. + /// - completion: Handler will be called with a dictionary mapping feature flag keys to OptimizelyDecision + func decideAllAsync(user: OptimizelyUserContext, + options: [OptimizelyDecideOption]? = nil, + completion: @escaping DecideForKeysCompletion) { + + decisionQueue.async { + guard let config = self.config else { + self.logger.e(OptimizelyError.sdkNotReady) + completion([:]) + return + } + + let decision = self.decide(user: user, keys: config.featureFlagKeys, options: options, isAsync: true, ignoreDefaultOptions: false) + completion(decision) + } + } + private func createOptimizelyDecision(flagKey: String, user: OptimizelyUserContext, flagDecision: FeatureDecision?, @@ -179,7 +299,7 @@ extension OptimizelyClient { let userId = user.userId let attributes = user.attributes - let flagEnabled = flagDecision?.variation.featureEnabled ?? false + let flagEnabled = flagDecision?.variation?.featureEnabled ?? false logger.i("Feature \(flagKey) is enabled for user \(userId) \(flagEnabled)") @@ -231,7 +351,7 @@ extension OptimizelyClient { reasons: reasonsToReport, decisionEventDispatched: decisionEventDispatched)) - return OptimizelyDecision(variationKey: flagDecision?.variation.key, + return OptimizelyDecision(variationKey: flagDecision?.variation?.key, enabled: flagEnabled, variables: optimizelyJSON, ruleKey: ruleKey, @@ -240,16 +360,6 @@ extension OptimizelyClient { reasons: reasonsToReport) } - func decideAll(user: OptimizelyUserContext, - options: [OptimizelyDecideOption]? = nil) -> [String: OptimizelyDecision] { - guard let config = self.config else { - logger.e(OptimizelyError.sdkNotReady) - return [:] - } - - return decide(user: user, keys: config.featureFlagKeys, options: options) - } - } // MARK: - Utils @@ -286,16 +396,16 @@ extension OptimizelyClient { if let valueType = Constants.VariableValueType(rawValue: type) { switch valueType { - case .string: - break - case .integer: - valueParsed = Int(value) - case .double: - valueParsed = Double(value) - case .boolean: - valueParsed = Bool(value) - case .json: - valueParsed = OptimizelyJSON(payload: value)?.toMap() + case .string: + break + case .integer: + valueParsed = Int(value) + case .double: + valueParsed = Double(value) + case .boolean: + valueParsed = Bool(value) + case .json: + valueParsed = OptimizelyJSON(payload: value)?.toMap() } } diff --git a/Sources/Optimizely+Decide/OptimizelyUserContext.swift b/Sources/Optimizely+Decide/OptimizelyUserContext.swift index e21c16d6..70959baf 100644 --- a/Sources/Optimizely+Decide/OptimizelyUserContext.swift +++ b/Sources/Optimizely+Decide/OptimizelyUserContext.swift @@ -16,6 +16,9 @@ import Foundation +public typealias DecideCompletion = (OptimizelyDecision) -> Void +public typealias DecideForKeysCompletion = ([String: OptimizelyDecision]) -> Void + /// An object for user contexts that the SDK will use to make decisions for. public class OptimizelyUserContext { weak var optimizely: OptimizelyClient? @@ -121,6 +124,45 @@ public class OptimizelyUserContext { return optimizely.decide(user: clone, key: key, options: options) } + /// Asynchronously makes a feature decision for a given feature key. + /// + /// - Parameters: + /// - key: The feature key to make a decision for + /// - options: Optional array of decision options that will be used for this decision only + /// - completion: A callback that receives the resulting OptimizelyDecision + /// + /// - Note: + /// - If the SDK is not ready, this method will immediately return an error decision through the completion handler. + /// - The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler. + public func decideAsync(key: String, + options: [OptimizelyDecideOption]? = nil, + completion: @escaping DecideCompletion) { + + guard let optimizely = self.optimizely, let clone = self.clone else { + let decision = OptimizelyDecision.errorDecision(key: key, user: self, error: .sdkNotReady) + completion(decision) + return + } + optimizely.decideAsync(user: clone, key: key, options: options, completion: completion) + } + + /// Returns a decision result asynchronously for a given flag key + /// - Parameters: + /// - key: A flag key for which a decision will be made + /// - options: An array of options for decision-making + /// - Returns: A decision result + /// + /// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public func decideAsync(key: String, + options: [OptimizelyDecideOption]? = nil) async -> OptimizelyDecision { + return await withCheckedContinuation { continuation in + decideAsync(key: key, options: options) { decision in + continuation.resume(returning: decision) + } + } + } + /// Returns a key-map of decision results for multiple flag keys and a user context. /// /// - If the SDK finds an error (__flagKeyInvalid__, etc) for a key, the response will include a decision for the key showing `reasons` for the error (regardless of __includeReasons__ in options). @@ -141,6 +183,46 @@ public class OptimizelyUserContext { return optimizely.decide(user: clone, keys: keys, options: options) } + /// Asynchronously decides variations for multiple feature flags. + /// + /// - Parameters: + /// - keys: An array of feature flag keys. + /// - options: An array of options for decision-making. + /// - completion: A callback that receives a dictionary mapping each feature flag key to its corresponding decision result. + /// + /// - Note: + /// - If the SDK is not ready, this method will immediately return an error decision through the completion handler. + /// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler. + public func decideAsync(keys: [String], + options: [OptimizelyDecideOption]? = nil, + completion: @escaping DecideForKeysCompletion) { + + guard let optimizely = self.optimizely, let clone = self.clone else { + logger.e(OptimizelyError.sdkNotReady) + completion([:]) + return + } + + optimizely.decideAsync(user: clone, keys: keys, options: options, completion: completion) + } + + /// Returns decisions for multiple flag keys asynchronously + /// - Parameters: + /// - keys: An array of flag keys for which decisions will be made + /// - options: An array of options for decision-making + /// - Returns: A dictionary of all decision results, mapped by flag keys + /// + /// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public func decideAsync(keys: [String], + options: [OptimizelyDecideOption]? = nil) async -> [String: OptimizelyDecision] { + return await withCheckedContinuation { continuation in + decideAsync(keys: keys, options: options) { decisions in + continuation.resume(returning: decisions) + } + } + } + /// Returns a key-map of decision results for all active flag keys. /// /// - Parameters: @@ -155,6 +237,38 @@ public class OptimizelyUserContext { return optimizely.decideAll(user: clone, options: options) } + /// Asynchronously makes a decision for all features and experiments for this user. + /// + /// - Parameters: + /// - options: An array of decision options. If not provided, the default options will be used. + /// - completion: A closure that will be called with the decision results for all keys. + /// The closure takes a dictionary of feature/experiment keys to their corresponding decision results. + /// + /// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler. + public func decideAllAsync(options: [OptimizelyDecideOption]? = nil, completion: @escaping DecideForKeysCompletion) { + guard let optimizely = self.optimizely, let clone = self.clone else { + logger.e(OptimizelyError.sdkNotReady) + completion([:]) + return + } + + optimizely.decideAllAsync(user: clone, options: options, completion: completion) + } + + /// Returns decisions for all active flag keys asynchronously + /// - Parameter options: An array of options for decision-making + /// - Returns: A dictionary of all decision results, mapped by flag keys + /// + /// - Note: The completion handler will be called on a background queue. If you need to update the UI, dispatch to the main queue within the completion handler. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public func decideAllAsync(options: [OptimizelyDecideOption]? = nil) async -> [String: OptimizelyDecision] { + return await withCheckedContinuation { continuation in + decideAllAsync(options: options) { decisions in + continuation.resume(returning: decisions) + } + } + } + /// Tracks an event. /// /// - Parameters: diff --git a/Sources/Optimizely/OptimizelyClient.swift b/Sources/Optimizely/OptimizelyClient.swift index 7c7179a4..092691f3 100644 --- a/Sources/Optimizely/OptimizelyClient.swift +++ b/Sources/Optimizely/OptimizelyClient.swift @@ -48,6 +48,7 @@ open class OptimizelyClient: NSObject { } let eventLock = DispatchQueue(label: "com.optimizely.client") + let decisionQueue = DispatchQueue(label: "com.optimizely.decisionQueue") // MARK: - Customizable Services @@ -107,11 +108,13 @@ open class OptimizelyClient: NSObject { let logger = logger ?? DefaultLogger() type(of: logger).logLevel = defaultLogLevel ?? .info + let cmabService = DefaultCmabService.createDefault() + self.registerServices(sdkKey: sdkKey, logger: logger, eventDispatcher: eventDispatcher ?? DefaultEventDispatcher.sharedInstance, datafileHandler: datafileHandler ?? DefaultDatafileHandler(), - decisionService: DefaultDecisionService(userProfileService: userProfileService), + decisionService: DefaultDecisionService(userProfileService: userProfileService, cmabService: cmabService), notificationCenter: DefaultNotificationCenter()) self.logger = HandlerRegistryService.shared.injectLogger() @@ -435,7 +438,7 @@ open class OptimizelyClient: NSObject { options: nil).result let source = pair?.source ?? Constants.DecisionSource.rollout.rawValue - let featureEnabled = pair?.variation.featureEnabled ?? false + let featureEnabled = pair?.variation?.featureEnabled ?? false if featureEnabled { logger.i(.featureEnabledForUser(featureKey, userId)) } else { @@ -585,8 +588,8 @@ open class OptimizelyClient: NSObject { user: makeInternalUserContext(userId: userId, attributes: attributes), options: nil).result if let decision = decision { - if let featureVariable = decision.variation.variables?.filter({$0.id == variable.id}).first { - if let featureEnabled = decision.variation.featureEnabled, featureEnabled { + if let featureVariable = decision.variation?.variables?.filter({$0.id == variable.id}).first { + if let featureEnabled = decision.variation?.featureEnabled, featureEnabled { featureValue = featureVariable.value logger.i(.userReceivedVariableValue(featureValue, variableKey, featureKey)) } else { @@ -675,7 +678,7 @@ open class OptimizelyClient: NSObject { featureFlag: featureFlag, user: makeInternalUserContext(userId: userId, attributes: attributes), options: nil).result - if let featureEnabled = decision?.variation.featureEnabled { + if let featureEnabled = decision?.variation?.featureEnabled { enabled = featureEnabled if featureEnabled { logger.i(.featureEnabledForUser(featureKey, userId)) @@ -688,7 +691,7 @@ open class OptimizelyClient: NSObject { for v in featureFlag.variables { var featureValue = v.defaultValue ?? "" - if enabled, let variable = decision?.variation.getVariable(id: v.id) { + if enabled, let variable = decision?.variation?.getVariable(id: v.id) { featureValue = variable.value } diff --git a/Sources/Utils/LogMessage.swift b/Sources/Utils/LogMessage.swift index 4ce7c08a..d2b1e084 100644 --- a/Sources/Utils/LogMessage.swift +++ b/Sources/Utils/LogMessage.swift @@ -47,6 +47,8 @@ enum LogMessage { case userHasNoForcedVariation(_ userId: String) case userHasNoForcedVariationForExperiment(_ userId: String, _ expKey: String) case userBucketedIntoVariationInExperiment(_ userId: String, _ expKey: String, _ varKey: String) + case userBucketedIntoEntity(_ entityId: String) + case userNotBucketedIntoAnyEntity case userBucketedIntoVariationInHoldout(_ userId: String, _ expKey: String, _ varKey: String) case userNotBucketedIntoVariation(_ userId: String) case userBucketedIntoInvalidVariation(_ id: String) @@ -56,6 +58,7 @@ enum LogMessage { case userNotBucketedIntoAnyExperimentInGroup(_ userId: String, _ group: String) case userBucketedIntoInvalidExperiment(_ id: String) case userNotInExperiment(_ userId: String, _ expKey: String) + case userNotInCmabExperiment(_ userId: String, _ expKey: String) case userReceivedDefaultVariableValue(_ userId: String, _ feature: String, _ variable: String) case userReceivedAllDefaultVariableValues(_ userId: String, _ feature: String) case featureNotEnabledReturnDefaultVariableValue(_ userId: String, _ feature: String, _ variable: String) @@ -73,6 +76,8 @@ enum LogMessage { case failedToAssignValue case valueForKeyNotFound(_ key: String) case lowPeriodicDownloadInterval + case cmabFetchFailed(_ expKey: String) + case cmabNotSupportedInSyncMode } extension LogMessage: CustomStringConvertible { @@ -114,6 +119,8 @@ extension LogMessage: CustomStringConvertible { case .userHasNoForcedVariation(let userId): message = "User (\(userId)) is not in the forced variation map." case .userHasNoForcedVariationForExperiment(let userId, let expKey): message = "No experiment (\(expKey)) mapped to user (\(userId)) in the forced variation map." case .userBucketedIntoVariationInExperiment(let userId, let expKey, let varKey): message = "User (\(userId)) is in variation (\(varKey)) of experiment (\(expKey))" + case .userBucketedIntoEntity(let entityId): message = "User bucketed into entity (\(entityId))" + case .userNotBucketedIntoAnyEntity: message = "User not bucketed into any entity" case .userBucketedIntoVariationInHoldout(let userId, let holdoutKey, let varKey): message = "User (\(userId)) is in variation (\(varKey)) of holdout (\(holdoutKey))" case .userNotBucketedIntoVariation(let userId): message = "User (\(userId)) is in no variation." case .userNotBucketedIntoHoldoutVariation(let userId): message = "User (\(userId)) is in no holdout variation." @@ -123,6 +130,7 @@ extension LogMessage: CustomStringConvertible { case .userNotBucketedIntoAnyExperimentInGroup(let userId, let group): message = "User (\(userId)) is not in any experiment of group (\(group))." case .userBucketedIntoInvalidExperiment(let id): message = "Bucketed into an invalid experiment id (\(id))" case .userNotInExperiment(let userId, let expKey): message = "User (\(userId)) does not meet conditions to be in experiment (\(expKey))." + case .userNotInCmabExperiment(let userId, let expKey): message = "User (\(userId)) does not fall into cmab traffic allocation in experiment (\(expKey))." case .userReceivedDefaultVariableValue(let userId, let feature, let variable): message = "User (\(userId)) is not in any variation or rollout rule. Returning default value for variable (\(variable)) of feature flag (\(feature))." case .userReceivedAllDefaultVariableValues(let userId, let feature): message = "User (\(userId)) is not in any variation or rollout rule. Returning default value for all variables of feature flag (\(feature))." case .featureNotEnabledReturnDefaultVariableValue(let userId, let feature, let variable): message = "Feature (\(feature)) is not enabled for user (\(userId)). Returning the default variable value (\(variable))." @@ -140,6 +148,8 @@ extension LogMessage: CustomStringConvertible { case .failedToAssignValue: message = "Value for path could not be assigned to provided type." case .valueForKeyNotFound(let key): message = "Value for JSON key (\(key)) not found." case .lowPeriodicDownloadInterval: message = "Polling intervals below 30 seconds are not recommended." + case .cmabFetchFailed(let key): message = "Failed to fetch CMAB data for experiment: \(key)." + case .cmabNotSupportedInSyncMode: message = "CMAB is not supported in sync mode." } return message diff --git a/Tests/OptimizelyTests-Common/BucketTests_BucketToEntity.swift b/Tests/OptimizelyTests-Common/BucketTests_BucketToEntity.swift new file mode 100644 index 00000000..7fbf5315 --- /dev/null +++ b/Tests/OptimizelyTests-Common/BucketTests_BucketToEntity.swift @@ -0,0 +1,189 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class BucketTests_BucketToEntity: XCTestCase { + var optimizely: OptimizelyClient! + var config: ProjectConfig! + var bucketer: DefaultBucketer! + + var kUserId = "12345" + var kGroupId = "333333" + var kExperimentId = "444444" + + var kExperimentKey = "countryExperiment" + + var kVariationKeyA = "a" + var kVariationKeyB = "b" + var kVariationKeyC = "c" + var kVariationKeyD = "d" + + var kVariationIdA = "a11" + var kVariationIdB = "b11" + var kVariationIdC = "c11" + var kVariationIdD = "d11" + + var kAudienceIdCountry = "10" + var kAudienceIdAge = "20" + var kAudienceIdInvalid = "9999999" + + var kAttributesCountryMatch: [String: Any] = ["country": "us"] + var kAttributesCountryNotMatch: [String: Any] = ["country": "ca"] + var kAttributesAgeMatch: [String: Any] = ["age": 30] + var kAttributesAgeNotMatch: [String: Any] = ["age": 10] + var kAttributesEmpty: [String: Any] = [:] + + var experiment: Experiment! + + // MARK: - Sample datafile data + + var sampleExperimentData: [String: Any] { return + [ + "status": "Running", + "id": kExperimentId, + "key": kExperimentKey, + "layerId": "10420273888", + "trafficAllocation": [ + ["entityId": kVariationIdA, "endOfRange": 2500], + ["entityId": kVariationIdB, "endOfRange": 5000], + ["entityId": kVariationIdC, "endOfRange": 7500], + ["entityId": kVariationIdD, "endOfRange": 10000] + ], + "audienceIds": [kAudienceIdCountry], + "variations": [ + [ + "variables": [], + "id": kVariationIdA, + "key": kVariationKeyA + ], + [ + "variables": [], + "id": kVariationIdB, + "key": kVariationKeyB + ], + [ + "variables": [], + "id": kVariationIdC, + "key": kVariationKeyC + ], + [ + "variables": [], + "id": kVariationIdD, + "key": kVariationKeyD + ] + ], + "forcedVariations": [:] + ] + } + + var sampleGroupData: [String: Any] { return + ["id": kGroupId, + "policy": "random", + "trafficAllocation": [ + ["entityId": kExperimentId, "endOfRange": 10000] + ], + "experiments": [sampleExperimentData] + ] + } + + // MARK: - Setup + + override func setUp() { + super.setUp() + + self.optimizely = OTUtils.createOptimizely(datafileName: "empty_datafile", + clearUserProfileService: true) + self.config = self.optimizely.config + self.bucketer = ((optimizely.decisionService as! DefaultDecisionService).bucketer as! DefaultBucketer) + } + + func testBucketToEntityWithEmptyGroup() { + experiment = try! OTUtils.model(from: sampleExperimentData) + self.config.project.experiments = [experiment] + + self.config.project.groups = [] + + let fullAllocation = TrafficAllocation(entityId: "entity_123", endOfRange: 10000) + let bucketedEntityId = bucketer.bucketToEntityId(config: config, experiment: experiment, bucketingId: "id_123", trafficAllocation: [fullAllocation]).result + XCTAssertEqual(bucketedEntityId, "entity_123") + + let zeroAllocation = TrafficAllocation(entityId: "entity_123", endOfRange: 0) + let nilEntityId = bucketer.bucketToEntityId(config: config, experiment: experiment, bucketingId: "id_123", trafficAllocation: [zeroAllocation]).result + XCTAssertEqual(nilEntityId, nil) + } + + func testBucketToEntityWithGroupMatched() { + experiment = try! OTUtils.model(from: sampleExperimentData) + self.config.project.experiments = [experiment] + + let group: Group = try! OTUtils.model(from: sampleGroupData) + self.config.project.groups = [group] + + let tests = [["userId": "ppid1", "entityId": "entity1", "expect": "entity1"], + ["userId": "ppid2", "entityId": "entity2", "expect": "entity2"], + ["userId": "ppid3", "entityId": "entity3", "expect": "entity3"], + ["userId": "a very very very very very very very very very very very very very very very long ppd string", "entityId": "entity4", "expect": "entity4"]] + + var entityId: String! + + for (idx, test) in tests.enumerated() { + entityId = bucketer.bucketToEntityId(config: config, experiment: experiment, bucketingId: test["userId"]!, trafficAllocation: [TrafficAllocation(entityId: test["entityId"]!, endOfRange: 10000)]).result + XCTAssertEqual(test["expect"], entityId, "test[\(idx)] failed") + } + } + + func testBucketToEntityWithGroupNotMatched() { + experiment = try! OTUtils.model(from: sampleExperimentData) + self.config.project.experiments = [experiment] + + var group: Group = try! OTUtils.model(from: sampleGroupData) + group.trafficAllocation[0].endOfRange = 0 + self.config.project.groups = [group] + + let tests = [["userId": "ppid1", "entityId": "entity1", "expect": "entity1"], + ["userId": "ppid2", "entityId": "entity2", "expect": "entity2"], + ["userId": "ppid3", "entityId": "entity3", "expect": "entity3"], + ["userId": "a very very very very very very very very very very very very very very very long ppd string", "entityId": "entity4", "expect": "entity4"]] + + for (_, test) in tests.enumerated() { + let response: DecisionResponse = bucketer.bucketToEntityId(config: config, experiment: experiment, bucketingId: test["userId"]!, trafficAllocation: [TrafficAllocation(entityId: test["entityId"]!, endOfRange: 10000)]) + XCTAssertEqual(response.result, nil) + } + } + + func testBucketToEntityWithNoRandoomGroup() { + experiment = try! OTUtils.model(from: sampleExperimentData) + self.config.project.experiments = [experiment] + + var group: Group = try! OTUtils.model(from: sampleGroupData) + group.policy = .overlapping + self.config.project.groups = [group] + + let tests = [["userId": "ppid1", "entityId": "entity1", "expect": "entity1"], + ["userId": "ppid2", "entityId": "entity2", "expect": "entity2"], + ["userId": "ppid3", "entityId": "entity3", "expect": "entity3"], + ["userId": "a very very very very very very very very very very very very very very very long ppd string", "entityId": "entity4", "expect": "entity4"]] + + var entityId: String! + + for (idx, test) in tests.enumerated() { + entityId = bucketer.bucketToEntityId(config: config, experiment: experiment, bucketingId: test["userId"]!, trafficAllocation: [TrafficAllocation(entityId: test["entityId"]!, endOfRange: 10000)]).result + XCTAssertEqual(test["expect"], entityId, "test[\(idx)] failed") + } + } + +} diff --git a/Tests/OptimizelyTests-Common/CmabServiceTests.swift b/Tests/OptimizelyTests-Common/CmabServiceTests.swift index 10c042cc..f79d2f16 100644 --- a/Tests/OptimizelyTests-Common/CmabServiceTests.swift +++ b/Tests/OptimizelyTests-Common/CmabServiceTests.swift @@ -462,3 +462,198 @@ class DefaultCmabServiceTests: XCTestCase { } } +extension DefaultCmabServiceTests { + func testSyncFetchDecision() { + cmabClient.fetchDecisionResult = .success("variation-123") + + let result = cmabService.getDecision( + config: config, + userContext: userContext, + ruleId: "exp-123", + options: [] + ) + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "variation-123") + XCTAssertEqual(self.cmabClient.lastRuleId, "exp-123") + XCTAssertEqual(self.cmabClient.lastUserId, "test-user") + XCTAssertEqual(self.cmabClient.lastAttributes?.count, 2) + XCTAssertEqual(self.cmabClient.lastAttributes?["age"] as? Int, 25) + XCTAssertEqual(self.cmabClient.lastAttributes?["location"] as? String, "San Francisco") + + // Verify it was cached + let cacheKey = "9-test-user-exp-123" + XCTAssertNotNil(self.cmabCache.lookup(key: cacheKey)) + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + } + + func testSyncCachedDecision() { + // First, put something in the cache + let attributesHash = cmabService.hashAttributes(["age": 25, "location": "San Francisco"]) + let cacheKey = "9-test-user-exp-123" + let cacheValue = CmabCacheValue( + attributesHash: attributesHash, + variationId: "cached-variation", + cmabUUID: "cached-uuid" + ) + cmabCache.save(key: cacheKey, value: cacheValue) + + let result = cmabService.getDecision( + config: config, + userContext: userContext, + ruleId: "exp-123", + options: [] + ) + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "cached-variation") + XCTAssertEqual(decision.cmabUUID, "cached-uuid") + XCTAssertFalse(self.cmabClient.fetchDecisionCalled, "Should not call API when cache hit") + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + } + + func testSyncFailedFetch() { + let testError = CmabClientError.fetchFailed("Test error") + cmabClient.fetchDecisionResult = .failure(testError) + + let result = cmabService.getDecision( + config: config, + userContext: userContext, + ruleId: "exp-123", + options: [] + ) + + switch result { + case .success: + XCTFail("Expected failure but got success") + + case .failure(let error): + XCTAssertEqual((error as? CmabClientError)?.message, "Test error") + + // Verify no caching of failed results + let cacheKey = "9-test-user-exp-123" + XCTAssertNil(self.cmabCache.lookup(key: cacheKey)) + } + } + + func testSyncIgnoreCmabCacheOption() { + // First, put something in the cache + let attributesHash = cmabService.hashAttributes(["age": 25, "location": "San Francisco"]) + let cacheKey = "9-test-user-exp-123" + let cacheValue = CmabCacheValue( + attributesHash: attributesHash, + variationId: "cached-variation", + cmabUUID: "cached-uuid" + ) + cmabCache.save(key: cacheKey, value: cacheValue) + + cmabClient.fetchDecisionResult = .success("new-variation") + + let result = cmabService.getDecision( + config: config, + userContext: userContext, + ruleId: "exp-123", + options: [.ignoreCmabCache] + ) + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "new-variation") + XCTAssertTrue(self.cmabClient.fetchDecisionCalled, "Should always call API when ignoreCmabCache option is set") + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + } + + func testSyncResetCmabCacheOption() { + // First, put something in the cache + let attributesHash = cmabService.hashAttributes(["age": 25, "location": "San Francisco"]) + let cacheKey = "9-test-user-exp-123" + let cacheValue = CmabCacheValue( + attributesHash: attributesHash, + variationId: "cached-variation", + cmabUUID: "cached-uuid" + ) + cmabCache.save(key: cacheKey, value: cacheValue) + + // Also add another item to verify it's cleared + let otherCacheKey = "other-key" + cmabCache.save(key: otherCacheKey, value: cacheValue) + + cmabClient.fetchDecisionResult = .success("new-variation") + + let result = cmabService.getDecision( + config: config, + userContext: userContext, + ruleId: "exp-123", + options: [.resetCmabCache] + ) + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "new-variation") + XCTAssertTrue(self.cmabClient.fetchDecisionCalled, "Should call API after resetting cache") + + // Verify the entire cache was reset + XCTAssertNil(self.cmabCache.lookup(key: otherCacheKey)) + + // But the new decision should be cached + XCTAssertNotNil(self.cmabCache.lookup(key: cacheKey)) + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + } + + func testSyncInvalidateUserCmabCacheOption() { + // First, put something in the cache + let attributesHash = cmabService.hashAttributes(["age": 25, "location": "San Francisco"]) + let userCacheKey = "9-test-user-exp-123" + let otherUserCacheKey = "other-user-key" + + let cacheValue = CmabCacheValue( + attributesHash: attributesHash, + variationId: "cached-variation", + cmabUUID: "cached-uuid" + ) + + // Cache for both current user and another user + cmabCache.save(key: userCacheKey, value: cacheValue) + cmabCache.save(key: otherUserCacheKey, value: cacheValue) + + cmabClient.fetchDecisionResult = .success("new-variation") + + let result = cmabService.getDecision( + config: config, + userContext: userContext, + ruleId: "exp-123", + options: [.invalidateUserCmabCache] + ) + + switch result { + case .success(let decision): + XCTAssertEqual(decision.variationId, "new-variation") + XCTAssertTrue(self.cmabClient.fetchDecisionCalled, "Should call API after invalidating user cache") + + // Verify only the specific user's cache was invalidated + XCTAssertNotNil(self.cmabCache.lookup(key: otherUserCacheKey), "Other users' cache should remain intact") + + // The new decision should be cached for the current user + XCTAssertNotNil(self.cmabCache.lookup(key: userCacheKey)) + XCTAssertEqual(self.cmabCache.lookup(key: userCacheKey)?.variationId, "new-variation") + + case .failure(let error): + XCTFail("Expected success but got error: \(error)") + } + } + +} diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift index 157bed45..8e885b2a 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift @@ -1251,7 +1251,7 @@ class FakeDecisionService: DefaultDecisionService { var variation: Variation? var source: String! - override init(userProfileService: OPTUserProfileService) { + override init(userProfileService: OPTUserProfileService, cmabService: CmabService = DefaultCmabService.createDefault()) { super.init(userProfileService: DefaultUserProfileService()) } @@ -1267,7 +1267,7 @@ class FakeDecisionService: DefaultDecisionService { return DecisionResponse.responseNoReasons(result: featureDecision) } - override func getVariationForFeatureExperiments(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + override func getVariationForFeatureExperiments(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, isAsync: Bool = false, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { guard let experiment = self.experiment, let tmpVariation = self.variation else { return DecisionResponse.nilNoReasons() } diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift index 210b2ffb..80bd62cc 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift @@ -704,6 +704,158 @@ extension DecisionServiceTests_Experiments { } } +// MARK: - CMAB + +extension DecisionServiceTests_Experiments { + func testGetVariationWithCMABTrafficAllocation() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + var cmabExperiment: Experiment = try! OTUtils.model(from: sampleExperimentData) + cmabExperiment.cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + self.config.project.experiments = [cmabExperiment] + let mocCmabService = MockCmabService() + mocCmabService.variationId = "10389729780" // kVariationKeyA + + self.decisionService = DefaultDecisionService(userProfileService: DefaultUserProfileService(), cmabService: mocCmabService) + + let user = optimizely.createUserContext( + userId: kUserId, + attributes: kAttributesCountryMatch + ) + + let decision = self.decisionService.getVariation(config: config, + experiment: cmabExperiment, + user: user, + options: nil, + isAsync: true, + userProfileTracker: nil) + let variation = decision.result?.variation + XCTAssertNotNil(variation) + XCTAssertEqual(variation?.key, kVariationKeyA) + } + + func testGetVariationWithCMABZeroTrafficAllocation() { + // Test when traffic allocation is 0% + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + var cmabExperiment: Experiment = try! OTUtils.model(from: sampleExperimentData) + cmabExperiment.cmab = try! OTUtils.model(from: [ + "trafficAllocation": 0, // 0% traffic + "attributeIds": ["10389729780"] + ]) + self.config.project.experiments = [cmabExperiment] + + let user = optimizely.createUserContext( + userId: kUserId, + attributes: kAttributesCountryMatch + ) + let expectedReasons = DecisionReasons() + expectedReasons.addInfo(LogMessage.userNotInCmabExperiment(user.userId, cmabExperiment.key)) + expectedReasons.addInfo(LogMessage.userNotBucketedIntoVariation(user.userId)) + let decision = self.decisionService.getVariation(config: config, + experiment: cmabExperiment, + user: user, + options: nil, + isAsync: true, + userProfileTracker: nil) + XCTAssertNil(decision.result, "Should return nil for 0% traffic allocation") + XCTAssertEqual(expectedReasons.toReport(), decision.reasons.toReport()) + } + + func testGetVariationWithCMABFetchError() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + var cmabExperiment: Experiment = try! OTUtils.model(from: sampleExperimentData) + cmabExperiment.cmab = try! OTUtils.model(from: [ + "trafficAllocation": 10000, + "attributeIds": ["10389729780"] + ]) + self.config.project.experiments = [cmabExperiment] + + let mockCmabService = MockCmabService() + mockCmabService.error = CmabClientError.fetchFailed("Test error") + + self.decisionService = DefaultDecisionService( + userProfileService: DefaultUserProfileService(), + cmabService: mockCmabService + ) + + let user = optimizely.createUserContext( + userId: kUserId, + attributes: kAttributesCountryMatch + ) + + let expectedReasons = DecisionReasons() + expectedReasons.addInfo(LogMessage.cmabFetchFailed(cmabExperiment.key)) + expectedReasons.addInfo(LogMessage.userNotBucketedIntoVariation(user.userId)) + + let decision = self.decisionService.getVariation(config: config, + experiment: cmabExperiment, + user: user, + options: nil, + isAsync: true, + userProfileTracker: nil) + + XCTAssertNotNil(decision.result) + XCTAssertEqual(decision.result?.variation, nil) + XCTAssertEqual(expectedReasons.toReport(), decision.reasons.toReport()) + } + + func testCmabNotSupportedInSyncMode() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + var cmabExperiment: Experiment = try! OTUtils.model(from: sampleExperimentData) + cmabExperiment.cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + self.config.project.experiments = [cmabExperiment] + let mocCmabService = MockCmabService() + mocCmabService.variationId = "10389729780" // kVariationKeyA + + self.decisionService = DefaultDecisionService(userProfileService: DefaultUserProfileService(), cmabService: mocCmabService) + + let user = optimizely.createUserContext( + userId: kUserId, + attributes: kAttributesCountryMatch + ) + + let expectedReasons = DecisionReasons() + expectedReasons.addInfo(LogMessage.cmabNotSupportedInSyncMode) + expectedReasons.addInfo(LogMessage.userNotBucketedIntoVariation(user.userId)) + + let decision = self.decisionService.getVariation(config: config, + experiment: cmabExperiment, + user: user, + options: nil, + isAsync: false, + userProfileTracker: nil) + XCTAssertNil(decision.result?.variation) + XCTAssertEqual(expectedReasons.toReport(), decision.reasons.toReport()) + } + + func testGetVariationWhenUserHasNoVariation() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + var cmabExperiment: Experiment = try! OTUtils.model(from: sampleExperimentData) + cmabExperiment.cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + self.config.project.experiments = [cmabExperiment] + let mocCmabService = MockCmabService() + mocCmabService.variationId = "unknown_var_id" + + self.decisionService = DefaultDecisionService(userProfileService: DefaultUserProfileService(), cmabService: mocCmabService) + + let user = optimizely.createUserContext( + userId: kUserId, + attributes: kAttributesCountryMatch + ) + + let expectedReasons = DecisionReasons() + expectedReasons.addInfo(LogMessage.userNotBucketedIntoVariation(user.userId)) + + let decision = self.decisionService.getVariation(config: config, + experiment: cmabExperiment, + user: user, + options: nil, + isAsync: true, + userProfileTracker: nil) + XCTAssertNil(decision.result?.variation) + XCTAssertEqual(expectedReasons.toReport(), decision.reasons.toReport()) + } +} + // MARK: - Test getBucketingId() extension DecisionServiceTests_Experiments { @@ -725,3 +877,25 @@ extension DecisionServiceTests_Experiments { } } + +fileprivate struct MockError: Error { + var message: String? +} + +fileprivate class MockCmabService: DefaultCmabService { + var error: Error? + var variationId: String? + + init() { + super.init(cmabClient: DefaultCmabClient(), cmabCache: LruCache(size: 10, timeoutInSecs: 10)) + } + + override func getDecision(config: ProjectConfig, userContext: OptimizelyUserContext, ruleId: String, options: [OptimizelyDecideOption]) -> Result { + if let variationId = self.variationId { + let cmabUUID = UUID().uuidString + return .success(CmabDecision(variationId: variationId, cmabUUID: cmabUUID)) + } else { + return .failure(self.error ?? MockError()) + } + } +} diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift index 6a4b3eeb..cc94dbbe 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift @@ -259,19 +259,19 @@ extension DecisionServiceTests_Features { func testGetVariationForFeatureExperimentWhenMatched() { let pair = self.decisionService.getVariationForFeatureExperiments(config: config, - featureFlag: featureFlag, - user: optimizely.createUserContext(userId: kUserId, - attributes: kAttributesCountryMatch)).result + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch), + isAsync: false).result XCTAssert(pair?.experiment?.key == kExperimentKey) - XCTAssert(pair?.variation.key == kVariationKeyD) + XCTAssert(pair?.variation?.key == kVariationKeyD) XCTAssert(pair?.source == Constants.DecisionSource.featureTest.rawValue) } func testGetVariationForFeatureExperimentWhenNotMatched() { let pair = self.decisionService.getVariationForFeatureExperiments(config: config, - featureFlag: featureFlag, - user: optimizely.createUserContext(userId: kUserId, - attributes: kAttributesCountryNotMatch)).result + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch), + isAsync: false).result XCTAssertNil(pair) } @@ -281,9 +281,9 @@ extension DecisionServiceTests_Features { self.config.project.featureFlags = [featureFlag] let pair = self.decisionService.getVariationForFeatureExperiments(config: config, - featureFlag: featureFlag, - user: optimizely.createUserContext(userId: kUserId, - attributes: kAttributesCountryMatch)).result + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch), + isAsync: false).result XCTAssertNil(pair) } @@ -330,8 +330,8 @@ extension DecisionServiceTests_Features { let pair = ups_service.getVariationForFeatureList( config: config, featureFlags: [flag1, flag2, flag3], - user: optimizely.createUserContext(userId: kUserId, - attributes: kAttributesCountryMatch) + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch), + isAsync: false ) @@ -339,7 +339,7 @@ extension DecisionServiceTests_Features { XCTAssertEqual(mockProfileService.saveCount, 1) XCTAssertEqual(pair.count, 3) XCTAssert(pair[0].result?.experiment?.key == kExperimentKey) - XCTAssert(pair[0].result?.variation.key == kVariationKeyD) + XCTAssert(pair[0].result?.variation?.key == kVariationKeyD) XCTAssert(pair[0].result?.source == Constants.DecisionSource.featureTest.rawValue) } } @@ -361,7 +361,7 @@ extension DecisionServiceTests_Features { attributes: kAttributesRolloutAge1Match)).result XCTAssert(pair?.experiment?.id == kRolloutExperimentId) - XCTAssert(pair?.variation.key == kRolloutVariationKeyA) + XCTAssert(pair?.variation?.key == kRolloutVariationKeyA) XCTAssert(pair?.source == Constants.DecisionSource.rollout.rawValue) } @@ -412,7 +412,7 @@ extension DecisionServiceTests_Features { user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesRolloutAge1Match)).result XCTAssert(pair?.experiment?.id == kRolloutExperimentId3) - XCTAssert(pair?.variation.key == kRolloutVariationKeyC) + XCTAssert(pair?.variation?.key == kRolloutVariationKeyC) XCTAssert(pair?.source == Constants.DecisionSource.rollout.rawValue) } @@ -427,7 +427,7 @@ extension DecisionServiceTests_Features { user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesRolloutAge2Match)).result XCTAssert(pair?.experiment?.id == kRolloutExperimentId2) - XCTAssert(pair?.variation.key == kRolloutVariationKeyB) + XCTAssert(pair?.variation?.key == kRolloutVariationKeyB) XCTAssert(pair?.source == Constants.DecisionSource.rollout.rawValue) } @@ -443,7 +443,7 @@ extension DecisionServiceTests_Features { user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesRolloutAge1Match)).result XCTAssert(pair?.experiment?.id == kRolloutExperimentId) - XCTAssert(pair?.variation.key == kRolloutVariationKeyA) + XCTAssert(pair?.variation?.key == kRolloutVariationKeyA) XCTAssert(pair?.source == Constants.DecisionSource.rollout.rawValue) } @@ -489,7 +489,7 @@ extension DecisionServiceTests_Features { user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(pair?.experiment?.key == kExperimentKey) - XCTAssert(pair?.variation.key == kVariationKeyD) + XCTAssert(pair?.variation?.key == kVariationKeyD) } func testGetVariationForFeatureWhenExperimentNotMatchAndRolloutNotExist() { @@ -512,9 +512,9 @@ extension DecisionServiceTests_Features { attributes: kAttributesCountryNotMatch)).result if let pair = pair { XCTAssert(pair.experiment?.id == kRolloutExperimentId3) - XCTAssert(pair.variation.key == kRolloutVariationKeyC) + XCTAssert(pair.variation!.key == kRolloutVariationKeyC) XCTAssert(pair.experiment?.id == kRolloutExperimentId3) - XCTAssert(pair.variation.key == kRolloutVariationKeyC) + XCTAssert(pair.variation?.key == kRolloutVariationKeyC) } else { XCTFail() } diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift index c18096c9..f7476354 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift @@ -365,7 +365,7 @@ extension DecisionServiceTests_Holdouts { XCTAssertNotNil(decision, "Decision should not be nil") XCTAssertEqual(decision?.experiment?.id, holdout.id, "Should return holdout experiment") - XCTAssertEqual(decision?.variation.key, "holdout_a", "Should return holdout variation") + XCTAssertEqual(decision?.variation?.key, "holdout_a", "Should return holdout variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue, "Source should be holdout") } @@ -394,7 +394,7 @@ extension DecisionServiceTests_Holdouts { // Should fall back to experiment and bucket into variation D XCTAssertNotNil(decision, "Decision should not be nil") XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") - XCTAssertEqual(decision?.variation.key, kVariationKeyD, "Should return experiment variation") + XCTAssertEqual(decision?.variation?.key, kVariationKeyD, "Should return experiment variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should be featureTest") } @@ -415,7 +415,7 @@ extension DecisionServiceTests_Holdouts { // Should skip holdout and bucket into experiment XCTAssertNotNil(decision, "Decision should not be nil") XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") - XCTAssertEqual(decision?.variation.key, kVariationKeyD, "Should return experiment variation") + XCTAssertEqual(decision?.variation?.key, kVariationKeyD, "Should return experiment variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should be featureTest") } @@ -433,7 +433,7 @@ extension DecisionServiceTests_Holdouts { // Should bucket into experiment XCTAssertNotNil(decision, "Decision should not personally identifiable informationnil") XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") - XCTAssertEqual(decision?.variation.key, kVariationKeyD, "Should return experiment variation") + XCTAssertEqual(decision?.variation?.key, kVariationKeyD, "Should return experiment variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should be featureTest") } @@ -453,7 +453,7 @@ extension DecisionServiceTests_Holdouts { // Should return holdout decision XCTAssertNotNil(decision, "Decision should not be nil") XCTAssertEqual(decision?.experiment?.id, holdout.id, "Should return holdout experiment") - XCTAssertEqual(decision?.variation.key, "holdout_a", "Should return holdout variation") + XCTAssertEqual(decision?.variation?.key, "holdout_a", "Should return holdout variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue, "Source should be holdout") } @@ -473,7 +473,7 @@ extension DecisionServiceTests_Holdouts { // Should return holdout decision XCTAssertNotNil(decision, "Decision should not be nil") XCTAssertEqual(decision?.experiment?.id, holdout.id, "Should return holdout experiment") - XCTAssertEqual(decision?.variation.key, "holdout_a", "Should return holdout variation") + XCTAssertEqual(decision?.variation?.key, "holdout_a", "Should return holdout variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue, "Source should be holdout") } @@ -494,7 +494,7 @@ extension DecisionServiceTests_Holdouts { // Should skip holdout and bucket into experiment XCTAssertNotNil(decision, "Decision should not be nil") XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") - XCTAssertEqual(decision?.variation.key, kVariationKeyD, "Should return experiment variation") + XCTAssertEqual(decision?.variation?.key, kVariationKeyD, "Should return experiment variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should Westhill") } @@ -524,7 +524,7 @@ extension DecisionServiceTests_Holdouts { // Should select global holdout first (ordering: global > included) XCTAssertNotNil(decision) XCTAssertEqual(decision?.experiment?.id, globalHoldout.id, "Should select global holdout first") - XCTAssertEqual(decision?.variation.key, "global_variation") + XCTAssertEqual(decision?.variation?.key, "global_variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue) } @@ -548,7 +548,7 @@ extension DecisionServiceTests_Holdouts { // Global holdout fails bucketing, should select included holdout XCTAssertNotNil(decision) XCTAssertEqual(decision?.experiment?.id, includedHoldout.id, "Should select included holdout") - XCTAssertEqual(decision?.variation.key, "included_variation") + XCTAssertEqual(decision?.variation?.key, "included_variation") XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue) } @@ -572,7 +572,7 @@ extension DecisionServiceTests_Holdouts { // All holdouts fail, should fall back to experiment XCTAssertNotNil(decision) XCTAssertEqual(decision?.experiment?.id, kExperimentId) - XCTAssertEqual(decision?.variation.key, kVariationKeyD) + XCTAssertEqual(decision?.variation?.key, kVariationKeyD) XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue) } @@ -592,7 +592,7 @@ extension DecisionServiceTests_Holdouts { // Holdout has no traffic allocation, should fall back to experiment XCTAssertNotNil(decision) XCTAssertEqual(decision?.experiment?.id, kExperimentId) - XCTAssertEqual(decision?.variation.key, kVariationKeyD) + XCTAssertEqual(decision?.variation?.key, kVariationKeyD) XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue) } @@ -638,7 +638,7 @@ extension DecisionServiceTests_Holdouts { // Holdout has no variations, should fall back to experiment XCTAssertNotNil(decision) XCTAssertEqual(decision?.experiment?.id, kExperimentId) - XCTAssertEqual(decision?.variation.key, kVariationKeyD) + XCTAssertEqual(decision?.variation?.key, kVariationKeyD) XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue) } @@ -667,8 +667,8 @@ extension DecisionServiceTests_Holdouts { XCTAssertNotNil(decision2) XCTAssertEqual(decision1?.experiment?.id, includedHoldout.id) XCTAssertEqual(decision2?.experiment?.id, includedHoldout.id) - XCTAssertEqual(decision1?.variation.key, "included_variation") - XCTAssertEqual(decision2?.variation.key, "included_variation") + XCTAssertEqual(decision1?.variation?.key, "included_variation") + XCTAssertEqual(decision2?.variation?.key, "included_variation") XCTAssertEqual(decision1?.source, Constants.DecisionSource.holdout.rawValue) XCTAssertEqual(decision2?.source, Constants.DecisionSource.holdout.rawValue) } diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Async.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Async.swift new file mode 100644 index 00000000..70dc3d6b --- /dev/null +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Async.swift @@ -0,0 +1,511 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class OptimizelyUserContextTests_Decide_Async: XCTestCase { + + let kUserId = "tester" + + var optimizely: OptimizelyClient! + var eventDispatcher = MockEventDispatcher() + var decisionService: DefaultDecisionService! + var ups: OPTUserProfileService! + + override func setUp() { + super.setUp() + + let datafile = OTUtils.loadJSONDatafile("decide_datafile")! + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, + eventDispatcher: eventDispatcher, + userProfileService: OTUtils.createClearUserProfileService()) + decisionService = (optimizely.decisionService as! DefaultDecisionService) + ups = decisionService.userProfileService + try! optimizely.start(datafile: datafile) + } + + func testDecideAsync() { + let expectation = XCTestExpectation(description: "Async decision completed") + let featureKey = "feature_2" + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) + let user = optimizely.createUserContext(userId: kUserId) + + user.decideAsync(key: featureKey) { decision in + XCTAssertEqual(decision.variationKey, "variation_with_traffic") + XCTAssertTrue(decision.enabled) + XCTAssertTrue(NSDictionary(dictionary: decision.variables.toMap()).isEqual(to: variablesExpected.toMap())) + XCTAssertEqual(decision.ruleKey, "exp_no_audience") + XCTAssertEqual(decision.flagKey, featureKey) + XCTAssertEqual(decision.userContext, user) + XCTAssert(decision.reasons.isEmpty) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1) + } + + func testDecideAsyncCompletionOrder() { + let expectation = XCTestExpectation(description: "Async decision completed") + let featureKey = "feature_2" + let user = optimizely.createUserContext(userId: kUserId) + var operationOrder: [String] = [] + + operationOrder.append("before") + + user.decideAsync(key: featureKey) { decision in + operationOrder.append("during") + expectation.fulfill() + } + operationOrder.append("after") + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(operationOrder, ["before", "after", "during"]) + } + + func testDecideForKeys_twoFeaturesAsync() { + // Create expectation + let expectation = XCTestExpectation(description: "Multiple features decision completed") + + // Setup test data + let featureKey1 = "feature_1" + let featureKey2 = "feature_2" + let featureKeys = [featureKey1, featureKey2] + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + + // Make async decision + user.decideAsync(keys: featureKeys) { decisions in + // Verify number of decisions + XCTAssertEqual(decisions.count, 2) + + // Verify first feature decision + XCTAssertNotNil(decisions[featureKey1]) + XCTAssertEqual(decisions[featureKey1]!, OptimizelyDecision( + variationKey: "a", + enabled: true, + variables: variablesExpected1, + ruleKey: "exp_with_audience", + flagKey: featureKey1, + userContext: user, + reasons: [] + )) + + // Verify second feature decision + XCTAssertNotNil(decisions[featureKey2]) + XCTAssertEqual(decisions[featureKey2]!, OptimizelyDecision( + variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected2, + ruleKey: "exp_no_audience", + flagKey: featureKey2, + userContext: user, + reasons: [] + )) + + expectation.fulfill() + } + + // Wait for async operation to complete + wait(for: [expectation], timeout: 1) + } + + //MARK: - Decide All Async + + func testDecideAllAsync() { + let expectation = XCTestExpectation(description: "All decisions completed") + + let featureKey1 = "feature_1" + let featureKey2 = "feature_2" + let featureKey3 = "feature_3" + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) + let variablesExpected3 = OptimizelyJSON.createEmpty() + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + + user.decideAllAsync { decisions in + XCTAssertEqual(decisions.count, 3) + + XCTAssertNotNil(decisions[featureKey1]) + XCTAssertEqual(decisions[featureKey1]!, OptimizelyDecision( + variationKey: "a", + enabled: true, + variables: variablesExpected1, + ruleKey: "exp_with_audience", + flagKey: featureKey1, + userContext: user, + reasons: [] + )) + + XCTAssertNotNil(decisions[featureKey2]) + XCTAssertEqual(decisions[featureKey2]!, OptimizelyDecision( + variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected2, + ruleKey: "exp_no_audience", + flagKey: featureKey2, + userContext: user, + reasons: [] + )) + + XCTAssertNotNil(decisions[featureKey3]) + XCTAssertEqual(decisions[featureKey3]!, OptimizelyDecision( + variationKey: nil, + enabled: false, + variables: variablesExpected3, + ruleKey: nil, + flagKey: featureKey3, + userContext: user, + reasons: [] + )) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + func testDecideAllAsync_enabledOnly() { + let expectation = XCTestExpectation(description: "Enabled flags decisions completed") + + let featureKey1 = "feature_1" + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + + user.decideAllAsync(options: [.enabledFlagsOnly]) { decisions in + XCTAssertEqual(decisions.count, 2) + + XCTAssertNotNil(decisions[featureKey1]) + XCTAssertEqual(decisions[featureKey1]!, OptimizelyDecision( + variationKey: "a", + enabled: true, + variables: variablesExpected1, + ruleKey: "exp_with_audience", + flagKey: featureKey1, + userContext: user, + reasons: [] + )) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } +} + +extension OptimizelyUserContextTests_Decide_Async { + + func testDecideAsync_sdkNotReady() { + let expectation = XCTestExpectation(description: "SDK not ready decision") + let featureKey = "feature_1" + + self.optimizely = OptimizelyClient(sdkKey: "12345", + userProfileService: OTUtils.createClearUserProfileService()) + + let user = optimizely.createUserContext(userId: kUserId) + user.decideAsync(key: featureKey) { decision in + XCTAssertNil(decision.variationKey) + XCTAssertFalse(decision.enabled) + XCTAssertTrue(decision.variables.isEmpty) + XCTAssertNil(decision.ruleKey) + XCTAssertEqual(decision.flagKey, featureKey) + XCTAssertEqual(decision.userContext, user) + + XCTAssertEqual(decision.reasons.count, 1) + XCTAssertEqual(decision.reasons.first, OptimizelyError.sdkNotReady.reason) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + func testDecideAsync_sdkNotReady_optimizelyReleased() { + let expectation = XCTestExpectation(description: "SDK released decision") + let featureKey = "feature_1" + + var optimizelyClient: OptimizelyClient! = OptimizelyClient(sdkKey: OTUtils.randomSdkKey) + let datafile = OTUtils.loadJSONDatafile("decide_datafile")! + try! optimizelyClient.start(datafile: datafile) + + let user = optimizelyClient.createUserContext(userId: kUserId) + + // Release client to simulate weak reference becoming nil + optimizelyClient = nil + + user.decideAsync(key: featureKey) { decision in + XCTAssertNil(decision.variationKey) + XCTAssertEqual(decision.reasons.count, 1) + XCTAssertEqual(decision.reasons.first, OptimizelyError.sdkNotReady.reason) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + func testDecideAsync_invalidFeatureKey() { + let expectation = XCTestExpectation(description: "Invalid feature key decision") + let featureKey = "invalid_key" + + let user = optimizely.createUserContext(userId: kUserId) + + user.decideAsync(key: featureKey) { decision in + XCTAssertNil(decision.variationKey) + XCTAssertFalse(decision.enabled) + XCTAssertTrue(decision.variables.isEmpty) + XCTAssertNil(decision.ruleKey) + XCTAssertEqual(decision.reasons.count, 1) + XCTAssertEqual(decision.reasons.first, OptimizelyError.featureKeyInvalid(featureKey).reason) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + // MARK: - Decide For Keys Async + + func testDecideForKeysAsync_sdkNotReady() { + let expectation = XCTestExpectation(description: "SDK not ready multiple decisions") + let featureKeys = ["feature_1"] + + self.optimizely = OptimizelyClient(sdkKey: "12345", + userProfileService: OTUtils.createClearUserProfileService()) + + let user = optimizely.createUserContext(userId: kUserId) + user.decideAsync(keys: featureKeys) { decisions in + XCTAssertEqual(decisions.count, 0) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + func testDecideForKeysAsync_sdkNotReady_optimizelyReleased() { + let expectation = XCTestExpectation(description: "SDK released multiple decisions") + let featureKeys = ["feature_1"] + + var optimizelyClient: OptimizelyClient! = OptimizelyClient(sdkKey: OTUtils.randomSdkKey) + let datafile = OTUtils.loadJSONDatafile("decide_datafile")! + try! optimizelyClient.start(datafile: datafile) + + let user = optimizelyClient.createUserContext(userId: kUserId) + + optimizelyClient = nil + + user.decideAsync(keys: featureKeys) { decisions in + XCTAssertEqual(decisions.count, 0) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + func testDecideForKeysAsync_errorDecisionIncluded() { + let expectation = XCTestExpectation(description: "Error decision included") + let featureKey1 = "feature_2" + let featureKey2 = "invalid_key" + let featureKeys = [featureKey1, featureKey2] + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let user = optimizely.createUserContext(userId: kUserId) + + user.decideAsync(keys: featureKeys) { decisions in + XCTAssertEqual(decisions.count, 2) + + XCTAssertEqual(decisions[featureKey1], OptimizelyDecision( + variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected1, + ruleKey: "exp_no_audience", + flagKey: featureKey1, + userContext: user, + reasons: [] + )) + + XCTAssertEqual(decisions[featureKey2], OptimizelyDecision.errorDecision( + key: featureKey2, + user: user, + error: .featureKeyInvalid(featureKey2) + )) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } +} + +extension OptimizelyUserContextTests_Decide_Async { + // MARK: - Concurrent Tests + + func testDecideAsync_multipleConcurrentRequests() { + let expectations = [ + XCTestExpectation(description: "First decision"), + XCTestExpectation(description: "Second decision"), + XCTestExpectation(description: "Third decision") + ] + + let featureKeys = ["feature_1", "feature_2", "feature_3"] + let user = optimizely.createUserContext(userId: kUserId) + + // Make concurrent requests + for (index, key) in featureKeys.enumerated() { + user.decideAsync(key: key) { _ in + expectations[index].fulfill() + } + } + + wait(for: expectations, timeout: 1) + } + + // MARK: - Memory Tests + + func testDecideAsync_memoryLeak() { + let expectation = XCTestExpectation(description: "Memory leak check") + weak var weakUser: OptimizelyUserContext? + + autoreleasepool { + let user = optimizely.createUserContext(userId: kUserId) + weakUser = user + + user.decideAsync(key: "feature_1") { _ in + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 1) + XCTAssertNil(weakUser, "User context should be deallocated") + } + + // MARK: - Edge Cases + + func testDecideAsync_emptyFeatureKey() { + let expectation = XCTestExpectation(description: "Empty key decision") + + let user = optimizely.createUserContext(userId: kUserId) + user.decideAsync(key: "") { decision in + XCTAssertFalse(decision.enabled) + XCTAssertEqual(decision.reasons.first, OptimizelyError.featureKeyInvalid("").reason) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } +} + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +extension OptimizelyUserContextTests_Decide_Async { + + func testDecideAsyncAwait() async { + let featureKey = "feature_2" + let variablesExpected = try! optimizely.getAllFeatureVariables( + featureKey: featureKey, + userId: kUserId + ) + + let user = optimizely.createUserContext(userId: kUserId) + let decision = await user.decideAsync(key: featureKey) + + XCTAssertEqual(decision.variationKey, "variation_with_traffic") + XCTAssertTrue(decision.enabled) + XCTAssertTrue(NSDictionary(dictionary: decision.variables.toMap()) + .isEqual(to: variablesExpected.toMap())) + XCTAssertEqual(decision.ruleKey, "exp_no_audience") + XCTAssertEqual(decision.flagKey, featureKey) + XCTAssertEqual(decision.userContext, user) + XCTAssert(decision.reasons.isEmpty) + } + + func testDecideForKeysAsyncAwait() async { + let featureKey1 = "feature_1" + let featureKey2 = "feature_2" + let featureKeys = [featureKey1, featureKey2] + + let user = optimizely.createUserContext( + userId: kUserId, + attributes: ["gender": "f"] + ) + + let decisions = await user.decideAsync(keys: featureKeys) + XCTAssertEqual(decisions.count, 2) + + XCTAssertEqual(decisions[featureKey1]?.variationKey, "a") + XCTAssertEqual(decisions[featureKey2]?.variationKey, "variation_with_traffic") + } + + func testDecideAllAsyncAwait() async { + let user = optimizely.createUserContext( + userId: kUserId, + attributes: ["gender": "f"] + ) + + let decisions = await user.decideAllAsync() + XCTAssertEqual(decisions.count, 3) + + XCTAssertEqual(decisions["feature_1"]?.variationKey, "a") + XCTAssertEqual(decisions["feature_2"]?.variationKey, "variation_with_traffic") + XCTAssertNil(decisions["feature_3"]?.variationKey) + } + + func testDecideAsyncAwait_sdkNotReady() async { + self.optimizely = OptimizelyClient(sdkKey: "12345") + let user = optimizely.createUserContext(userId: kUserId) + + let decision = await user.decideAsync(key: "feature_1") + XCTAssertNil(decision.variationKey) + XCTAssertEqual(decision.reasons.first, OptimizelyError.sdkNotReady.reason) + } +} + +fileprivate class MockCmabService: DefaultCmabService { + var variationId: String? + var error: Error? + var decisionCalled = false + var decisionCallCount = 0 + var lastRuleKey: String? + var ignoreCacheUsed = false + + init() { + super.init(cmabClient: DefaultCmabClient(), cmabCache: LruCache(size: 10, timeoutInSecs: 10)) + } + + override func getDecision(config: ProjectConfig, userContext: OptimizelyUserContext, ruleId: String, options: [OptimizelyDecideOption]) -> Result { + decisionCalled = true + decisionCallCount += 1 + lastRuleKey = ruleId + ignoreCacheUsed = options.contains(.ignoreCmabCache) + + if let error = error { + return .failure(error) + } + + if let variationId = variationId { + return .success(CmabDecision( + variationId: variationId, + cmabUUID: "test-uuid" + )) + } + + return .failure(CmabClientError.fetchFailed("No variation set")) + } +} + diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift new file mode 100644 index 00000000..8e46eb03 --- /dev/null +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift @@ -0,0 +1,287 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class OptimizelyUserContextTests_Decide_CMAB: XCTestCase { + + let kUserId = "tester" + + var optimizely: OptimizelyClient! + var config: ProjectConfig! + var eventDispatcher = MockEventDispatcher() + var decisionService: DefaultDecisionService! + fileprivate var mockCmabService: MockCmabService! + + override func setUp() { + super.setUp() + + let datafile = OTUtils.loadJSONDatafile("decide_datafile")! + mockCmabService = MockCmabService() + decisionService = DefaultDecisionService(userProfileService: DefaultUserProfileService(), cmabService: mockCmabService) + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, + eventDispatcher: eventDispatcher, + userProfileService: OTUtils.createClearUserProfileService()) + optimizely.decisionService = decisionService + self.config = self.optimizely.config + try! optimizely.start(datafile: datafile) + } + + override func tearDown() { + optimizely = nil + mockCmabService = nil + decisionService = nil + } + + func testDecideAsync_withCmabExperiment() { + let expectation = XCTestExpectation(description: "CMAB decision completed") + // Set up the CMAB experiment + let cmab: Cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + var experiments = optimizely.config!.project.experiments + experiments[0].cmab = cmab + optimizely.config?.project.experiments = experiments + mockCmabService.variationId = "10389729780" // corresponds to variation "a" + + // Create user with attributes that match CMAB experiment + let user = optimizely.createUserContext( + userId: kUserId, + attributes: ["gender": "f", "age": 25] + ) + + // Run the decision + user.decideAsync(key: "feature_1") { decision in + // Verify decision + XCTAssertEqual(decision.variationKey, "a", "Expected variation key 'a' but got \(String(describing: decision.variationKey))") + XCTAssertTrue(decision.enabled, "Expected feature to be enabled but was disabled") + XCTAssertEqual(decision.ruleKey, "exp_with_audience", "Expected rule id 'exp_with_audience' but got \(String(describing: decision.ruleKey))") + + // Verify CMAB service was called + XCTAssertTrue(self.mockCmabService.decisionCalled, "CMAB decision service was not called") + XCTAssertEqual(self.mockCmabService.lastRuleId, "10390977673", "Expected CMAB rule id '10390977673' but got \(String(describing: self.mockCmabService.lastRuleId))") + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5) // Increased timeout for reliability + } + + func testDecideAsync_multipleCmabExperiments() { + let expectation = XCTestExpectation(description: "CMAB decision completed") + + // Set up multiple CMAB experiments + // First experiment with zero traffic allocation - user won't be bucketed into this experiment + let cmab1: Cmab = try! OTUtils.model(from: ["trafficAllocation": 0, "attributeIds": ["10389729780"]]) + // Second experiment with full traffic allocation - user should be bucketed into this experiment + let cmab2: Cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10418551353"]]) + + // Update project configuration with CMAB experiments + var experiments = optimizely.config!.project.experiments + experiments[0].cmab = cmab1 + experiments[1].cmab = cmab2 + optimizely.config?.project.experiments = experiments + + // Configure mock CMAB service to return specific variation + mockCmabService.variationId = "10418551353" // corresponds to variation "a" + + // Define feature keys to test with + let featureKey1 = "feature_1" + let featureKey2 = "feature_2" + let featureKeys = [featureKey1, featureKey2] + + // Pre-fetch expected variable values for validation + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) + + // Create test user context with attributes + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + + // Test multiple decisions with decideAsync + user.decideAsync(keys: featureKeys, options: [.ignoreUserProfileService]) { decisions in + + // Verify correct number of decisions were returned + XCTAssertEqual(decisions.count, 2) + + // Verify CMAB service was called the correct number of times + XCTAssertEqual(self.mockCmabService.decisionCallCount, 1) + + // Verify first feature decision matches expected values + XCTAssertNotNil(decisions[featureKey1]) + XCTAssertEqual(decisions[featureKey1]!, OptimizelyDecision( + variationKey: "18257766532", + enabled: true, + variables: variablesExpected1, + ruleKey: "18322080788", + flagKey: featureKey1, + userContext: user, + reasons: [] + )) + + // Verify second feature decision matches expected values + XCTAssertNotNil(decisions[featureKey2]) + XCTAssertEqual(decisions[featureKey2]!, OptimizelyDecision( + variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected2, + ruleKey: "exp_no_audience", + flagKey: featureKey2, + userContext: user, + reasons: [] + )) + + // Verify CMAB service was correctly called with the expected parameters + XCTAssertTrue(self.mockCmabService.decisionCalled, "CMAB decision service was not called") + XCTAssertEqual(self.mockCmabService.lastRuleId, "10420810910", "Expected CMAB rule id '10390977673' but got \(String(describing: self.mockCmabService.lastRuleId))") + + expectation.fulfill() + } + + // Wait for async operations to complete + wait(for: [expectation], timeout: 5) // Increased timeout for reliability + } + + func testDecideAsync_cmabWithUserProfileCahing() { + let expectation1 = XCTestExpectation(description: "First CMAB decision") + let expectation2 = XCTestExpectation(description: "Second CMAB decision") + + // Set up the CMAB experiment + let cmab: Cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + var experiments = optimizely.config!.project.experiments + experiments[0].cmab = cmab + optimizely.config?.project.experiments = experiments + mockCmabService.variationId = "10389729780" // corresponds to variation "a" + + // Create user with attributes that match CMAB experiment + let user = optimizely.createUserContext( + userId: kUserId, + attributes: ["gender": "f", "age": 25] + ) + + // First decision cache into user profile + user.decideAsync(key: "feature_1") { decision in + XCTAssertEqual(decision.variationKey, "a") + XCTAssertEqual(self.mockCmabService.decisionCallCount, 1) + expectation1.fulfill() + + // Second decision (should use cache) + user.decideAsync(key: "feature_1") { decision in + XCTAssertEqual(decision.variationKey, "a") + // Call count should still be 1 (cached) + XCTAssertEqual(self.mockCmabService.decisionCallCount, 1) + expectation2.fulfill() + } + } + + wait(for: [expectation1, expectation2], timeout: 1) + } + + func testDecideAsync_cmabCacheOptions() { + let exp1 = XCTestExpectation(description: "First call") + let exp2 = XCTestExpectation(description: "Second call") + let exp3 = XCTestExpectation(description: "Third call") + + + // Set up the CMAB experiment + let cmab: Cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + var experiments = optimizely.config!.project.experiments + experiments[0].cmab = cmab + optimizely.config?.project.experiments = experiments + mockCmabService.variationId = "10389729780" // corresponds to variation "a" + + // Create user with attributes that match CMAB experiment + let user = optimizely.createUserContext( + userId: kUserId, + attributes: ["gender": "f", "age": 25] + ) + user.decideAsync(key: "feature_1", options: [.ignoreUserProfileService, .ignoreCmabCache]) { decision in + XCTAssertEqual(decision.variationKey, "a") + XCTAssertTrue(self.mockCmabService.ignoreCacheUsed) + exp1.fulfill() + } + user.decideAsync(key: "feature_1", options: [.ignoreUserProfileService, .resetCmabCache]) { decision in + XCTAssertEqual(decision.variationKey, "a") + XCTAssertTrue(self.mockCmabService.resetCacheCache) + exp2.fulfill() + } + user.decideAsync(key: "feature_1", options: [.ignoreUserProfileService, .invalidateUserCmabCache]) { decision in + XCTAssertEqual(decision.variationKey, "a") + XCTAssertTrue(self.mockCmabService.invalidateUserCmabCache) + exp3.fulfill() + } + wait(for: [exp1, exp2, exp3], timeout: 1) + + } + + func testDecideAsync_cmabError() { + let expectation = XCTestExpectation(description: "CMAB error handling") + // Set up the CMAB experiment + let cmab: Cmab = try! OTUtils.model(from: ["trafficAllocation": 10000, "attributeIds": ["10389729780"]]) + var experiments = optimizely.config!.project.experiments + experiments[0].cmab = cmab + optimizely.config?.project.experiments = experiments + mockCmabService.variationId = "10389729780" // corresponds to variation "a" + mockCmabService.error = CmabClientError.fetchFailed("Test error") + + // Create user with attributes that match CMAB experiment + let user = optimizely.createUserContext( + userId: kUserId, + attributes: ["gender": "f", "age": 25] + ) + + user.decideAsync(key: "feature_1", options: [.ignoreUserProfileService, .includeReasons]) { decision in + XCTAssertTrue(decision.reasons.contains(LogMessage.cmabFetchFailed("exp_with_audience").reason)) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + +} + +fileprivate class MockCmabService: DefaultCmabService { + var variationId: String? + var error: Error? + var decisionCalled = false + var decisionCallCount = 0 + var lastRuleId: String? + var ignoreCacheUsed = false + var resetCacheCache = false + var invalidateUserCmabCache = false + + init() { + super.init(cmabClient: DefaultCmabClient(), cmabCache: LruCache(size: 10, timeoutInSecs: 10)) + } + + override func getDecision(config: ProjectConfig, userContext: OptimizelyUserContext, ruleId: String, options: [OptimizelyDecideOption]) -> Result { + decisionCalled = true + lastRuleId = ruleId + ignoreCacheUsed = options.contains(.ignoreCmabCache) + resetCacheCache = options.contains(.resetCmabCache) + invalidateUserCmabCache = options.contains(.invalidateUserCmabCache) + decisionCallCount += 1 + if let error = error { + return .failure(error) + } + + if let variationId = variationId { + return .success(CmabDecision( + variationId: variationId, + cmabUUID: "test-uuid" + )) + } + + return .failure(CmabClientError.fetchFailed("No variation set")) + } +} From 765206b5d806fc2a46f45e413ed71d4c4d94b95a Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:55:40 +0600 Subject: [PATCH 35/37] [FSSDK-11181] chore: add cmab uuid into impression event meta (#603) --- .../DispatchEvents/BatchEvent.swift | 2 + .../DefaultDecisionService.swift | 3 +- .../Events/BatchEventBuilder.swift | 5 +- .../OptimizelyClient+Decide.swift | 3 +- Sources/Optimizely/OptimizelyClient.swift | 12 +++-- .../OptimizelyClientTests_Others.swift | 4 +- .../BatchEventBuilderTests_Events.swift | 7 ++- ...timizelyUserContextTests_Decide_CMAB.swift | 51 +++++++++++++++++++ 8 files changed, 75 insertions(+), 12 deletions(-) diff --git a/Sources/Data Model/DispatchEvents/BatchEvent.swift b/Sources/Data Model/DispatchEvents/BatchEvent.swift index 9a74a455..345c7b85 100644 --- a/Sources/Data Model/DispatchEvents/BatchEvent.swift +++ b/Sources/Data Model/DispatchEvents/BatchEvent.swift @@ -85,6 +85,7 @@ struct DecisionMetadata: Codable, Equatable { let flagKey: String let variationKey: String let enabled: Bool + var cmabUUID: String? enum CodingKeys: String, CodingKey { case ruleType = "rule_type" @@ -92,6 +93,7 @@ struct DecisionMetadata: Codable, Equatable { case flagKey = "flag_key" case variationKey = "variation_key" case enabled = "enabled" + case cmabUUID = "cmab_uuid" } } diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 1fd9343d..145702f0 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -400,6 +400,7 @@ class DefaultDecisionService: OPTDecisionService { } let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker, isAsync: isAsync, options: options) + reasons.merge(flagExpDecision.reasons) if let decision = flagExpDecision.result { @@ -459,7 +460,7 @@ class DefaultDecisionService: OPTDecisionService { let featureDecision = FeatureDecision(experiment: experiment, variation: nil, source: Constants.DecisionSource.featureTest.rawValue) return DecisionResponse(result: featureDecision, reasons: reasons) } else if let variation = result.variation { - let featureDecision = FeatureDecision(experiment: experiment, variation: variation, source: Constants.DecisionSource.featureTest.rawValue) + let featureDecision = FeatureDecision(experiment: experiment, variation: variation, source: Constants.DecisionSource.featureTest.rawValue, cmabUUID: result.cmabUUID) return DecisionResponse(result: featureDecision, reasons: reasons) } } diff --git a/Sources/Implementation/Events/BatchEventBuilder.swift b/Sources/Implementation/Events/BatchEventBuilder.swift index 4dbd0961..4027b032 100644 --- a/Sources/Implementation/Events/BatchEventBuilder.swift +++ b/Sources/Implementation/Events/BatchEventBuilder.swift @@ -28,9 +28,10 @@ class BatchEventBuilder { attributes: OptimizelyAttributes?, flagKey: String, ruleType: String, - enabled: Bool) -> Data? { + enabled: Bool, + cmabUUID: String?) -> Data? { - let metaData = DecisionMetadata(ruleType: ruleType, ruleKey: experiment?.key ?? "", flagKey: flagKey, variationKey: variation?.key ?? "", enabled: enabled) + let metaData = DecisionMetadata(ruleType: ruleType, ruleKey: experiment?.key ?? "", flagKey: flagKey, variationKey: variation?.key ?? "", enabled: enabled, cmabUUID: cmabUUID) let decision = Decision(variationID: variation?.id ?? "", campaignID: experiment?.layerId ?? "", diff --git a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift index 06b1b45a..a75eaf11 100644 --- a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift +++ b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift @@ -314,7 +314,8 @@ extension OptimizelyClient { attributes: attributes, flagKey: feature.key, ruleType: ruleType, - enabled: flagEnabled) + enabled: flagEnabled, + cmabUUID: flagDecision?.cmabUUID) decisionEventDispatched = true } } diff --git a/Sources/Optimizely/OptimizelyClient.swift b/Sources/Optimizely/OptimizelyClient.swift index 092691f3..8dcf525f 100644 --- a/Sources/Optimizely/OptimizelyClient.swift +++ b/Sources/Optimizely/OptimizelyClient.swift @@ -318,7 +318,8 @@ open class OptimizelyClient: NSObject { attributes: attributes, flagKey: "", ruleType: Constants.DecisionSource.experiment.rawValue, - enabled: true) + enabled: true, + cmabUUID: nil) return variation.key } @@ -452,7 +453,8 @@ open class OptimizelyClient: NSObject { attributes: attributes, flagKey: featureKey, ruleType: source, - enabled: featureEnabled) + enabled: featureEnabled, + cmabUUID: pair?.cmabUUID) } sendDecisionNotification(userId: userId, @@ -817,7 +819,8 @@ extension OptimizelyClient { attributes: OptimizelyAttributes? = nil, flagKey: String, ruleType: String, - enabled: Bool) { + enabled: Bool, + cmabUUID: String?) { // non-blocking (event data serialization takes time) eventLock.async { @@ -830,7 +833,8 @@ extension OptimizelyClient { attributes: attributes, flagKey: flagKey, ruleType: ruleType, - enabled: enabled) else { + enabled: enabled, + cmabUUID: cmabUUID) else { self.logger.e(OptimizelyError.eventBuildFailure(DispatchEvent.activateEventKey)) return } diff --git a/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Others.swift b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Others.swift index ff859314..6f8d84f9 100644 --- a/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Others.swift +++ b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Others.swift @@ -289,7 +289,7 @@ class OptimizelyClientTests_Others: XCTestCase { // set invalid (infinity) to attribute values, which will cause JSONEncoder.encode exception let attributes = ["testvar": Double.infinity] - optimizely.sendImpressionEvent(experiment: experiment, variation: variation, userId: kUserId, attributes: attributes, flagKey: "", ruleType: Constants.DecisionSource.rollout.rawValue, enabled: true) + optimizely.sendImpressionEvent(experiment: experiment, variation: variation, userId: kUserId, attributes: attributes, flagKey: "", ruleType: Constants.DecisionSource.rollout.rawValue, enabled: true, cmabUUID: nil) XCTAssert(eventDispatcher.events.count == 0) } @@ -321,7 +321,7 @@ class OptimizelyClientTests_Others: XCTestCase { // force condition for sdk-not-ready optimizely.config = nil - optimizely.sendImpressionEvent(experiment: experiment, variation: variation, userId: kUserId, flagKey: experiment.key, ruleType: Constants.DecisionSource.rollout.rawValue, enabled: true) + optimizely.sendImpressionEvent(experiment: experiment, variation: variation, userId: kUserId, flagKey: experiment.key, ruleType: Constants.DecisionSource.rollout.rawValue, enabled: true, cmabUUID: nil) XCTAssert(eventDispatcher.events.isEmpty, "event should not be sent out sdk is not configured properly") optimizely.sendConversionEvent(eventKey: kEventKey, userId: kUserId) diff --git a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift index 516f9ea3..d46fa280 100644 --- a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift +++ b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift @@ -111,6 +111,7 @@ class BatchEventBuilderTests_Events: XCTestCase { XCTAssertEqual(metaData["rule_key"] as! String, "ab_running_exp_audience_combo_exact_foo_or_true__and__42_or_4_2") XCTAssertEqual(metaData["flag_key"] as! String, "") XCTAssertEqual(metaData["variation_key"] as! String, "all_traffic_variation") + XCTAssertNil(metaData["cmab_uuid"]) XCTAssertTrue(metaData["enabled"] as! Bool) let de = (snapshot["events"] as! Array>)[0] @@ -212,7 +213,7 @@ class BatchEventBuilderTests_Events: XCTestCase { let experiment = optimizely.config?.getExperiment(id: "10390977714") optimizely.config?.project.sendFlagDecisions = true - let event = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, experiment: experiment!, variation: nil, userId: userId, attributes: attributes, flagKey: experiment!.key, ruleType: Constants.DecisionSource.featureTest.rawValue, enabled: false) + let event = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, experiment: experiment!, variation: nil, userId: userId, attributes: attributes, flagKey: experiment!.key, ruleType: Constants.DecisionSource.featureTest.rawValue, enabled: false, cmabUUID: "cmab_uuid_124") XCTAssertNotNil(event) let visitor = (getEventJSON(data: event!)!["visitors"] as! Array>)[0] @@ -224,6 +225,7 @@ class BatchEventBuilderTests_Events: XCTestCase { XCTAssertEqual(metaData["rule_key"] as! String, "ab_running_exp_audience_combo_exact_foo_or_true__and__42_or_4_2") XCTAssertEqual(metaData["flag_key"] as! String, "ab_running_exp_audience_combo_exact_foo_or_true__and__42_or_4_2") XCTAssertEqual(metaData["variation_key"] as! String, "") + XCTAssertEqual(metaData["cmab_uuid"] as! String, "cmab_uuid_124") XCTAssertFalse(metaData["enabled"] as! Bool) optimizely.config?.project.sendFlagDecisions = nil } @@ -231,7 +233,7 @@ class BatchEventBuilderTests_Events: XCTestCase { func testCreateImpressionEventWithoutExperimentAndVariation() { optimizely.config?.project.sendFlagDecisions = true - let event = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, experiment: nil, variation: nil, userId: userId, attributes: [String: Any](), flagKey: "feature_1", ruleType: Constants.DecisionSource.rollout.rawValue, enabled: true) + let event = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, experiment: nil, variation: nil, userId: userId, attributes: [String: Any](), flagKey: "feature_1", ruleType: Constants.DecisionSource.rollout.rawValue, enabled: true, cmabUUID: nil) XCTAssertNotNil(event) let visitor = (getEventJSON(data: event!)!["visitors"] as! Array>)[0] @@ -243,6 +245,7 @@ class BatchEventBuilderTests_Events: XCTestCase { XCTAssertEqual(metaData["rule_key"] as! String, "") XCTAssertEqual(metaData["flag_key"] as! String, "feature_1") XCTAssertEqual(metaData["variation_key"] as! String, "") + XCTAssertEqual(metaData["cmab_uuid"] as? String, nil) XCTAssertTrue(metaData["enabled"] as! Bool) optimizely.config?.project.sendFlagDecisions = nil } diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift index 8e46eb03..e9046953 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift @@ -72,7 +72,30 @@ class OptimizelyUserContextTests_Decide_CMAB: XCTestCase { XCTAssertTrue(self.mockCmabService.decisionCalled, "CMAB decision service was not called") XCTAssertEqual(self.mockCmabService.lastRuleId, "10390977673", "Expected CMAB rule id '10390977673' but got \(String(describing: self.mockCmabService.lastRuleId))") + // Verify impression event + self.optimizely.eventLock.sync {} + + guard let event = self.getFirstEventJSON(client: self.optimizely) else { + XCTFail("No impression event found") + expectation.fulfill() + return + } + + let visitor = (event["visitors"] as! Array>)[0] + let snapshot = (visitor["snapshots"] as! Array>)[0] + let decision = (snapshot["decisions"] as! Array>)[0] + let metaData = decision["metadata"] as! Dictionary + + // Verify event metadata + XCTAssertEqual(metaData["rule_type"] as! String, Constants.DecisionSource.featureTest.rawValue) + XCTAssertEqual(metaData["rule_key"] as! String, "exp_with_audience") + XCTAssertEqual(metaData["flag_key"] as! String, "feature_1") + XCTAssertEqual(metaData["variation_key"] as! String, "a") + XCTAssertEqual(metaData["cmab_uuid"] as? String, "test-uuid") + XCTAssertTrue(metaData["enabled"] as! Bool) + expectation.fulfill() + } wait(for: [expectation], timeout: 5) // Increased timeout for reliability @@ -285,3 +308,31 @@ fileprivate class MockCmabService: DefaultCmabService { return .failure(CmabClientError.fetchFailed("No variation set")) } } + +extension OptimizelyUserContextTests_Decide_CMAB { + + func getFirstEvent(dispatcher: MockEventDispatcher) -> EventForDispatch? { + optimizely.eventLock.sync{} + return dispatcher.events.first + } + + func getFirstEventJSON(dispatcher: MockEventDispatcher) -> [String: Any]? { + guard let event = getFirstEvent(dispatcher: dispatcher) else { return nil } + + let json = try! JSONSerialization.jsonObject(with: event.body, options: .allowFragments) as! [String: Any] + return json + } + + func getFirstEventJSON(client: OptimizelyClient) -> [String: Any]? { + guard let event = getFirstEvent(dispatcher: client.eventDispatcher as! MockEventDispatcher) else { return nil } + + let json = try! JSONSerialization.jsonObject(with: event.body, options: .allowFragments) as! [String: Any] + return json + } + + func getEventJSON(data: Data) -> [String: Any]? { + let json = try! JSONSerialization.jsonObject(with: data, options: .allowFragments) as! [String: Any] + return json + } + +} From 6fd56c31b5da337482db9d3928d25649b0ad5f2a Mon Sep 17 00:00:00 2001 From: Muzahidul Islam <129880873+muzahidul-opti@users.noreply.github.com> Date: Tue, 15 Jul 2025 23:30:24 +0600 Subject: [PATCH 36/37] [FSSDK-11690] chore: remove layer id from holdout (#604) * Remove layer id from holdout --- Sources/Data Model/Holdout.swift | 7 +++---- .../BatchEventBuilderTests_Events.swift | 1 - .../DecisionServiceTests_Holdouts.swift | 4 ---- ...imizelyUserContextTests_Decide_Holdouts.swift | 1 - ...ntextTests_Decide_With_Holdouts_Reasons.swift | 1 - .../OptimizelyTests-DataModel/HoldoutTests.swift | 16 ---------------- 6 files changed, 3 insertions(+), 27 deletions(-) diff --git a/Sources/Data Model/Holdout.swift b/Sources/Data Model/Holdout.swift index 8a230bc9..2b8ce6af 100644 --- a/Sources/Data Model/Holdout.swift +++ b/Sources/Data Model/Holdout.swift @@ -27,7 +27,6 @@ struct Holdout: Codable, ExperimentCore { var id: String var key: String var status: Status - var layerId: String var variations: [Variation] var trafficAllocation: [TrafficAllocation] var audienceIds: [String] @@ -36,12 +35,14 @@ struct Holdout: Codable, ExperimentCore { var excludedFlags: [String] enum CodingKeys: String, CodingKey { - case id, key, status, layerId, variations, trafficAllocation, audienceIds, audienceConditions, includedFlags, excludedFlags + case id, key, status, variations, trafficAllocation, audienceIds, audienceConditions, includedFlags, excludedFlags } var variationsMap: [String: OptimizelyVariation] = [:] // replace with serialized string representation with audience names when ProjectConfig is ready var audiences: String = "" + // Not necessary for HO + var layerId: String = "" init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -49,7 +50,6 @@ struct Holdout: Codable, ExperimentCore { id = try container.decode(String.self, forKey: .id) key = try container.decode(String.self, forKey: .key) status = try container.decode(Status.self, forKey: .status) - layerId = try container.decode(String.self, forKey: .layerId) variations = try container.decode([Variation].self, forKey: .variations) trafficAllocation = try container.decode([TrafficAllocation].self, forKey: .trafficAllocation) audienceIds = try container.decode([String].self, forKey: .audienceIds) @@ -65,7 +65,6 @@ extension Holdout: Equatable { return lhs.id == rhs.id && lhs.key == rhs.key && lhs.status == rhs.status && - lhs.layerId == rhs.layerId && lhs.variations == rhs.variations && lhs.trafficAllocation == rhs.trafficAllocation && lhs.audienceIds == rhs.audienceIds && diff --git a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift index d46fa280..2d743f2d 100644 --- a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift +++ b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift @@ -32,7 +32,6 @@ class BatchEventBuilderTests_Events: XCTestCase { "status": "Running", "id": "holdout_4444444", "key": "holdout_key", - "layerId": "10420273888", "trafficAllocation": [ ["entityId": "holdout_variation_a11", "endOfRange": 10000] // 100% traffic allocation ], diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift index f7476354..2cc96c98 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift @@ -111,7 +111,6 @@ class DecisionServiceTests_Holdouts: XCTestCase { "status": "Running", "id": "holdout_4444444", "key": "holdout_key", - "layerId": "10420273888", "trafficAllocation": [ ["entityId": "holdout_variation_a11", "endOfRange": 1000] // 10% traffic allocation ], @@ -133,7 +132,6 @@ class DecisionServiceTests_Holdouts: XCTestCase { "status": "Running", "id": "holdout_global", "key": "holdout_global", - "layerId": "10420273888", "trafficAllocation": [ ["entityId": "holdout_global_variation", "endOfRange": 500] ], @@ -155,7 +153,6 @@ class DecisionServiceTests_Holdouts: XCTestCase { "status": "Running", "id": "holdout_included", "key": "holdout_included", - "layerId": "10420273889", "trafficAllocation": [ ["entityId": "holdout_included_variation", "endOfRange": 1000] ], @@ -177,7 +174,6 @@ class DecisionServiceTests_Holdouts: XCTestCase { "status": "Running", "id": "holdout_excluded", "key": "holdout_excluded", - "layerId": "10420273890", "trafficAllocation": [ ["entityId": "holdout_excluded_variation", "endOfRange": 1000] ], diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift index 7f2228e8..edb12f60 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift @@ -29,7 +29,6 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { "status": "Running", "id": "id_holdout", "key": "key_holdout", - "layerId": "10420273888", "trafficAllocation": [ ["entityId": "id_holdout_variation", "endOfRange": 500] ], diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift index ff93122f..b4b8e0e3 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift @@ -28,7 +28,6 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { "status": "Running", "id": "id_holdout", "key": "key_holdout", - "layerId": "10420273888", "trafficAllocation": [ ["entityId": "id_holdout_variation", "endOfRange": 500] ], diff --git a/Tests/OptimizelyTests-DataModel/HoldoutTests.swift b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift index df815ce1..da01277f 100644 --- a/Tests/OptimizelyTests-DataModel/HoldoutTests.swift +++ b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift @@ -35,7 +35,6 @@ class HoldoutTests: XCTestCase { static var sampleData: [String: Any] = ["id": "11111", "key": "background", "status": "Running", - "layerId": "22222", "variations": [HoldoutTests.variationData], "trafficAllocation": [HoldoutTests.trafficAllocationData], "audienceIds": ["33333"], @@ -44,7 +43,6 @@ class HoldoutTests: XCTestCase { static var sampleDataWithIncludedFlags: [String: Any] = ["id": "55555", "key": "background", "status": "Running", - "layerId": "22222", "variations": [HoldoutTests.variationData], "trafficAllocation": [HoldoutTests.trafficAllocationData], "audienceIds": ["33333"], @@ -54,7 +52,6 @@ class HoldoutTests: XCTestCase { static var sampleDataWithExcludedFlags: [String: Any] = ["id": "3333", "key": "background", "status": "Running", - "layerId": "22222", "variations": [HoldoutTests.variationData], "trafficAllocation": [HoldoutTests.trafficAllocationData], "audienceIds": ["33333"], @@ -76,7 +73,6 @@ extension HoldoutTests { XCTAssert(model.id == "11111") XCTAssert(model.key == "background") XCTAssert(model.status == .running) - XCTAssert(model.layerId == "22222") XCTAssert(model.variations == [try! OTUtils.model(from: HoldoutTests.variationData)]) XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: HoldoutTests.trafficAllocationData)]) XCTAssert(model.audienceIds == ["33333"]) @@ -91,7 +87,6 @@ extension HoldoutTests { XCTAssert(model.id == "55555") XCTAssert(model.key == "background") XCTAssert(model.status == .running) - XCTAssert(model.layerId == "22222") XCTAssert(model.variations == [try! OTUtils.model(from: HoldoutTests.variationData)]) XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: HoldoutTests.trafficAllocationData)]) XCTAssert(model.audienceIds == ["33333"]) @@ -107,7 +102,6 @@ extension HoldoutTests { XCTAssert(model.id == "3333") XCTAssert(model.key == "background") XCTAssert(model.status == .running) - XCTAssert(model.layerId == "22222") XCTAssert(model.variations == [try! OTUtils.model(from: HoldoutTests.variationData)]) XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: HoldoutTests.trafficAllocationData)]) XCTAssert(model.audienceIds == ["33333"]) @@ -126,7 +120,6 @@ extension HoldoutTests { XCTAssert(model.id == "11111") XCTAssert(model.key == "background") XCTAssert(model.status == .running) - XCTAssert(model.layerId == "22222") XCTAssert(model.variations == [try! OTUtils.model(from: HoldoutTests.variationData)]) XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: HoldoutTests.trafficAllocationData)]) XCTAssert(model.audienceIds == ["33333"]) @@ -156,14 +149,6 @@ extension HoldoutTests { XCTAssertNil(model) } - func testDecodeFailWithMissingLayerId() { - var data: [String: Any] = HoldoutTests.sampleData - data["layerId"] = nil - - let model: Holdout? = try? OTUtils.model(from: data) - XCTAssertNil(model) - } - func testDecodeFailWithMissingVariations() { var data: [String: Any] = HoldoutTests.sampleData data["variations"] = nil @@ -203,7 +188,6 @@ extension HoldoutTests { let commonData: [String: Any] = ["id": "11111", "key": "background", "status": "Running", - "layerId": "22222", "variations": [HoldoutTests.variationData], "trafficAllocation": [HoldoutTests.trafficAllocationData], "audienceIds": [], From d772ee2ac46060607a26bddb25e4e039a2409118 Mon Sep 17 00:00:00 2001 From: esrakartalOpt <102107327+esrakartalOpt@users.noreply.github.com> Date: Tue, 2 Sep 2025 08:52:49 -0500 Subject: [PATCH 37/37] [FSSDK-11454] Swift - Add SDK Multi-Region Support for Data Hosting (#606) * [FSSDK-11454] Swift - Add SDK Multi-Region Support for Data Hosting * Fix test errors * Fix errors * Fix missing arg error * Implement copilot reviews * Fix errors * Fix region error * feat(EventForDispatch): add Objective-C initializer * Add test cases for invalid region * Implemented comments * Remove unused region arg * Implement suggested cleanup and change * Remove duplicate method * Remove unnecessary getEndpoint method --------- Co-authored-by: muzahidul-opti --- .../DispatchEvents/BatchEvent.swift | 2 + .../DispatchEvents/EventForDispatch.swift | 6 +- Sources/Data Model/Project.swift | 16 +- Sources/Data Model/ProjectConfig.swift | 7 + .../ArrayEventForDispatch+Extension.swift | 18 +- .../Events/BatchEventBuilder.swift | 9 +- .../OptimizelyUserContext+ObjC.swift | 2 +- .../Optimizely/OptimizelyClient+ObjC.swift | 7 + .../EventDispatcherTests_Batch.swift | 3 +- .../BatchEventBuilderTests_Region.swift | 396 ++++++++++++++++++ Tests/TestUtils/OTUtils.swift | 3 +- 11 files changed, 456 insertions(+), 13 deletions(-) create mode 100644 Tests/OptimizelyTests-Common/BatchEventBuilderTests_Region.swift diff --git a/Sources/Data Model/DispatchEvents/BatchEvent.swift b/Sources/Data Model/DispatchEvents/BatchEvent.swift index 345c7b85..5c7a26f9 100644 --- a/Sources/Data Model/DispatchEvents/BatchEvent.swift +++ b/Sources/Data Model/DispatchEvents/BatchEvent.swift @@ -25,6 +25,7 @@ struct BatchEvent: Codable, Equatable { let clientName: String let anonymizeIP: Bool let enrichDecisions: Bool + let region: String enum CodingKeys: String, CodingKey { case revision @@ -35,6 +36,7 @@ struct BatchEvent: Codable, Equatable { case clientName = "client_name" case anonymizeIP = "anonymize_ip" case enrichDecisions = "enrich_decisions" + case region } func getEventAttribute(key: String) -> EventAttribute? { diff --git a/Sources/Data Model/DispatchEvents/EventForDispatch.swift b/Sources/Data Model/DispatchEvents/EventForDispatch.swift index 88cf5d83..966e1f33 100644 --- a/Sources/Data Model/DispatchEvents/EventForDispatch.swift +++ b/Sources/Data Model/DispatchEvents/EventForDispatch.swift @@ -18,12 +18,14 @@ import Foundation @objcMembers public class EventForDispatch: NSObject, Codable { public static var eventEndpoint = "https://logx.optimizely.com/v1/events" + public static var euEventEndpoint = "https://eu.logx.optimizely.com/v1/events" public let url: URL public let body: Data - public init(url: URL? = nil, body: Data) { - self.url = url ?? URL(string: EventForDispatch.eventEndpoint)! + public init(url: URL? = nil, body: Data, region: Region = .US) { + let endpoint = url?.absoluteString ?? (region == .US ? Self.eventEndpoint : Self.euEventEndpoint) + self.url = URL(string: endpoint)! self.body = body } diff --git a/Sources/Data Model/Project.swift b/Sources/Data Model/Project.swift index 3a518c25..95d58248 100644 --- a/Sources/Data Model/Project.swift +++ b/Sources/Data Model/Project.swift @@ -16,6 +16,12 @@ import Foundation +/// Optimizely region identifiers +public enum Region: String, Codable, Equatable { + case US + case EU +} + protocol ProjectProtocol { func evaluateAudience(audienceId: String, user: OptimizelyUserContext) throws -> Bool } @@ -48,6 +54,8 @@ struct Project: Codable, Equatable { var environmentKey: String? // Holdouts var holdouts: [Holdout] + // Region + var region: Region? let logger = OPTLoggerFactory.getLogger() // Required since logger is not decodable @@ -57,7 +65,7 @@ struct Project: Codable, Equatable { // V3 case anonymizeIP // V4 - case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey, holdouts + case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey, holdouts, region } init(from decoder: Decoder) throws { @@ -88,6 +96,8 @@ struct Project: Codable, Equatable { environmentKey = try container.decodeIfPresent(String.self, forKey: .environmentKey) // Holdouts - defaults to empty array if key is not present holdouts = try container.decodeIfPresent([Holdout].self, forKey: .holdouts) ?? [] + // Region - defaults to US if not present + region = try container.decodeIfPresent(Region.self, forKey: .region) } // Required since logger is not equatable @@ -97,7 +107,9 @@ struct Project: Codable, Equatable { lhs.accountId == rhs.accountId && lhs.events == rhs.events && lhs.revision == rhs.revision && lhs.anonymizeIP == rhs.anonymizeIP && lhs.rollouts == rhs.rollouts && lhs.integrations == rhs.integrations && lhs.typedAudiences == rhs.typedAudiences && - lhs.featureFlags == rhs.featureFlags && lhs.botFiltering == rhs.botFiltering && lhs.sendFlagDecisions == rhs.sendFlagDecisions && lhs.sdkKey == rhs.sdkKey && lhs.environmentKey == rhs.environmentKey + lhs.featureFlags == rhs.featureFlags && lhs.botFiltering == rhs.botFiltering && + lhs.sendFlagDecisions == rhs.sendFlagDecisions && lhs.sdkKey == rhs.sdkKey && + lhs.environmentKey == rhs.environmentKey && lhs.region == rhs.region } } diff --git a/Sources/Data Model/ProjectConfig.swift b/Sources/Data Model/ProjectConfig.swift index a2cd3bf2..be2c71d6 100644 --- a/Sources/Data Model/ProjectConfig.swift +++ b/Sources/Data Model/ProjectConfig.swift @@ -215,6 +215,13 @@ extension ProjectConfig { extension ProjectConfig { + /** + * Get the region value. Defaults to US if not specified in the project. + */ + public var region: Region { + return project.region ?? .US + } + /** * Get sendFlagDecisions value. */ diff --git a/Sources/Extensions/ArrayEventForDispatch+Extension.swift b/Sources/Extensions/ArrayEventForDispatch+Extension.swift index 5d8e5d28..17855f22 100644 --- a/Sources/Extensions/ArrayEventForDispatch+Extension.swift +++ b/Sources/Extensions/ArrayEventForDispatch+Extension.swift @@ -45,6 +45,7 @@ extension Array where Element == EventForDispatch { var url: URL? var projectId: String? var revision: String? + var region: String? let checkUrl = { (event: EventForDispatch) -> Bool in if url == nil { @@ -69,10 +70,18 @@ extension Array where Element == EventForDispatch { } return revision == batchEvent.revision } + + let checkRegion = { (batchEvent: BatchEvent) -> Bool in + if region == nil { + region = batchEvent.region + return region != nil + } + return region == batchEvent.region + } for event in self { if let batchEvent = try? JSONDecoder().decode(BatchEvent.self, from: event.body) { - if !checkUrl(event) || !checkProjectId(batchEvent) || !checkRevision(batchEvent) { + if !checkUrl(event) || !checkProjectId(batchEvent) || !checkRevision(batchEvent) || !checkRegion(batchEvent) { break } @@ -101,12 +110,13 @@ extension Array where Element == EventForDispatch { projectID: base.projectID, clientName: base.clientName, anonymizeIP: base.anonymizeIP, - enrichDecisions: true) + enrichDecisions: true, + region: base.region) guard let data = try? JSONEncoder().encode(batchEvent) else { return nil } - - return EventForDispatch(url: url, body: data) + + return EventForDispatch(url: url, body: data, region: Region(rawValue: base.region) ?? .US) } } diff --git a/Sources/Implementation/Events/BatchEventBuilder.swift b/Sources/Implementation/Events/BatchEventBuilder.swift index 4027b032..57db1dcb 100644 --- a/Sources/Implementation/Events/BatchEventBuilder.swift +++ b/Sources/Implementation/Events/BatchEventBuilder.swift @@ -87,6 +87,7 @@ class BatchEventBuilder { attributes: OptimizelyAttributes?, decisions: [Decision]?, dispatchEvents: [DispatchEvent]) -> Data? { + let eventRegion = config.region let snapShot = Snapshot(decisions: decisions, events: dispatchEvents) let eventAttributes = getEventAttributes(config: config, attributes: attributes) @@ -100,9 +101,13 @@ class BatchEventBuilder { projectID: config.project.projectId, clientName: Utils.swiftSdkClientName, anonymizeIP: config.project.anonymizeIP, - enrichDecisions: true) + enrichDecisions: true, + region: eventRegion.rawValue) + + let data = try? JSONEncoder().encode(batchEvent) + let eventForDispatch = EventForDispatch(url: nil, body: data ?? Data(), region: eventRegion) - return try? JSONEncoder().encode(batchEvent) + return eventForDispatch.body } // MARK: - Event Tags diff --git a/Sources/Optimizely+Decide/OptimizelyUserContext+ObjC.swift b/Sources/Optimizely+Decide/OptimizelyUserContext+ObjC.swift index fa2a79b5..83a90a68 100644 --- a/Sources/Optimizely+Decide/OptimizelyUserContext+ObjC.swift +++ b/Sources/Optimizely+Decide/OptimizelyUserContext+ObjC.swift @@ -35,7 +35,7 @@ import Foundation public init(optimizely: OptimizelyClient, userId: String, attributes: [String: Any]? = nil) { userContext = OptimizelyUserContext(optimizely: optimizely, userId: userId, attributes: attributes) } - + public init(user: OptimizelyUserContext) { self.userContext = user } diff --git a/Sources/Optimizely/OptimizelyClient+ObjC.swift b/Sources/Optimizely/OptimizelyClient+ObjC.swift index 54161ad5..fc7abd69 100644 --- a/Sources/Optimizely/OptimizelyClient+ObjC.swift +++ b/Sources/Optimizely/OptimizelyClient+ObjC.swift @@ -546,3 +546,10 @@ extension OptimizelyClient { } } + +// MARK: - EventForDispatch Objective-C initializer +extension EventForDispatch { + @objc public convenience init(url: URL? = nil, body: Data) { + self.init(url: url, body: body, region: .US) + } +} diff --git a/Tests/OptimizelyTests-Batch-iOS/EventDispatcherTests_Batch.swift b/Tests/OptimizelyTests-Batch-iOS/EventDispatcherTests_Batch.swift index 946cea17..c5d0da8c 100644 --- a/Tests/OptimizelyTests-Batch-iOS/EventDispatcherTests_Batch.swift +++ b/Tests/OptimizelyTests-Batch-iOS/EventDispatcherTests_Batch.swift @@ -950,7 +950,8 @@ extension EventDispatcherTests_Batch { projectID: testProjectId, clientName: kClientName, anonymizeIP: kAnonymizeIP, - enrichDecisions: kEnrichDecision) + enrichDecisions: kEnrichDecision, + region: "US") } func dispatchMultipleEvents(_ events: [(url: String, event: BatchEvent)]) { diff --git a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Region.swift b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Region.swift new file mode 100644 index 00000000..1d00f65e --- /dev/null +++ b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Region.swift @@ -0,0 +1,396 @@ +// +// Copyright 2023-2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class BatchEventBuilderTests_Region: XCTestCase { + + let experimentKey = "ab_running_exp_audience_combo_exact_foo_or_true__and__42_or_4_2" + let userId = "test_user_1" + let featureKey = "feature_1" + + var optimizely: OptimizelyClient! + var eventDispatcher: MockEventDispatcher! + var project: Project! + let datafile = OTUtils.loadJSONDatafile("api_datafile")! + + override func setUp() { + eventDispatcher = MockEventDispatcher() + optimizely = OTUtils.createOptimizely(datafileName: "audience_targeting", + clearUserProfileService: true, + eventDispatcher: eventDispatcher)! + project = optimizely.config!.project! + } + + override func tearDown() { + Utils.sdkVersion = OPTIMIZELYSDKVERSION + Utils.swiftSdkClientName = "swift-sdk" + optimizely?.close() + optimizely = nil + optimizely?.eventDispatcher = nil + super.tearDown() + } + + // MARK: - Test Impression Event with Region + + func testCreateImpressionEventWithUSRegion() { + // Set the region to US + optimizely.config?.project.region = .US + + let attributes: [String: Any] = [ + "s_foo": "foo", + "b_true": true, + "i_42": 42, + "d_4_2": 4.2 + ] + + _ = try! optimizely.activate(experimentKey: experimentKey, + userId: userId, + attributes: attributes) + + let event = getFirstEventJSON(dispatcher: eventDispatcher)! + + // Check if the region is correctly set to US in the event + XCTAssertEqual(event["region"] as! String, "US") + + // Check if the event was sent to the correct endpoint + let eventForDispatch = getFirstEvent(dispatcher: eventDispatcher)! + XCTAssertEqual(eventForDispatch.url.absoluteString, EventForDispatch.eventEndpoint) + } + + func testCreateImpressionEventWithEURegion() { + // Set the region to EU + optimizely.config?.project.region = .EU + + let attributes: [String: Any] = [ + "s_foo": "foo", + "b_true": true, + "i_42": 42, + "d_4_2": 4.2 + ] + + _ = try! optimizely.activate(experimentKey: experimentKey, + userId: userId, + attributes: attributes) + + let event = getFirstEventJSON(dispatcher: eventDispatcher)! + + // Check if the region is correctly set to EU in the event + XCTAssertEqual(event["region"] as! String, "EU") + + // Check if the event was sent to the correct endpoint + let eventForDispatch = getFirstEvent(dispatcher: eventDispatcher)! + XCTAssertEqual(eventForDispatch.url.absoluteString, EventForDispatch.euEventEndpoint) + } + + func testCreateImpressionEventWithInvalidRegion() { + // Set the region to invalid ZZ + optimizely.config?.project.region = .ZZ + + let attributes: [String: Any] = [ + "s_foo": "foo", + "b_true": true, + "i_42": 42, + "d_4_2": 4.2 + ] + + _ = try! optimizely.activate(experimentKey: experimentKey, + userId: userId, + attributes: attributes) + + let event = getFirstEventJSON(dispatcher: eventDispatcher)! + + // Check if the region is correctly set to default US in the event + XCTAssertEqual(event["region"] as! String, "US") + + // Check if the event was sent to the correct endpoint + let eventForDispatch = getFirstEvent(dispatcher: eventDispatcher)! + XCTAssertEqual(eventForDispatch.url.absoluteString, EventForDispatch.eventEndpoint) + } + + // MARK: - Test Conversion Event with Region + + func testCreateConversionEventWithUSRegion() { + // Set the region to US + optimizely.config?.project.region = .US + + let eventKey = "event_single_targeted_exp" + let attributes: [String: Any] = ["s_foo": "bar"] + let eventTags: [String: Any] = ["browser": "chrome"] + + try! optimizely.track(eventKey: eventKey, + userId: userId, + attributes: attributes, + eventTags: eventTags) + + let event = getFirstEventJSON(dispatcher: eventDispatcher)! + + // Check if the region is correctly set to US in the event + XCTAssertEqual(event["region"] as! String, "US") + + // Check if the event was sent to the correct endpoint + let eventForDispatch = getFirstEvent(dispatcher: eventDispatcher)! + XCTAssertEqual(eventForDispatch.url.absoluteString, EventForDispatch.eventEndpoint) + } + + func testCreateConversionEventWithEURegion() { + // Set the region to EU + optimizely.config?.project.region = .EU + + let eventKey = "event_single_targeted_exp" + let attributes: [String: Any] = ["s_foo": "bar"] + let eventTags: [String: Any] = ["browser": "chrome"] + + try! optimizely.track(eventKey: eventKey, + userId: userId, + attributes: attributes, + eventTags: eventTags) + + let event = getFirstEventJSON(dispatcher: eventDispatcher)! + + // Check if the region is correctly set to EU in the event + XCTAssertEqual(event["region"] as! String, "EU") + + // Check if the event was sent to the correct endpoint + let eventForDispatch = getFirstEvent(dispatcher: eventDispatcher)! + XCTAssertEqual(eventForDispatch.url.absoluteString, EventForDispatch.euEventEndpoint) + } + + func testCreateConversionEventWithInvalidRegion() { + // Set the region to invalid ZZ + optimizely.config?.project.region = .ZZ + + let eventKey = "event_single_targeted_exp" + let attributes: [String: Any] = ["s_foo": "bar"] + let eventTags: [String: Any] = ["browser": "chrome"] + + try! optimizely.track(eventKey: eventKey, + userId: userId, + attributes: attributes, + eventTags: eventTags) + + let event = getFirstEventJSON(dispatcher: eventDispatcher)! + + // Check if the region is correctly set to default US in the event + XCTAssertEqual(event["region"] as! String, "US") + + // Check if the event was sent to the correct endpoint + let eventForDispatch = getFirstEvent(dispatcher: eventDispatcher)! + XCTAssertEqual(eventForDispatch.url.absoluteString, EventForDispatch.eventEndpoint) + } + + // MARK: - Test Direct Event Creation with Region + + func testDirectImpressionEventCreationWithUSRegion() { + // Set the region to US + optimizely.config?.project.region = .US + + let experiment = optimizely.config?.getExperiment(id: "10390977714") + let variation = experiment?.variations.first + + let event = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, + experiment: experiment, + variation: variation, + userId: userId, + attributes: nil, + flagKey: experiment!.key, + ruleType: Constants.DecisionSource.experiment.rawValue, + enabled: true, + cmabUUID: nil) + + XCTAssertNotNil(event) + + let eventJson = getEventJSON(data: event!)! + + // Check if the region is correctly set to US in the event + XCTAssertEqual(eventJson["region"] as! String, "US") + } + + func testDirectImpressionEventCreationWithEURegion() { + // Set the region to EU + optimizely.config?.project.region = .EU + + let experiment = optimizely.config?.getExperiment(id: "10390977714") + let variation = experiment?.variations.first + + let event = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, + experiment: experiment, + variation: variation, + userId: userId, + attributes: nil, + flagKey: experiment!.key, + ruleType: Constants.DecisionSource.experiment.rawValue, + enabled: true, + cmabUUID: nil) + + XCTAssertNotNil(event) + + let eventJson = getEventJSON(data: event!)! + + // Check if the region is correctly set to EU in the event + XCTAssertEqual(eventJson["region"] as! String, "EU") + } + + func testDirectConversionEventCreationWithUSRegion() { + // Set the region to US + optimizely.config?.project.region = .US + + let eventKey = "event_single_targeted_exp" + let eventTags: [String: Any] = ["browser": "chrome"] + + let event = BatchEventBuilder.createConversionEvent(config: optimizely.config!, + eventKey: eventKey, + userId: userId, + attributes: nil, + eventTags: eventTags) + + XCTAssertNotNil(event) + + let eventJson = getEventJSON(data: event!)! + + // Check if the region is correctly set to US in the event + XCTAssertEqual(eventJson["region"] as! String, "US") + } + + func testDirectConversionEventCreationWithEURegion() { + // Set the region to EU + optimizely.config?.project.region = .EU + + let eventKey = "event_single_targeted_exp" + let eventTags: [String: Any] = ["browser": "chrome"] + + let event = BatchEventBuilder.createConversionEvent(config: optimizely.config!, + eventKey: eventKey, + userId: userId, + attributes: nil, + eventTags: eventTags) + + XCTAssertNotNil(event) + + let eventJson = getEventJSON(data: event!)! + + // Check if the region is correctly set to EU in the event + XCTAssertEqual(eventJson["region"] as! String, "EU") + } + + // MARK: - Test Event Batching with Region + + func testEventBatchingWithSameRegion() { + // Set the region to US + optimizely.config?.project.region = .US + + // Create two events with the same region + let experiment = optimizely.config?.getExperiment(id: "10390977714") + let variation = experiment?.variations.first + + // Create first event + let event1 = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, + experiment: experiment, + variation: variation, + userId: userId, + attributes: nil, + flagKey: experiment!.key, + ruleType: Constants.DecisionSource.experiment.rawValue, + enabled: true, + cmabUUID: nil) + + // Create second event + let event2 = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, + experiment: experiment, + variation: variation, + userId: userId + "2", + attributes: nil, + flagKey: experiment!.key, + ruleType: Constants.DecisionSource.experiment.rawValue, + enabled: true, + cmabUUID: nil) + + // Create EventForDispatch objects + let eventForDispatch1 = EventForDispatch(url: nil, body: event1!, region: .US) + let eventForDispatch2 = EventForDispatch(url: nil, body: event2!, region: .US) + + // Test batching + let batchResult = [eventForDispatch1, eventForDispatch2].batch() + + // Events should be batched together since they have the same region + XCTAssertEqual(batchResult.numEvents, 2) + XCTAssertNotNil(batchResult.eventForDispatch) + } + + func testEventBatchingWithDifferentRegions() { + // Create two events with different regions + let experiment = optimizely.config?.getExperiment(id: "10390977714") + let variation = experiment?.variations.first + + // Set region to US for first event + optimizely.config?.project.region = .US + + // Create first event (US) + let event1 = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, + experiment: experiment, + variation: variation, + userId: userId, + attributes: nil, + flagKey: experiment!.key, + ruleType: Constants.DecisionSource.experiment.rawValue, + enabled: true, + cmabUUID: nil) + + // Set region to EU for second event + optimizely.config?.project.region = .EU + + // Create second event (EU) + let event2 = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, + experiment: experiment, + variation: variation, + userId: userId + "2", + attributes: nil, + flagKey: experiment!.key, + ruleType: Constants.DecisionSource.experiment.rawValue, + enabled: true, + cmabUUID: nil) + + // Create EventForDispatch objects + let eventForDispatch1 = EventForDispatch(url: nil, body: event1!, region: .US) + let eventForDispatch2 = EventForDispatch(url: nil, body: event2!, region: .EU) + + // Test batching + let batchResult = [eventForDispatch1, eventForDispatch2].batch() + + // Only the first event should be batched as they have different regions + XCTAssertEqual(batchResult.numEvents, 1) + XCTAssertNotNil(batchResult.eventForDispatch) + } + + // MARK: - Utils + + func getFirstEvent(dispatcher: MockEventDispatcher) -> EventForDispatch? { + optimizely.eventLock.sync{} + return dispatcher.events.first + } + + func getFirstEventJSON(dispatcher: MockEventDispatcher) -> [String: Any]? { + guard let event = getFirstEvent(dispatcher: dispatcher) else { return nil } + + let json = try! JSONSerialization.jsonObject(with: event.body, options: .allowFragments) as! [String: Any] + return json + } + + func getEventJSON(data: Data) -> [String: Any]? { + let json = try! JSONSerialization.jsonObject(with: data, options: .allowFragments) as! [String: Any] + return json + } +} diff --git a/Tests/TestUtils/OTUtils.swift b/Tests/TestUtils/OTUtils.swift index 42633839..549a7128 100644 --- a/Tests/TestUtils/OTUtils.swift +++ b/Tests/TestUtils/OTUtils.swift @@ -232,7 +232,8 @@ class OTUtils { projectID: testProjectId, clientName: "test", anonymizeIP: true, - enrichDecisions: true) + enrichDecisions: true, + region: "US") } static func clearAllEventQueues() {