diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 181fcbf4e..e0401f44b 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,18 +13,21 @@ 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: + 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 - name: Trigger build env: SDK: swift @@ -37,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/source_clear_cron.yml b/.github/workflows/source_clear_cron.yml index ca1a715b8..764649ab0 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 dc913225d..44387a326 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -17,7 +17,7 @@ on: description: release env: - VERSION: 3.10.4 + VERSION: 5.1.1 jobs: @@ -29,10 +29,9 @@ 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-12 + runs-on: macos-13 steps: - uses: actions/checkout@v3 - uses: maxim-lobanov/setup-xcode@v1 @@ -41,16 +40,15 @@ 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-12 + runs-on: macos-13 if: "${{ github.event.inputs.PREP == 'true' && github.event_name == 'workflow_dispatch' }}" steps: - uses: actions/checkout@v3 @@ -69,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 @@ -79,7 +77,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 @@ -96,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 + Scripts/run_release.sh \ No newline at end of file diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 3fd6ddfdd..8ad2e4511 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: @@ -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" @@ -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 }} @@ -72,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 @@ -81,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 @@ -89,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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index b82b41307..fae41347b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,91 @@ # 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 + +### Functionality Enhancement +* Add support for nested event tags ([#570](https://github.com/optimizely/swift-sdk/pull/570)). + +## 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 + +### 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 + +### 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/OptimizelySwiftSDK.podspec b/OptimizelySwiftSDK.podspec index 62b419d61..22790261b 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 = "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" } @@ -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 diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index d5c85cdd1..a442597cf 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 */; }; @@ -1978,12 +1978,162 @@ 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 */; }; + 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 */; }; 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; @@ -2408,21 +2558,42 @@ 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 = ""; }; 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; @@ -2635,7 +2806,6 @@ isa = PBXGroup; children = ( 6E6522DD278E4F3800954EA1 /* OdpManager.swift */, - 84E2E9412852A378001114AB /* OdpVuidManager.swift */, 848617C72863DC2700B7F41B /* OdpSegmentManager.swift */, 84E2E9712855875E001114AB /* OdpEventManager.swift */, 848617D82863E21200B7F41B /* OdpSegmentApiManager.swift */, @@ -2660,6 +2830,7 @@ 6E75165D22C520D400B2B157 /* Sources */ = { isa = PBXGroup; children = ( + 98F28A3F2E02DD4D00A86546 /* CMAB */, 6E75166622C520D400B2B157 /* Optimizely */, 6EC6DD3F24ABF8180017D296 /* Optimizely+Decide */, 6E75165E22C520D400B2B157 /* Customization */, @@ -2710,6 +2881,7 @@ 6EA2CC232345618E001E7531 /* OptimizelyConfig.swift */, 6ECB60C9234D5D9C00016D41 /* OptimizelyConfig+ObjC.swift */, C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */, + 84E2E9412852A378001114AB /* VuidManager.swift */, C78CAFA324486E0A009FE876 /* OptimizelyJSON+ObjC.swift */, ); path = Optimizely; @@ -2762,6 +2934,7 @@ 6E75167E22C520D400B2B157 /* DefaultBucketer.swift */, 6E75167F22C520D400B2B157 /* DefaultNotificationCenter.swift */, 6E75168022C520D400B2B157 /* DefaultDecisionService.swift */, + 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */, 6EF8DE3024BF7D69008B9488 /* DecisionReasons.swift */, 6E994B3325A3E6EA00999262 /* DecisionResponse.swift */, 6E75168122C520D400B2B157 /* Datastore */, @@ -2802,7 +2975,11 @@ 6E75169022C520D400B2B157 /* Variation.swift */, 6E75169122C520D400B2B157 /* TrafficAllocation.swift */, 6E75169222C520D400B2B157 /* Project.swift */, + 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */, 6E75169322C520D400B2B157 /* Experiment.swift */, + 98F28A1C2E01940500A86546 /* Cmab.swift */, + 980CC9072D833F2800E07D24 /* ExperimentCore.swift */, + 980CC8F62D833F0D00E07D24 /* Holdout.swift */, 6E75169422C520D400B2B157 /* FeatureFlag.swift */, 6E75169522C520D400B2B157 /* Group.swift */, 6E75169622C520D400B2B157 /* Variable.swift */, @@ -2925,14 +3102,18 @@ 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 */, 6E75199822C5211100B2B157 /* DataStoreTests.swift */, 6E75199022C5211100B2B157 /* DecisionListenerTests.swift */, + 989428BF2DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift */, 6E981FC1232C363300FADDD6 /* DecisionListenerTests_Datafile.swift */, 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 */, @@ -2946,14 +3127,20 @@ 84861810286D0B8900B7F41B /* OdpEventManagerTests.swift */, 8486180E286D0B8900B7F41B /* OdpManagerTests.swift */, 8486180D286D0B8900B7F41B /* OdpSegmentManagerTests.swift */, - 8486180F286D0B8900B7F41B /* OdpVuidManagerTests.swift */, + 98F28A512E02E81500A86546 /* CMABClientTests.swift */, + 98F28A662E05220300A86546 /* CmabServiceTests.swift */, + 8486180F286D0B8900B7F41B /* VuidManagerTests.swift */, 84861819286D188B00B7F41B /* OdpSegmentApiManagerTests.swift */, 8486181A286D188B00B7F41B /* OdpEventApiManagerTests.swift */, 6E27EC9A266EF11000B4A6D4 /* OptimizelyDecisionTests.swift */, 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 */, 6EB97BCC24C89DFB00068883 /* OptimizelyUserContextTests_Decide_Legacy.swift */, 6E0A72D326C5B9AE00FF92B5 /* OptimizelyUserContextTests_ForcedDecisions.swift */, 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */, @@ -2991,11 +3178,14 @@ 6E7519A622C5211100B2B157 /* AttributeValueTests_Evaluate.swift */, 6E7519A722C5211100B2B157 /* RolloutTests.swift */, 6E7519A822C5211100B2B157 /* ProjectConfigTests.swift */, + 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */, 6E7519A922C5211100B2B157 /* UserAttributeTests.swift */, 6E7519AA22C5211100B2B157 /* GroupTests.swift */, 6E7519AB22C5211100B2B157 /* VariationTests.swift */, + 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */, 6E7519AC22C5211100B2B157 /* ExperimentTests.swift */, 6E7519AD22C5211100B2B157 /* EventTests.swift */, + 98F28A2D2E01968000A86546 /* CmabTests.swift */, 6E7519AE22C5211100B2B157 /* ConditionHolderTests.swift */, 6E7519AF22C5211100B2B157 /* TrafficAllocationTests.swift */, 6E7519B022C5211100B2B157 /* ProjectTests.swift */, @@ -3021,6 +3211,7 @@ 6E7519B722C5211100B2B157 /* MockUrlSession.swift */, 6E5D121E2638DDF4000ABFC3 /* MockEventDispatcher.swift */, 6E20050726B4D28400278087 /* MockLogger.swift */, + 989428B22DBFA431008BA1C8 /* MockBucketer.swift */, ); path = TestUtils; sourceTree = ""; @@ -3108,6 +3299,15 @@ name = Frameworks; sourceTree = ""; }; + 98F28A3F2E02DD4D00A86546 /* CMAB */ = { + isa = PBXGroup; + children = ( + 98F28A402E02DD6D00A86546 /* CmabClient.swift */, + 98F28A552E0451CC00A86546 /* CmabService.swift */, + ); + path = CMAB; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -4118,17 +4318,20 @@ 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 */, 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 */, 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 */, @@ -4174,11 +4377,13 @@ 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 */, 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 */, @@ -4190,7 +4395,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 */, @@ -4200,6 +4407,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 */, @@ -4239,9 +4447,11 @@ 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 */, + 98F28A652E0451CC00A86546 /* CmabService.swift in Sources */, 6E6522E4278E4F3800954EA1 /* OdpManager.swift in Sources */, 6E424CFC26324B620081004A /* DataStoreFile.swift in Sources */, 6E424CFD26324B620081004A /* DataStoreQueueStackImpl.swift in Sources */, @@ -4250,9 +4460,11 @@ 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 /* OdpVuidManager.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 */, @@ -4273,13 +4485,16 @@ 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 */, + 98F28A212E01940500A86546 /* Cmab.swift in Sources */, 6E424D5026324C4D0081004A /* OptimizelyDecideOption.swift in Sources */, 6E424D5126324C4D0081004A /* OptimizelyDecision.swift in Sources */, 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 */, @@ -4305,6 +4520,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 */, @@ -4338,12 +4554,15 @@ 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 */, 6E623F03253F9045000617D0 /* DecisionInfo.swift in Sources */, 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 */, @@ -4359,7 +4578,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 */, @@ -4368,7 +4587,9 @@ 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 */, 6E75185922C520D400B2B157 /* FeatureVariable.swift in Sources */, 6E4544AB270E67C800F2CEBC /* NetworkReachability.swift in Sources */, @@ -4383,11 +4604,13 @@ 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 */, 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 */, @@ -4422,17 +4645,20 @@ 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 */, 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 */, 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 */, @@ -4478,11 +4704,13 @@ 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 */, 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 */, @@ -4494,7 +4722,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 */, @@ -4504,6 +4734,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 */, @@ -4526,12 +4757,14 @@ 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 */, 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 */, @@ -4542,7 +4775,9 @@ 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 */, 6E75188022C520D400B2B157 /* TrafficAllocation.swift in Sources */, 6E9B11DD22C548A200C22D81 /* OptimizelyClientTests_Valid.swift in Sources */, @@ -4555,7 +4790,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 */, @@ -4578,6 +4813,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 */, @@ -4601,6 +4837,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 */, @@ -4610,6 +4847,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 */, @@ -4630,6 +4868,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 */, @@ -4647,12 +4886,14 @@ 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 */, 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 */, @@ -4671,6 +4912,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 */, @@ -4678,7 +4920,8 @@ 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 */, + 980CC8F82D833F0D00E07D24 /* Holdout.swift in Sources */, 6E75173D22C520D400B2B157 /* MurmurHash3.swift in Sources */, 6E7516E922C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E7518A722C520D400B2B157 /* FeatureFlag.swift in Sources */, @@ -4695,13 +4938,16 @@ 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 */, 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 */, + 984FE5202CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E7518B322C520D400B2B157 /* Group.swift in Sources */, 6E20050F26B4D28500278087 /* MockLogger.swift in Sources */, 6EC6DD3A24ABF6990017D296 /* OptimizelyClient+Decide.swift in Sources */, @@ -4732,6 +4978,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 */, @@ -4752,6 +4999,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 */, @@ -4769,33 +5017,44 @@ 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 */, 6E5D12282638DDF4000ABFC3 /* MockEventDispatcher.swift in Sources */, 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 */, + 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 */, 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 /* OdpVuidManagerTests.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 */, 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 */, 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 */, 6E7516EB22C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E75188522C520D400B2B157 /* TrafficAllocation.swift in Sources */, 6E75176F22C520D400B2B157 /* Utils.swift in Sources */, @@ -4823,7 +5082,9 @@ 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 */, + 98F28A4E2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E9B117322C5487100C22D81 /* BatchEventBuilderTests_Attributes.swift in Sources */, 6E9B11B622C5489600C22D81 /* OTUtils.swift in Sources */, 6E75183122C520D400B2B157 /* BatchEvent.swift in Sources */, @@ -4831,7 +5092,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 */, @@ -4847,6 +5108,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 */, @@ -4870,11 +5132,14 @@ 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 */, 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 */, @@ -4899,7 +5164,9 @@ 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 */, + 989428B82DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E7517F622C520D400B2B157 /* DataStoreMemory.swift in Sources */, 6E9B119322C5488300C22D81 /* AttributeTests.swift in Sources */, 845945C9287758A600D13E11 /* OdpConfig.swift in Sources */, @@ -4911,7 +5178,9 @@ 84861809286CF33700B7F41B /* OdpEvent.swift in Sources */, 6E75171022C520D400B2B157 /* OptimizelyClient.swift in Sources */, 6E7516C822C520D400B2B157 /* DefaultEventDispatcher.swift in Sources */, - 84E2E9502852A378001114AB /* OdpVuidManager.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 */, 6E20051226B4D28600278087 /* MockLogger.swift in Sources */, @@ -4925,9 +5194,11 @@ 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 */, + 98F28A602E0451CC00A86546 /* CmabService.swift in Sources */, 6E75183222C520D400B2B157 /* BatchEvent.swift in Sources */, 6E7518DA22C520D400B2B157 /* AttributeValue.swift in Sources */, 84640882281320F000CCF97D /* IntegrationTests.swift in Sources */, @@ -4947,12 +5218,14 @@ 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 */, 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 */, @@ -4972,10 +5245,12 @@ 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 */, 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 */, @@ -4997,6 +5272,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 */, @@ -5018,6 +5294,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 */, @@ -5035,15 +5312,20 @@ 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 */, 6E5D12202638DDF4000ABFC3 /* MockEventDispatcher.swift in Sources */, 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 */, + 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 */, @@ -5052,6 +5334,8 @@ 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 */, 6EC6DD3524ABF6990017D296 /* OptimizelyClient+Decide.swift in Sources */, @@ -5062,22 +5346,26 @@ 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 */, 6E75181322C520D400B2B157 /* DataStoreQueueStackImpl.swift in Sources */, 6EC6DD6924AE94820017D296 /* OptimizelyUserContextTests.swift in Sources */, 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 */, 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 /* OdpVuidManager.swift in Sources */, + 84E2E9472852A378001114AB /* VuidManager.swift in Sources */, 6E623F06253F9045000617D0 /* DecisionInfo.swift in Sources */, 6E424BE2263228E90081004A /* AtomicArray.swift in Sources */, 6E9B115822C5486E00C22D81 /* EventDispatcherTests.swift in Sources */, @@ -5091,10 +5379,12 @@ 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 */, 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 */, @@ -5113,6 +5403,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 */, @@ -5126,6 +5417,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 */, @@ -5142,6 +5434,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 */, @@ -5166,6 +5459,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 */, @@ -5178,7 +5472,9 @@ 84861804286CF33700B7F41B /* OdpEvent.swift in Sources */, 6E75170B22C520D400B2B157 /* OptimizelyClient.swift in Sources */, 6E7516C322C520D400B2B157 /* DefaultEventDispatcher.swift in Sources */, - 84E2E94B2852A378001114AB /* OdpVuidManager.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 */, 6E20050D26B4D28500278087 /* MockLogger.swift in Sources */, @@ -5191,6 +5487,8 @@ 6E75182122C520D400B2B157 /* BatchEventBuilder.swift in Sources */, 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 */, @@ -5200,6 +5498,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 */, @@ -5208,11 +5507,13 @@ 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 */, 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 */, @@ -5220,6 +5521,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 */, @@ -5234,15 +5536,18 @@ 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 */, 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 */, 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 */, @@ -5292,6 +5597,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 */, @@ -5299,6 +5605,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 */, @@ -5307,13 +5614,17 @@ 84B4D75A27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */, 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 */, - 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 */, 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 */, @@ -5340,6 +5651,8 @@ 84E7ABC527D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 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 */, @@ -5393,6 +5706,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 */, @@ -5400,6 +5714,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 */, @@ -5408,13 +5723,17 @@ 84B4D75F27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */, 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 */, - 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 */, 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 */, @@ -5441,6 +5760,8 @@ 84E7ABCA27D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 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 */, @@ -5488,12 +5809,15 @@ 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 */, 6E623F02253F9045000617D0 /* DecisionInfo.swift in Sources */, 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 */, @@ -5509,7 +5833,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 */, @@ -5518,7 +5842,9 @@ 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 */, 6E7517F822C520D400B2B157 /* DataStoreUserDefaults.swift in Sources */, 6E4544AA270E67C800F2CEBC /* NetworkReachability.swift in Sources */, @@ -5533,11 +5859,13 @@ 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 */, 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 */, @@ -5572,17 +5900,20 @@ 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 */, 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 */, 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 */, @@ -5628,11 +5959,13 @@ 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 */, 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 */, @@ -5644,7 +5977,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 */, @@ -5654,6 +5989,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 */, @@ -5675,6 +6011,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 */, @@ -5716,8 +6053,9 @@ 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 /* OdpVuidManager.swift in Sources */, + 84E2E9452852A378001114AB /* VuidManager.swift in Sources */, 6E6522E1278E4F3800954EA1 /* OdpManager.swift in Sources */, 75C71A2425E454460084187E /* AttributeValue.swift in Sources */, 75C71A2525E454460084187E /* ConditionLeaf.swift in Sources */, @@ -5727,15 +6065,19 @@ 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 */, 75C71A2D25E454460084187E /* TrafficAllocation.swift in Sources */, 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 */, + 98AC97E22DAE4579001405DD /* HoldoutConfig.swift in Sources */, 75C71A3325E454460084187E /* Attribute.swift in Sources */, 75C71A3425E454460084187E /* BackgroundingCallbacks.swift in Sources */, 75C71A3525E454460084187E /* OPTNotificationCenter.swift in Sources */, @@ -5754,6 +6096,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 */, @@ -5777,12 +6120,15 @@ 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 */, 6E623F04253F9045000617D0 /* DecisionInfo.swift in Sources */, 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 */, @@ -5798,7 +6144,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 */, @@ -5807,7 +6153,9 @@ 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 */, BD64855C2491474500F30986 /* DataStoreUserDefaults.swift in Sources */, 6E4544AC270E67C800F2CEBC /* NetworkReachability.swift in Sources */, @@ -5822,11 +6170,13 @@ 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 */, 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/Package.swift b/Package.swift index ab02c5309..24b0467aa 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] ) diff --git a/README.md b/README.md index 2d387b058..d068f006c 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', '~> 5.1.1'```
 
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.1.0```
2. Run the following command:
```carthage update```
@@ -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 diff --git a/Sources/CMAB/CmabClient.swift b/Sources/CMAB/CmabClient.swift new file mode 100644 index 000000000..1e69c3d94 --- /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/Sources/CMAB/CmabService.swift b/Sources/CMAB/CmabService.swift new file mode 100644 index 000000000..c74205e88 --- /dev/null +++ b/Sources/CMAB/CmabService.swift @@ -0,0 +1,186 @@ +// +// 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]) -> Result + func getDecision(config: ProjectConfig, + userContext: OptimizelyUserContext, + ruleId: String, + options: [OptimizelyDecideOption], + completion: @escaping CmabDecisionCompletionHandler) +} + +class DefaultCmabService: CmabService { + 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]) -> 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, + 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 + } +} + +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/Audience/AttributeValue.swift b/Sources/Data Model/Audience/AttributeValue.swift index 66371d42c..3c46a5ee9 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/Sources/Data Model/Cmab.swift b/Sources/Data Model/Cmab.swift new file mode 100644 index 000000000..cf7670680 --- /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/DispatchEvents/BatchEvent.swift b/Sources/Data Model/DispatchEvents/BatchEvent.swift index 9a74a455f..5c7a26f9a 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? { @@ -85,6 +87,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 +95,7 @@ struct DecisionMetadata: Codable, Equatable { case flagKey = "flag_key" case variationKey = "variation_key" case enabled = "enabled" + case cmabUUID = "cmab_uuid" } } diff --git a/Sources/Data Model/DispatchEvents/EventForDispatch.swift b/Sources/Data Model/DispatchEvents/EventForDispatch.swift index 88cf5d83c..966e1f331 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/Experiment.swift b/Sources/Data Model/Experiment.swift index 415bcb98c..bfe8418aa 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" @@ -35,9 +35,10 @@ struct Experiment: Codable, OptimizelyExperiment { 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,81 +58,20 @@ 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 } } // 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.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/Sources/Data Model/HoldoutConfig.swift b/Sources/Data Model/HoldoutConfig.swift new file mode 100644 index 000000000..24726e2f2 --- /dev/null +++ b/Sources/Data Model/HoldoutConfig.swift @@ -0,0 +1,117 @@ +// +// 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 c85ad6a23..95d58248d 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 } @@ -46,7 +52,10 @@ struct Project: Codable, Equatable { var sendFlagDecisions: Bool? var sdkKey: String? var environmentKey: String? - + // Holdouts + var holdouts: [Holdout] + // Region + var region: Region? let logger = OPTLoggerFactory.getLogger() // Required since logger is not decodable @@ -56,17 +65,51 @@ 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, region + } + + 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) ?? [] + // Region - defaults to US if not present + region = try container.decodeIfPresent(Region.self, forKey: .region) } // 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 && 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 c1faa3578..be2c71d67 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]] @@ -34,12 +34,14 @@ 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]() var allExperiments = [Experiment]() var flagVariationsMap = [String: [Variation]]() var allSegments = [String]() + var holdoutConfig = HoldoutConfig() // MARK: - Init @@ -66,8 +68,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 @@ -109,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 } @@ -155,6 +166,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] @@ -200,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. */ @@ -270,6 +292,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 */ @@ -291,6 +320,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/Sources/Extensions/ArrayEventForDispatch+Extension.swift b/Sources/Extensions/ArrayEventForDispatch+Extension.swift index 5d8e5d28f..17855f22e 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/DecisionInfo.swift b/Sources/Implementation/DecisionInfo.swift index 72c38c307..f631da188 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, @@ -141,8 +141,11 @@ struct DecisionInfo { 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() } + return decisionInfo } diff --git a/Sources/Implementation/DefaultBucketer.swift b/Sources/Implementation/DefaultBucketer.swift index 44f896b0c..cdf5a6c1d 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,7 +93,84 @@ class DefaultBucketer: OPTBucketer { return DecisionResponse(result: nil, reasons: reasons) } - func bucketToVariation(experiment: Experiment, + /// 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 276d3a158..145702f05 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -17,43 +17,180 @@ import Foundation struct FeatureDecision { - var experiment: Experiment? - let variation: Variation + var experiment: ExperimentCore? + 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 + 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 } - // user-profile-service read-modify-write lock for supporting multiple clients - static let upsRMWLock = DispatchQueue(label: "ups-rmw") - - init(userProfileService: OPTUserProfileService) { + init(userProfileService: OPTUserProfileService, + cmabService: CmabService = DefaultCmabService.createDefault()) { self.bucketer = DefaultBucketer() self.userProfileService = userProfileService + self.cmabService = cmabService + } + + 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 + + /// 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, 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() + } + + // isAsync to `false` for backward compatibility + let response = getVariation(config: config, experiment: experiment, user: user, isAsync: false, userProfileTracker: profileTracker) + if (!ignoreUPS) { + profileTracker?.save() + } + + return DecisionResponse(result: response.result?.variation, reasons: response.reasons) + } + + /// 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. + /// - 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, + 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) @@ -64,10 +201,13 @@ 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) + let variationDecision = VariationDecision(variation: variation) + return DecisionResponse(result: variationDecision, reasons: reasons) } // ---- check to see if user is white-listed for a certain variation ---- @@ -76,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 @@ -85,41 +226,55 @@ 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) 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, 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 + // Acquire bucketingId + let bucketingId = getBucketingId(userId: userId, attributes: attributes) - if let variation = bucketedVariation { + 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 = variationDecision?.variation { 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) @@ -132,101 +287,151 @@ class DefaultDecisionService: OPTDecisionService { reasons.addInfo(info) } - return DecisionResponse(result: bucketedVariation, reasons: reasons) + return DecisionResponse(result: variationDecision, reasons: reasons) } - func doesMeetAudienceConditions(config: ProjectConfig, - experiment: Experiment, + // MARK: - Feature Flag Decision + + /// 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, + 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, isAsync: isAsync, options: options).first + + guard response?.result != nil else { + let reasons = response?.reasons ?? DecisionReasons(options: options) + return DecisionResponse(result: nil, reasons: reasons) + } + + 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. + /// - 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, - logType: Constants.EvaluationLogType = .experiment, - loggingKey: String? = nil) -> DecisionResponse { - let reasons = DecisionReasons() + isAsync: Bool, + options: [OptimizelyDecideOption]? = nil) -> [DecisionResponse] { - var result = true // success as default (no condition, etc) - let evType = logType.rawValue - let finalLoggingKey = loggingKey ?? experiment.key + 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() + } - 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 + var decisions = [DecisionResponse]() + + for featureFlag in featureFlags { + let flagDecisionResponse = getDecisionForFlag(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker, isAsync: isAsync, options: options) + decisions.append(flagDecisionResponse) } - logger.i(.audienceEvaluationResultCombined(evType, finalLoggingKey, result.description)) + // save profile + if !ignoreUPS { + profileTracker?.save() + } - return DecisionResponse(result: result, reasons: reasons) + return decisions } - func getVariationForFeature(config: ProjectConfig, - featureFlag: FeatureFlag, - user: OptimizelyUserContext, - 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. + /// - 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) - // Evaluate in this order: + let holdouts = config.getHoldoutForFlag(id: featureFlag.id) + for holdout in holdouts { + let holdoutDecision = getVariationForHoldout(config: config, + flagKey: featureFlag.key, + holdout: holdout, + user: user, + options: options) + reasons.merge(holdoutDecision.reasons) + if let variation = holdoutDecision.result { + 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, isAsync: isAsync, options: options) - // 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 { + reasons.merge(flagExpDecision.reasons) + + if let decision = flagExpDecision.result { return DecisionResponse(result: decision, 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 { + let rolloutDecision = getVariationForFeatureRollout(config: config, featureFlag: featureFlag, user: user, options: options) + reasons.merge(rolloutDecision.reasons) + + if let decision = rolloutDecision.result { return DecisionResponse(result: decision, reasons: reasons) + } else { + return DecisionResponse(result: nil, reasons: reasons) } - - return DecisionResponse(result: nil, reasons: reasons) } - func getVariationForFeatureExperiment(config: ProjectConfig, - featureFlag: FeatureFlag, - user: OptimizelyUserContext, - options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + /// 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. + /// - 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, + isAsync: Bool, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) let experimentIds = featureFlag.experimentIds @@ -244,11 +449,20 @@ class DefaultDecisionService: OPTDecisionService { flagKey: featureFlag.key, 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, cmabUUID: result.cmabUUID) + return DecisionResponse(result: featureDecision, reasons: reasons) + } } } } @@ -256,6 +470,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, @@ -310,36 +531,125 @@ 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, user: OptimizelyUserContext, - options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + userProfileTracker: UserProfileTracker?, + isAsync: Bool, + 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)) 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) } - // regular decision - let decisionResponse = getVariation(config: config, experiment: rule, user: user, - options: options) + options: options, + isAsync: isAsync, + userProfileTracker: userProfileTracker) + let variationResult = decisionResponse.result reasons.merge(decisionResponse.reasons) - let variation = decisionResponse.result - - return DecisionResponse(result: variation, reasons: reasons) + return DecisionResponse(result: variationResult, 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], @@ -348,7 +658,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] @@ -411,8 +721,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 @@ -424,6 +805,12 @@ class DefaultDecisionService: OPTDecisionService { return bucketingId } + /// 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 { @@ -452,7 +839,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 +851,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 +880,4 @@ extension DefaultDecisionService { self.logger.i(.savedVariationInUserProfile(variationId, experimentId, userId)) } } - } diff --git a/Sources/Implementation/Events/BatchEventBuilder.swift b/Sources/Implementation/Events/BatchEventBuilder.swift index 78fb329c4..57db1dcb7 100644 --- a/Sources/Implementation/Events/BatchEventBuilder.swift +++ b/Sources/Implementation/Events/BatchEventBuilder.swift @@ -22,15 +22,16 @@ class BatchEventBuilder { // MARK: - Impression Event static func createImpressionEvent(config: ProjectConfig, - experiment: Experiment?, + experiment: ExperimentCore?, variation: Variation?, userId: String, 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 ?? "", @@ -86,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) @@ -99,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/Implementation/UserProfileTracker.swift b/Sources/Implementation/UserProfileTracker.swift new file mode 100644 index 000000000..e632418ff --- /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/ODP/LruCache.swift b/Sources/ODP/LruCache.swift index fc6f95543..ceecb0777 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/Sources/ODP/OdpEventManager.swift b/Sources/ODP/OdpEventManager.swift index 9ddec34a2..9f22323d7 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 8553fdc10..a69242b2b 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 @@ -61,12 +57,11 @@ 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 - - self.eventManager.registerVUID(vuid: self.vuidManager.vuid) } func fetchQualifiedSegments(userId: String, @@ -77,7 +72,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, @@ -86,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).") @@ -97,15 +98,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 +124,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 6a1d71225..fc3d47139 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 da7e3c04b..a75eaf11a 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 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) } @@ -44,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: @@ -58,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 { @@ -66,53 +77,245 @@ 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)) } - let userId = user.userId - let attributes = user.attributes - let allOptions = defaultDecideOptions + (options ?? []) - let reasons = DecisionReasons(options: allOptions) - var decisionEventDispatched = false - var enabled = false + var allOptions = defaultDecideOptions + (options ?? []) + // Filtering out `enabledFlagsOnly` to ensure users always get a result. + allOptions.removeAll(where: { $0 == .enabledFlagsOnly }) - var decision: FeatureDecision? + 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, isAsync: false) + } + + /// 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 [:] + } - // check forced-decisions first + var decisionMap = [String : OptimizelyDecision]() - let forcedDecisionResponse = decisionService.findValidatedForcedDecision(config: config, - user: user, - context: OptimizelyDecisionContext(flagKey: key)) - reasons.merge(forcedDecisionResponse.reasons) + guard keys.count > 0 else { return decisionMap } - 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 - } + var validKeys = [String]() + var flagsWithoutForceDecision = [FeatureFlag]() + var flagDecisions = [String : FeatureDecision]() + var decisionReasonMap = [String : DecisionReasons]() + + 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) + } + } - if let featureEnabled = decision?.variation.featureEnabled { - enabled = featureEnabled + 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?, + decisionReasons: DecisionReasons, + allOptions: [OptimizelyDecideOption], + config: ProjectConfig) -> 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, + cmabUUID: flagDecision?.cmabUUID) decisionEventDispatched = true } } @@ -120,9 +323,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 +333,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,41 +361,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 { - logger.e(OptimizelyError.sdkNotReady) - return [:] - } - - return decide(user: user, keys: config.featureFlagKeys, options: options) - } - } // MARK: - Utils @@ -229,16 +397,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/OptimizelyDecideOption.swift b/Sources/Optimizely+Decide/OptimizelyDecideOption.swift index 763460a3e..f86619861 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/Sources/Optimizely+Decide/OptimizelyUserContext+ObjC.swift b/Sources/Optimizely+Decide/OptimizelyUserContext+ObjC.swift index fa2a79b58..83a90a68c 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+Decide/OptimizelyUserContext.swift b/Sources/Optimizely+Decide/OptimizelyUserContext.swift index 7f7cf48e4..70959baf7 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? @@ -84,7 +87,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 { @@ -122,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). @@ -142,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: @@ -156,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+ObjC.swift b/Sources/Optimizely/OptimizelyClient+ObjC.swift index 54161ad5b..fc7abd69f 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/Sources/Optimizely/OptimizelyClient.swift b/Sources/Optimizely/OptimizelyClient.swift index 665bc0af6..8dcf525fd 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 @@ -60,6 +61,7 @@ open class OptimizelyClient: NSObject { var decisionService: OPTDecisionService! public var notificationCenter: OPTNotificationCenter? public var odpManager: OdpManager! + public var vuid: String? let sdkSettings: OptimizelySdkSettings // MARK: - Public interfaces @@ -89,8 +91,12 @@ open class OptimizelyClient: NSObject { self.sdkKey = sdkKey self.sdkSettings = settings ?? OptimizelySdkSettings() self.defaultDecideOptions = defaultDecideOptions ?? [] - + super.init() + 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, @@ -102,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() @@ -114,7 +122,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 = vuid { + self.odpManager.vuid = _vuid + sendInitializedEvent(vuid: _vuid) + } logger.d("SDK Version: \(version)") } @@ -307,7 +318,8 @@ open class OptimizelyClient: NSObject { attributes: attributes, flagKey: "", ruleType: Constants.DecisionSource.experiment.rawValue, - enabled: true) + enabled: true, + cmabUUID: nil) return variation.key } @@ -427,7 +439,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 { @@ -441,7 +453,8 @@ open class OptimizelyClient: NSObject { attributes: attributes, flagKey: featureKey, ruleType: source, - enabled: featureEnabled) + enabled: featureEnabled, + cmabUUID: pair?.cmabUUID) } sendDecisionNotification(userId: userId, @@ -577,8 +590,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 { @@ -667,7 +680,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)) @@ -680,7 +693,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 } @@ -793,16 +806,21 @@ 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: Experiment?, + func sendImpressionEvent(experiment: ExperimentCore?, variation: Variation?, userId: String, attributes: OptimizelyAttributes? = nil, flagKey: String, ruleType: String, - enabled: Bool) { + enabled: Bool, + cmabUUID: String?) { // non-blocking (event data serialization takes time) eventLock.async { @@ -815,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 } @@ -884,7 +903,7 @@ extension OptimizelyClient { extension OptimizelyClient { - func sendActivateNotification(experiment: Experiment, + func sendActivateNotification(experiment: ExperimentCore, variation: Variation, userId: String, attributes: OptimizelyAttributes?, @@ -970,9 +989,8 @@ extension OptimizelyClient { data: data) } - /// the device vuid (read only) - public var vuid: String { - return odpManager.vuid + func sendInitializedEvent(vuid: String) { + try? odpManager.sendInitializedEvent(vuid: vuid) } 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 f1112148d..47c223489 100644 --- a/Sources/ODP/OdpVuidManager.swift +++ b/Sources/Optimizely/VuidManager.swift @@ -16,15 +16,21 @@ 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 +41,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 +69,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/Sources/Protocols/OPTBucketer.swift b/Sources/Protocols/OPTBucketer.swift index 0f9440ec8..f7b9fd833 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/Supporting Files/PrivacyInfo.xcprivacy b/Sources/Supporting Files/PrivacyInfo.xcprivacy index 1b18d1809..8042b2096 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 diff --git a/Sources/Utils/Constants.swift b/Sources/Utils/Constants.swift index f60f6fdc3..0c106b2ae 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 { @@ -88,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/Sources/Utils/LogMessage.swift b/Sources/Utils/LogMessage.swift index 2be76f5ec..d2b1e0847 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,13 +47,18 @@ 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) + 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) 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) @@ -68,6 +76,8 @@ enum LogMessage { case failedToAssignValue case valueForKeyNotFound(_ key: String) case lowPeriodicDownloadInterval + case cmabFetchFailed(_ expKey: String) + case cmabNotSupportedInSyncMode } extension LogMessage: CustomStringConvertible { @@ -76,6 +86,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 +102,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,13 +119,18 @@ 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." 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))." 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))." @@ -130,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/Sources/Utils/SDKVersion.swift b/Sources/Utils/SDKVersion.swift index 833a533e6..298c72273 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 = "5.1.1" diff --git a/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Decide.swift b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Decide.swift index c67ba7b77..a5628f1df 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 9151bc0d6..882aa717b 100644 --- a/Tests/OptimizelyTests-APIs/OptimizelyClientTests_ODP.swift +++ b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_ODP.swift @@ -187,10 +187,33 @@ 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) + XCTAssertNotNil(optimizely.vuid) + XCTAssert(optimizely.vuid!.starts(with: "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 func testUpdateOpdConfigCalled_wheneverProjectConfigUpdated_initialOrPolling() { diff --git a/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Others.swift b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Others.swift index ff8593141..6f8d84f93 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-Batch-iOS/EventDispatcherTests_Batch.swift b/Tests/OptimizelyTests-Batch-iOS/EventDispatcherTests_Batch.swift index 946cea17c..c5d0da8c8 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_EventTags.swift b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_EventTags.swift index 2411ed2e8..6041de92b 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/BatchEventBuilderTests_Events.swift b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift index f0d6f345f..2d743f2dd 100644 --- a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift +++ b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift @@ -27,6 +27,25 @@ 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", + "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 +57,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() { @@ -87,6 +110,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] @@ -188,7 +212,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] @@ -200,6 +224,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 } @@ -207,7 +232,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] @@ -219,6 +244,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 } @@ -461,6 +487,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 +661,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/BatchEventBuilderTests_Region.swift b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Region.swift new file mode 100644 index 000000000..1d00f65e1 --- /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/OptimizelyTests-Common/BucketTests_BucketToEntity.swift b/Tests/OptimizelyTests-Common/BucketTests_BucketToEntity.swift new file mode 100644 index 000000000..7fbf53159 --- /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/BucketTests_HoldoutToVariation.swift b/Tests/OptimizelyTests-Common/BucketTests_HoldoutToVariation.swift new file mode 100644 index 000000000..02b59464b --- /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/CMABClientTests.swift b/Tests/OptimizelyTests-Common/CMABClientTests.swift new file mode 100644 index 000000000..31094a25c --- /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) } + } + } +} diff --git a/Tests/OptimizelyTests-Common/CmabServiceTests.swift b/Tests/OptimizelyTests-Common/CmabServiceTests.swift new file mode 100644 index 000000000..f79d2f166 --- /dev/null +++ b/Tests/OptimizelyTests-Common/CmabServiceTests.swift @@ -0,0 +1,659 @@ +// +// 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) + } +} + +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/DecisionListenerTest_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift new file mode 100644 index 000000000..e62905fb6 --- /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/DecisionListenerTests.swift b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift index d5871a5a9..8e885b2a8 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() @@ -1247,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()) } @@ -1262,6 +1266,16 @@ class FakeDecisionService: DefaultDecisionService { let featureDecision = FeatureDecision(experiment: experiment, variation: tmpVariation, source: source) return DecisionResponse.responseNoReasons(result: featureDecision) } + + 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() + } + + let featureDecision = FeatureDecision(experiment: experiment, variation: tmpVariation, source: source) + return DecisionResponse.responseNoReasons(result: featureDecision) + } + } fileprivate extension HandlerRegistryService { diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTests_Datafile.swift b/Tests/OptimizelyTests-Common/DecisionListenerTests_Datafile.swift index 708f82177..e988cab89 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTests_Datafile.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTests_Datafile.swift @@ -118,7 +118,6 @@ class DecisionListenerTests_Datafile: XCTestCase { var notificationVariation: String? var notificationExperiment: String? var notificationType: String? - let exp = expectation(description: "x") _ = notificationCenter.addDecisionNotificationListener(decisionListener: { (type, _, _, decisionInfo) in diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Experiments.swift index fff345eab..80bd62ccb 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) } @@ -708,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 { @@ -729,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 2f243b029..cc94dbbec 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift @@ -258,20 +258,20 @@ class DecisionServiceTests_Features: XCTestCase { extension DecisionServiceTests_Features { func testGetVariationForFeatureExperimentWhenMatched() { - let pair = self.decisionService.getVariationForFeatureExperiment(config: config, - featureFlag: featureFlag, - user: optimizely.createUserContext(userId: kUserId, - attributes: kAttributesCountryMatch)).result + let pair = self.decisionService.getVariationForFeatureExperiments(config: config, + 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.getVariationForFeatureExperiment(config: config, - featureFlag: featureFlag, - user: optimizely.createUserContext(userId: kUserId, - attributes: kAttributesCountryNotMatch)).result + let pair = self.decisionService.getVariationForFeatureExperiments(config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch), + isAsync: false).result XCTAssertNil(pair) } @@ -280,15 +280,70 @@ extension DecisionServiceTests_Features { featureFlag.experimentIds = ["99999"] // not-existing experiment self.config.project.featureFlags = [featureFlag] - let pair = self.decisionService.getVariationForFeatureExperiment(config: config, - featureFlag: featureFlag, - user: optimizely.createUserContext(userId: kUserId, - attributes: kAttributesCountryMatch)).result + let pair = self.decisionService.getVariationForFeatureExperiments(config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch), + isAsync: false).result XCTAssertNil(pair) } } +// 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), + isAsync: false + ) + + + 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 { @@ -306,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) } @@ -357,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) } @@ -372,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) } @@ -388,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) } @@ -434,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() { @@ -457,12 +512,28 @@ 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() } } } + +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 + } + +} diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift new file mode 100644 index 000000000..2cc96c98a --- /dev/null +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift @@ -0,0 +1,671 @@ +// +// 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", + "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", + "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", + "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", + "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/LruCacheTests.swift b/Tests/OptimizelyTests-Common/LruCacheTests.swift index ddcfa299f..ede0d2609 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.. 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 000000000..e90469534 --- /dev/null +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift @@ -0,0 +1,338 @@ +// +// 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))") + + // 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 + } + + 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")) + } +} + +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 + } + +} diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift new file mode 100644 index 000000000..edb12f60f --- /dev/null +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift @@ -0,0 +1,563 @@ +// +// 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", + "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{} + XCTAssertFalse(eventDispatcher.events.isEmpty) + } +} diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift index f0a73284b..7ece916ec 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift @@ -17,14 +17,13 @@ import XCTest class OptimizelyUserContextTests_Decide_Reasons: XCTestCase { - let kUserId = "tester" var optimizely: OptimizelyClient! var decisionService: DefaultDecisionService! var ups: OPTUserProfileService! var user: OptimizelyUserContext! - + override func setUp() { super.setUp() @@ -41,13 +40,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 +58,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 +82,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 +98,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 +109,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 +123,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 +139,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 +153,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 +167,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 +181,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 +195,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 +209,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 +217,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 +241,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 +254,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 +276,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 +286,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 +321,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 +341,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 +358,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 +383,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 +395,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 +407,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 +419,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 +438,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 +455,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 +482,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 +490,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_Decide_With_Holdouts_Reasons.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift new file mode 100644 index 000000000..b4b8e0e35 --- /dev/null +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift @@ -0,0 +1,198 @@ +// +// 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", + "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-Common/OptimizelyUserContextTests_ODP_2.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_ODP_2.swift index 79e7a3ba8..2033a7405 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 000000000..35bb653b7 --- /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) + + } + +} diff --git a/Tests/OptimizelyTests-DataModel/AttributeValueTests.swift b/Tests/OptimizelyTests-DataModel/AttributeValueTests.swift index 402d7088f..0c4667123 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/CmabTests.swift b/Tests/OptimizelyTests-DataModel/CmabTests.swift new file mode 100644 index 000000000..42fb1180c --- /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 b47d41b52..665d60362 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/HoldoutConfigTests.swift b/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift new file mode 100644 index 000000000..b4c7f9ed4 --- /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 new file mode 100644 index 000000000..da01277f7 --- /dev/null +++ b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift @@ -0,0 +1,269 @@ +// +// 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 { + static var variationData: [String: Any] = ["id": "553339214", + "key": "house", + "featureEnabled": false] + + 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", + "variations": [HoldoutTests.variationData], + "trafficAllocation": [HoldoutTests.trafficAllocationData], + "audienceIds": ["33333"], + "audienceConditions": HoldoutTests.conditionHolderData] + + static var sampleDataWithIncludedFlags: [String: Any] = ["id": "55555", + "key": "background", + "status": "Running", + "variations": [HoldoutTests.variationData], + "trafficAllocation": [HoldoutTests.trafficAllocationData], + "audienceIds": ["33333"], + "audienceConditions": HoldoutTests.conditionHolderData, + "includedFlags": ["4444", "5555"]] + + static var sampleDataWithExcludedFlags: [String: Any] = ["id": "3333", + "key": "background", + "status": "Running", + "variations": [HoldoutTests.variationData], + "trafficAllocation": [HoldoutTests.trafficAllocationData], + "audienceIds": ["33333"], + "audienceConditions": HoldoutTests.conditionHolderData, + "excludedFlags": ["8888", "9999"]] + + + +} + +// 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.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: HoldoutTests.conditionHolderData))) + } + + func testDecodeSuccessWithIncludedFlags() { + let data: [String: Any] = HoldoutTests.sampleDataWithIncludedFlags + + let model: Holdout = try! OTUtils.model(from: data) + + XCTAssert(model.id == "55555") + XCTAssert(model.key == "background") + XCTAssert(model.status == .running) + 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: HoldoutTests.conditionHolderData))) + XCTAssertEqual(model.includedFlags, ["4444", "5555"]) + } + + func testDecodeSuccessWithExcludedFlags() { + let data: [String: Any] = HoldoutTests.sampleDataWithExcludedFlags + + let model: Holdout = try! OTUtils.model(from: data) + + XCTAssert(model.id == "3333") + XCTAssert(model.key == "background") + XCTAssert(model.status == .running) + 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: HoldoutTests.conditionHolderData))) + XCTAssertEqual(model.includedFlags, []) + XCTAssertEqual(model.excludedFlags, ["8888", "9999"]) + } + + + 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.variations == [try! OTUtils.model(from: HoldoutTests.variationData)]) + XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: HoldoutTests.trafficAllocationData)]) + 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 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", + "variations": [HoldoutTests.variationData], + "trafficAllocation": [HoldoutTests.trafficAllocationData], + "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) + } +} + diff --git a/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift b/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift index aebde55fb..e9384acb0 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", @@ -168,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"]))) + + } } diff --git a/Tests/OptimizelyTests-DataModel/ProjectTests.swift b/Tests/OptimizelyTests-DataModel/ProjectTests.swift index dbf5ba4c8..e0d10fb95 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 diff --git a/Tests/OptimizelyTests-DataModel/UserAttributeTests.swift b/Tests/OptimizelyTests-DataModel/UserAttributeTests.swift index e0ba63821..57022006e 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 diff --git a/Tests/TestUtils/MockBucketer.swift b/Tests/TestUtils/MockBucketer.swift new file mode 100644 index 000000000..e87244078 --- /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) + } +} + diff --git a/Tests/TestUtils/OTUtils.swift b/Tests/TestUtils/OTUtils.swift index 426338390..549a71284 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() {