diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml
index b55407af7..300016b82 100644
--- a/.github/workflows/csharp.yml
+++ b/.github/workflows/csharp.yml
@@ -29,7 +29,7 @@ jobs:
netFrameworksAndUnitTest:
name: Build Framework & Run Unit Tests
needs: [ lintCodebase ]
- runs-on: windows-2019 # required version for Framework 4.0
+ runs-on: windows-2022
env:
REPO_SLUG: ${{ github.repository }}
BUILD_NUMBER: ${{ github.run_id }}
@@ -44,15 +44,51 @@ jobs:
uses: microsoft/setup-msbuild@v1
- name: Setup NuGet
uses: NuGet/setup-nuget@v1
+ - name: Download and Extract .NET Framework Reference Assemblies
+ run: |
+ # Create temp directory
+ New-Item -ItemType Directory -Path "temp_ref_assemblies" -Force
+
+ # Download .NET 4.0 Reference Assemblies
+ echo "Downloading .NET 4.0 Reference Assemblies..."
+ Invoke-WebRequest -Uri "https://www.nuget.org/api/v2/package/Microsoft.NETFramework.ReferenceAssemblies.net40/1.0.3" -OutFile "temp_ref_assemblies/net40_ref.zip"
+
+ # Download .NET 4.5 Reference Assemblies
+ echo "Downloading .NET 4.5 Reference Assemblies..."
+ Invoke-WebRequest -Uri "https://www.nuget.org/api/v2/package/Microsoft.NETFramework.ReferenceAssemblies.net45/1.0.3" -OutFile "temp_ref_assemblies/net45_ref.zip"
+
+ # Extract .NET 4.0 Reference Assemblies
+ echo "Extracting .NET 4.0 Reference Assemblies..."
+ Expand-Archive -Path "temp_ref_assemblies/net40_ref.zip" -DestinationPath "temp_ref_assemblies/net40" -Force
+ if (Test-Path "temp_ref_assemblies/net40/build/.NETFramework/v4.0") {
+ echo "✓ .NET 4.0 Reference Assemblies extracted to workspace"
+ }
+
+ # Extract .NET 4.5 Reference Assemblies
+ echo "Extracting .NET 4.5 Reference Assemblies..."
+ Expand-Archive -Path "temp_ref_assemblies/net45_ref.zip" -DestinationPath "temp_ref_assemblies/net45" -Force
+ if (Test-Path "temp_ref_assemblies/net45/build/.NETFramework/v4.5") {
+ echo "✓ .NET 4.5 Reference Assemblies extracted to workspace"
+ }
- name: Restore NuGet packages
run: nuget restore ./OptimizelySDK.NETFramework.sln
- name: Build & strongly name assemblies
- run: msbuild /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk /p:Configuration=Release ./OptimizelySDK.NETFramework.sln
+ run: |
+ # Build with workspace-relative reference assembly paths
+ $Net40RefPath = "$(pwd)\temp_ref_assemblies\net40\build\.NETFramework\v4.0"
+ $Net45RefPath = "$(pwd)\temp_ref_assemblies\net45\build\.NETFramework\v4.5"
+
+ echo "Using .NET 4.0 Reference Assemblies from: $Net40RefPath"
+ echo "Using .NET 4.5 Reference Assemblies from: $Net45RefPath"
+
+ msbuild /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk /p:Configuration=Release /p:FrameworkPathOverride="$Net45RefPath" ./OptimizelySDK.NETFramework.sln
- name: Install & Run NUnit tests
run: |
nuget install NUnit.Console -Version 3.18.1 -DirectDownload -OutputDirectory .
# https://docs.nunit.org/articles/nunit/running-tests/Console-Command-Line.html
./NUnit.ConsoleRunner.3.18.1\tools\nunit3-console.exe /timeout 10000 /process Separate ./OptimizelySDK.Tests/bin/Release/OptimizelySDK.Tests.dll
+ - name: Cleanup reference assemblies
+ run: Remove-Item -Path "temp_ref_assemblies" -Recurse -Force
netStandard16:
name: Build Standard 1.6
@@ -75,7 +111,7 @@ jobs:
- name: Restore dependencies
run: dotnet restore OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
- name: Build & strongly name assemblies
- run: dotnet build OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=D:\a\csharp-sdk\csharp-sdk\keypair.snk -c Release
+ run: dotnet build OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk -c Release
netStandard20:
name: Build Standard 2.0
@@ -98,7 +134,7 @@ jobs:
- name: Restore dependencies
run: dotnet restore OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
- name: Build & strongly name assemblies
- run: dotnet build OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=D:\a\csharp-sdk\csharp-sdk\keypair.snk -c Release
+ run: dotnet build OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk -c Release
integration_tests:
name: Run Integration Tests
@@ -106,7 +142,6 @@ jobs:
uses: optimizely/csharp-sdk/.github/workflows/integration_test.yml@master
secrets:
CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }}
- TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }}
fullstack_production_suite:
name: Run Performance Tests
@@ -116,4 +151,3 @@ jobs:
FULLSTACK_TEST_REPO: ProdTesting
secrets:
CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }}
- TRAVIS_COM_TOKEN: ${{ secrets.TRAVIS_COM_TOKEN }}
diff --git a/.github/workflows/csharp_release.yml b/.github/workflows/csharp_release.yml
index cd80b0b20..f4c1736cd 100644
--- a/.github/workflows/csharp_release.yml
+++ b/.github/workflows/csharp_release.yml
@@ -3,6 +3,7 @@
on:
release:
types: [ published ] # Trigger on published pre-releases and releases
+ workflow_dispatch:
jobs:
variables:
@@ -30,20 +31,56 @@ jobs:
buildFrameworkVersions:
name: Build Framework versions
needs: [ variables ]
- runs-on: windows-2019 # required version for Framework 4.0
+ runs-on: windows-2022
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: ${{ needs.variables.outputs.tag }}
- name: Add msbuild to PATH
- uses: microsoft/setup-msbuild@v1
+ uses: microsoft/setup-msbuild@v2
- name: Setup NuGet
- uses: NuGet/setup-nuget@v1
+ uses: nuget/setup-nuget@v2
+ - name: Download and Extract .NET Framework Reference Assemblies
+ run: |
+ # Create temp directory
+ New-Item -ItemType Directory -Path "temp_ref_assemblies" -Force
+
+ # Download .NET 4.0 Reference Assemblies
+ echo "Downloading .NET 4.0 Reference Assemblies..."
+ Invoke-WebRequest -Uri "https://www.nuget.org/api/v2/package/Microsoft.NETFramework.ReferenceAssemblies.net40/1.0.3" -OutFile "temp_ref_assemblies/net40_ref.zip"
+
+ # Download .NET 4.5 Reference Assemblies
+ echo "Downloading .NET 4.5 Reference Assemblies..."
+ Invoke-WebRequest -Uri "https://www.nuget.org/api/v2/package/Microsoft.NETFramework.ReferenceAssemblies.net45/1.0.3" -OutFile "temp_ref_assemblies/net45_ref.zip"
+
+ # Extract .NET 4.0 Reference Assemblies
+ echo "Extracting .NET 4.0 Reference Assemblies..."
+ Expand-Archive -Path "temp_ref_assemblies/net40_ref.zip" -DestinationPath "temp_ref_assemblies/net40" -Force
+ if (Test-Path "temp_ref_assemblies/net40/build/.NETFramework/v4.0") {
+ echo "✓ .NET 4.0 Reference Assemblies extracted to workspace"
+ }
+
+ # Extract .NET 4.5 Reference Assemblies
+ echo "Extracting .NET 4.5 Reference Assemblies..."
+ Expand-Archive -Path "temp_ref_assemblies/net45_ref.zip" -DestinationPath "temp_ref_assemblies/net45" -Force
+ if (Test-Path "temp_ref_assemblies/net45/build/.NETFramework/v4.5") {
+ echo "✓ .NET 4.5 Reference Assemblies extracted to workspace"
+ }
- name: Restore NuGet packages
run: nuget restore ./OptimizelySDK.NETFramework.sln
- name: Build and strongly name assemblies
- run: msbuild /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk /p:Configuration=Release ./OptimizelySDK.NETFramework.sln
+ run: |
+ # Build with workspace-relative reference assembly paths
+ $Net40RefPath = "$(pwd)\temp_ref_assemblies\net40\build\.NETFramework\v4.0"
+ $Net45RefPath = "$(pwd)\temp_ref_assemblies\net45\build\.NETFramework\v4.5"
+
+ echo "Using .NET 4.0 Reference Assemblies from: $Net40RefPath"
+ echo "Using .NET 4.5 Reference Assemblies from: $Net45RefPath"
+
+ msbuild /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=$(pwd)/keypair.snk /p:Configuration=Release /p:FrameworkPathOverride="$Net45RefPath" ./OptimizelySDK.NETFramework.sln
+ - name: Cleanup reference assemblies
+ run: Remove-Item -Path "temp_ref_assemblies" -Recurse -Force
- name: Upload Framework artifacts
uses: actions/upload-artifact@v4
with:
@@ -54,14 +91,14 @@ jobs:
buildStandard16:
name: Build Standard 1.6 version
needs: [ variables ]
- runs-on: windows-latest
+ runs-on: windows-2022
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: ${{ needs.variables.outputs.tag }}
- name: Setup .NET
- uses: actions/setup-dotnet@v2
+ uses: actions/setup-dotnet@v4
- name: Restore dependencies
run: dotnet restore OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
- name: Build and strongly name assemblies
@@ -76,14 +113,14 @@ jobs:
buildStandard20:
name: Build Standard 2.0 version
needs: [ variables ]
- runs-on: windows-latest
+ runs-on: windows-2022
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: ${{ needs.variables.outputs.tag }}
- name: Setup .NET
- uses: actions/setup-dotnet@v2
+ uses: actions/setup-dotnet@v4
- name: Restore dependencies
run: dotnet restore OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
- name: Build and strongly name Standard 2.0 project
diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml
index cf9a96b37..b56cc8817 100644
--- a/.github/workflows/integration_test.yml
+++ b/.github/workflows/integration_test.yml
@@ -9,8 +9,6 @@ on:
secrets:
CI_USER_TOKEN:
required: true
- TRAVIS_COM_TOKEN:
- required: true
jobs:
test:
runs-on: ubuntu-latest
@@ -19,8 +17,8 @@ jobs:
with:
# You should create a personal access token and store it in your repository
token: ${{ secrets.CI_USER_TOKEN }}
- repository: 'optimizely/travisci-tools'
- path: 'home/runner/travisci-tools'
+ repository: 'optimizely/ci-helper-tools'
+ path: 'home/runner/ci-helper-tools'
ref: 'master'
- name: set SDK Branch if PR
env:
@@ -28,14 +26,12 @@ jobs:
if: ${{ github.event_name == 'pull_request' }}
run: |
echo "SDK_BRANCH=$HEAD_REF" >> $GITHUB_ENV
- echo "TRAVIS_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=$REF_NAME" >> $GITHUB_ENV
- echo "TRAVIS_BRANCH=$REF_NAME" >> $GITHUB_ENV
- name: Trigger build
env:
SDK: csharp
@@ -51,9 +47,8 @@ jobs:
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: |
echo "$GITHUB_CONTEXT"
- 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/sonarqube.yml b/.github/workflows/sonarqube.yml
deleted file mode 100644
index b60e311dd..000000000
--- a/.github/workflows/sonarqube.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-name: SonarQube
-on:
- push:
- branches:
- - dsier/sonarqube
-jobs:
- build:
- name: Build
- runs-on: windows-latest
- steps:
- - name: Set up JDK 11
- uses: actions/setup-java@v1
- with:
- java-version: 1.11
- - uses: actions/checkout@v2
- with:
- fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- - name: Cache SonarCloud packages
- uses: actions/cache@v1
- with:
- path: ~\sonar\cache
- key: ${{ runner.os }}-sonar
- restore-keys: ${{ runner.os }}-sonar
- - name: Cache SonarCloud scanner
- id: cache-sonar-scanner
- uses: actions/cache@v1
- with:
- path: .\.sonar\scanner
- key: ${{ runner.os }}-sonar-scanner
- restore-keys: ${{ runner.os }}-sonar-scanner
- - name: Install SonarCloud scanner
- if: steps.cache-sonar-scanner.outputs.cache-hit != 'true'
- shell: powershell
- run: |
- New-Item -Path .\.sonar\scanner -ItemType Directory
- dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner
- - name: Build and analyze
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
- SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- shell: powershell
- run: |
- .\.sonar\scanner\dotnet-sonarscanner begin /k:"csharpsdk" /o:"optidevx" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io/"
- dotnet build .\OptimizelySDK\
- .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN
diff --git a/OptimizelySDK.DemoApp/Scripts/README.md b/OptimizelySDK.DemoApp/Scripts/README.md
index ff1e9551c..12510e26f 100644
--- a/OptimizelySDK.DemoApp/Scripts/README.md
+++ b/OptimizelySDK.DemoApp/Scripts/README.md
@@ -7,7 +7,6 @@
-
@@ -34,7 +33,7 @@ to make it possible to position it near a given reference element.
The engine is completely modular and most of its features are implemented as **modifiers**
(similar to middlewares or plugins).
-The whole code base is written in ES2015 and its features are automatically tested on real browsers thanks to [SauceLabs](https://saucelabs.com/) and [TravisCI](https://travis-ci.org/).
+The whole code base is written in ES2015 and its features are automatically tested on real browsers thanks to [SauceLabs](https://saucelabs.com/).
Popper.js has zero dependencies. No jQuery, no LoDash, nothing.
It's used by big companies like [Twitter in Bootstrap v4](https://getbootstrap.com/), [Microsoft in WebClipper](https://github.com/OneNoteDev/WebClipper) and [Atlassian in AtlasKit](https://aui-cdn.atlassian.com/atlaskit/registry/).
diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
index a44954712..4c3145c2a 100644
--- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
+++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
@@ -88,6 +88,15 @@
Entity\Experiment.cs
+
+ Entity\Cmab.cs
+
+
+ Entity\Holdout.cs
+
+
+ Entity\ExperimentCore.cs
+
Entity\FeatureDecision.cs
@@ -215,9 +224,15 @@
Bucketing\ExperimentUtils
+
+ Utils\HoldoutConfig.cs
+
Bucketing\UserProfileUtil
+
+ Bucketing\VariationDecisionResult.cs
+
Entity\FeatureVariableUsage
diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
index 05785575d..6f2b3f23c 100644
--- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
+++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
@@ -90,6 +90,15 @@
Entity\Experiment.cs
+
+ Entity\Cmab.cs
+
+
+ Entity\Holdout.cs
+
+
+ Entity\ExperimentCore.cs
+
Entity\FeatureDecision.cs
@@ -214,9 +223,15 @@
Bucketing\ExperimentUtils
+
+ Utils\HoldoutConfig.cs
+
Bucketing\UserProfileUtil
+
+ Bucketing\VariationDecisionResult.cs
+
Entity\FeatureVariableUsage
diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
index b17f79e74..c1ba6d73a 100644
--- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
+++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
@@ -26,6 +26,9 @@
+
+
+
@@ -64,6 +67,7 @@
+
@@ -75,6 +79,7 @@
+
@@ -160,5 +165,7 @@
+
+
diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
index b7114653d..e41c7fd78 100644
--- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
+++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
@@ -142,6 +142,9 @@
Bucketing\UserProfileUtil.cs
+
+ Bucketing\VariationDecisionResult.cs
+
Config\DatafileProjectConfig.cs
@@ -178,7 +181,39 @@
Entity\Experiment.cs
-
+
+ Entity\Cmab.cs
+
+
+ Cmab\ICmabClient.cs
+
+
+ Cmab\DefaultCmabClient.cs
+
+
+ Cmab\ICmabService.cs
+
+
+ Cmab\DefaultCmabService.cs
+
+
+ Cmab\CmabRetryConfig.cs
+
+
+ Cmab\CmabConfig.cs
+
+
+ Cmab\CmabModels.cs
+
+
+ Cmab\CmabConstants.cs
+
+
+ Entity\Holdout.cs
+
+
+ Entity\ExperimentCore.cs
+
Entity\FeatureDecision.cs
@@ -331,12 +366,18 @@
Utils\ExperimentUtils.cs
+
+ Utils\HoldoutConfig.cs
+
Utils\Schema.cs
Utils\Validator.cs
+
+ Utils\ICacheWithRemove.cs
+
Event\BatchEventProcessor.cs
diff --git a/OptimizelySDK.Tests/Assertions.cs b/OptimizelySDK.Tests/Assertions.cs
index 9dfe2f7ba..3544d6212 100644
--- a/OptimizelySDK.Tests/Assertions.cs
+++ b/OptimizelySDK.Tests/Assertions.cs
@@ -488,7 +488,7 @@ public static void AreEqual(Experiment expected, Experiment actual)
Assert.AreEqual(expected.GroupId, actual.GroupId);
Assert.AreEqual(expected.GroupPolicy, actual.GroupPolicy);
Assert.AreEqual(expected.Id, actual.Id);
- Assert.AreEqual(expected.IsExperimentRunning, actual.IsExperimentRunning);
+ Assert.AreEqual(expected.isRunning, actual.isRunning);
Assert.AreEqual(expected.IsInMutexGroup, actual.IsInMutexGroup);
Assert.AreEqual(expected.Key, actual.Key);
Assert.AreEqual(expected.LayerId, actual.LayerId);
@@ -500,6 +500,33 @@ public static void AreEqual(Experiment expected, Experiment actual)
AreEquivalent(expected.Variations, actual.Variations);
}
+ public static void AreEqual(ExperimentCore expected, ExperimentCore actual)
+ {
+ if (expected == null && actual == null)
+ {
+ return;
+ }
+
+ Assert.IsNotNull(expected, "Expected ExperimentCore should not be null");
+ Assert.IsNotNull(actual, "Actual ExperimentCore should not be null");
+
+ Assert.AreEqual(expected.AudienceConditions, actual.AudienceConditions);
+ Assert.AreEqual(expected.AudienceConditionsList, actual.AudienceConditionsList);
+ Assert.AreEqual(expected.AudienceConditionsString, actual.AudienceConditionsString);
+ AreEquivalent(expected.AudienceIds, actual.AudienceIds);
+ Assert.AreEqual(expected.AudienceIdsList, actual.AudienceIdsList);
+ Assert.AreEqual(expected.AudienceIdsString, actual.AudienceIdsString);
+ Assert.AreEqual(expected.Id, actual.Id);
+ Assert.AreEqual(expected.isRunning, actual.isRunning);
+ Assert.AreEqual(expected.Key, actual.Key);
+ Assert.AreEqual(expected.LayerId, actual.LayerId);
+ Assert.AreEqual(expected.Status, actual.Status);
+ AreEquivalent(expected.TrafficAllocation, actual.TrafficAllocation);
+ AreEquivalent(expected.VariationIdToVariationMap, actual.VariationIdToVariationMap);
+ AreEquivalent(expected.VariationKeyToVariationMap, actual.VariationKeyToVariationMap);
+ AreEquivalent(expected.Variations, actual.Variations);
+ }
+
#endregion Experiment
#region FeatureDecision
@@ -507,6 +534,8 @@ public static void AreEqual(Experiment expected, Experiment actual)
public static void AreEqual(FeatureDecision expected, FeatureDecision actual)
{
AreEqual(expected.Experiment, actual.Experiment);
+ AreEqual(expected.Variation, actual.Variation);
+ Assert.AreEqual(expected.Source, actual.Source);
}
#endregion FeatureDecision
diff --git a/OptimizelySDK.Tests/BucketerBucketToEntityIdTest.cs b/OptimizelySDK.Tests/BucketerBucketToEntityIdTest.cs
new file mode 100644
index 000000000..837b4dcb3
--- /dev/null
+++ b/OptimizelySDK.Tests/BucketerBucketToEntityIdTest.cs
@@ -0,0 +1,286 @@
+/*
+ * Copyright 2025, Optimizely
+ *
+ * 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.
+ */
+
+using System.Collections.Generic;
+using Moq;
+using Newtonsoft.Json;
+using NUnit.Framework;
+using OptimizelySDK.Bucketing;
+using OptimizelySDK.Config;
+using OptimizelySDK.Entity;
+using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Logger;
+
+namespace OptimizelySDK.Tests
+{
+ [TestFixture]
+ public class BucketerBucketToEntityIdTest
+ {
+ [SetUp]
+ public void SetUp()
+ {
+ _loggerMock = new Mock();
+ }
+
+ private const string ExperimentId = "bucket_entity_exp";
+ private const string ExperimentKey = "bucket_entity_experiment";
+ private const string GroupId = "group_1";
+
+ private Mock _loggerMock;
+
+ [Test]
+ public void BucketToEntityIdAllowsBucketingWhenNoGroup()
+ {
+ var config = CreateConfig(new ConfigSetup { IncludeGroup = false });
+ var experiment = config.GetExperimentFromKey(ExperimentKey);
+ var bucketer = new Bucketer(_loggerMock.Object);
+
+ var fullAllocation = CreateTrafficAllocations(new TrafficAllocation
+ {
+ EntityId = "entity_123",
+ EndOfRange = 10000,
+ });
+ var fullResult = bucketer.BucketToEntityId(config, experiment, "bucketing_id", "user",
+ fullAllocation);
+ Assert.IsNotNull(fullResult.ResultObject);
+ Assert.AreEqual("entity_123", fullResult.ResultObject);
+
+ var zeroAllocation = CreateTrafficAllocations(new TrafficAllocation
+ {
+ EntityId = "entity_123",
+ EndOfRange = 0,
+ });
+ var zeroResult = bucketer.BucketToEntityId(config, experiment, "bucketing_id", "user",
+ zeroAllocation);
+ Assert.IsNull(zeroResult.ResultObject);
+ }
+
+ [Test]
+ public void BucketToEntityIdReturnsEntityIdWhenGroupAllowsUser()
+ {
+ var config = CreateConfig(new ConfigSetup
+ {
+ IncludeGroup = true,
+ GroupPolicy = "random",
+ GroupEndOfRange = 10000,
+ });
+
+ var experiment = config.GetExperimentFromKey(ExperimentKey);
+ var bucketer = new Bucketer(_loggerMock.Object);
+
+ var testCases = new[]
+ {
+ new { BucketingId = "ppid1", EntityId = "entity1" },
+ new { BucketingId = "ppid2", EntityId = "entity2" },
+ new { BucketingId = "ppid3", EntityId = "entity3" },
+ new
+ {
+ BucketingId =
+ "a very very very very very very very very very very very very very very very long ppd string",
+ EntityId = "entity4",
+ },
+ };
+
+ foreach (var testCase in testCases)
+ {
+ var allocation = CreateTrafficAllocations(new TrafficAllocation
+ {
+ EntityId = testCase.EntityId,
+ EndOfRange = 10000,
+ });
+ var result = bucketer.BucketToEntityId(config, experiment, testCase.BucketingId,
+ testCase.BucketingId, allocation);
+ Assert.AreEqual(testCase.EntityId, result.ResultObject,
+ $"Failed for {testCase.BucketingId}");
+ }
+ }
+
+ [Test]
+ public void BucketToEntityIdReturnsNullWhenGroupRejectsUser()
+ {
+ var config = CreateConfig(new ConfigSetup
+ {
+ IncludeGroup = true,
+ GroupPolicy = "random",
+ GroupEndOfRange = 0,
+ });
+
+ var experiment = config.GetExperimentFromKey(ExperimentKey);
+ var bucketer = new Bucketer(_loggerMock.Object);
+
+ var allocation = CreateTrafficAllocations(new TrafficAllocation
+ {
+ EntityId = "entity1",
+ EndOfRange = 10000,
+ });
+ var testCases = new[]
+ {
+ "ppid1",
+ "ppid2",
+ "ppid3",
+ "a very very very very very very very very very very very very very very very long ppd string",
+ };
+
+ foreach (var bucketingId in testCases)
+ {
+ var result = bucketer.BucketToEntityId(config, experiment, bucketingId, bucketingId,
+ allocation);
+ Assert.IsNull(result.ResultObject, $"Expected null for {bucketingId}");
+ }
+ }
+
+ [Test]
+ public void BucketToEntityIdAllowsBucketingWhenGroupOverlapping()
+ {
+ var config = CreateConfig(new ConfigSetup
+ {
+ IncludeGroup = true,
+ GroupPolicy = "overlapping",
+ GroupEndOfRange = 10000,
+ });
+
+ var experiment = config.GetExperimentFromKey(ExperimentKey);
+ var bucketer = new Bucketer(_loggerMock.Object);
+
+ var allocation = CreateTrafficAllocations(new TrafficAllocation
+ {
+ EntityId = "entity_overlapping",
+ EndOfRange = 10000,
+ });
+ var result =
+ bucketer.BucketToEntityId(config, experiment, "bucketing_id", "user", allocation);
+ Assert.AreEqual("entity_overlapping", result.ResultObject);
+ }
+
+ private static IList CreateTrafficAllocations(
+ params TrafficAllocation[] allocations
+ )
+ {
+ return new List(allocations);
+ }
+
+ private ProjectConfig CreateConfig(ConfigSetup setup)
+ {
+ if (setup == null)
+ {
+ setup = new ConfigSetup();
+ }
+
+ var datafile = BuildDatafile(setup);
+ return DatafileProjectConfig.Create(datafile, _loggerMock.Object,
+ new NoOpErrorHandler());
+ }
+
+ private static string BuildDatafile(ConfigSetup setup)
+ {
+ var variations = new object[]
+ {
+ new Dictionary
+ {
+ { "id", "var_1" },
+ { "key", "variation_1" },
+ { "variables", new object[0] },
+ },
+ };
+
+ var experiment = new Dictionary
+ {
+ { "status", "Running" },
+ { "key", ExperimentKey },
+ { "layerId", "layer_1" },
+ { "id", ExperimentId },
+ { "audienceIds", new string[0] },
+ { "audienceConditions", "[]" },
+ { "forcedVariations", new Dictionary() },
+ { "variations", variations },
+ {
+ "trafficAllocation", new object[]
+ {
+ new Dictionary
+ {
+ { "entityId", "var_1" },
+ { "endOfRange", 10000 },
+ },
+ }
+ },
+ };
+
+ object[] groups;
+ if (setup.IncludeGroup)
+ {
+ var groupExperiment = new Dictionary(experiment);
+ groupExperiment["trafficAllocation"] = new object[0];
+
+ groups = new object[]
+ {
+ new Dictionary
+ {
+ { "id", GroupId },
+ { "policy", setup.GroupPolicy },
+ {
+ "trafficAllocation", new object[]
+ {
+ new Dictionary
+ {
+ { "entityId", ExperimentId },
+ { "endOfRange", setup.GroupEndOfRange },
+ },
+ }
+ },
+ { "experiments", new object[] { groupExperiment } },
+ },
+ };
+ }
+ else
+ {
+ groups = new object[0];
+ }
+
+ var datafile = new Dictionary
+ {
+ { "version", "4" },
+ { "projectId", "project_1" },
+ { "accountId", "account_1" },
+ { "revision", "1" },
+ { "environmentKey", string.Empty },
+ { "sdkKey", string.Empty },
+ { "sendFlagDecisions", false },
+ { "anonymizeIP", false },
+ { "botFiltering", false },
+ { "attributes", new object[0] },
+ { "audiences", new object[0] },
+ { "typedAudiences", new object[0] },
+ { "events", new object[0] },
+ { "featureFlags", new object[0] },
+ { "rollouts", new object[0] },
+ { "integrations", new object[0] },
+ { "holdouts", new object[0] },
+ { "groups", groups },
+ { "experiments", new object[] { experiment } },
+ { "segments", new object[0] },
+ };
+
+ return JsonConvert.SerializeObject(datafile);
+ }
+
+ private class ConfigSetup
+ {
+ public bool IncludeGroup { get; set; }
+ public string GroupPolicy { get; set; }
+ public int GroupEndOfRange { get; set; }
+ }
+ }
+}
diff --git a/OptimizelySDK.Tests/BucketerHoldoutTest.cs b/OptimizelySDK.Tests/BucketerHoldoutTest.cs
new file mode 100644
index 000000000..742d82158
--- /dev/null
+++ b/OptimizelySDK.Tests/BucketerHoldoutTest.cs
@@ -0,0 +1,369 @@
+/*
+ * Copyright 2025, Optimizely
+ *
+ * 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.
+ */
+
+using System.IO;
+using Moq;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+using OptimizelySDK.Bucketing;
+using OptimizelySDK.Config;
+using OptimizelySDK.Entity;
+using OptimizelySDK.Logger;
+using OptimizelySDK.OptimizelyDecisions;
+
+namespace OptimizelySDK.Tests
+{
+ [TestFixture]
+ public class BucketerHoldoutTest
+ {
+ private Mock LoggerMock;
+ private Bucketer Bucketer;
+ private TestBucketer TestBucketer;
+ private ProjectConfig Config;
+ private JObject TestData;
+ private const string TestUserId = "test_user_id";
+ private const string TestBucketingId = "test_bucketing_id";
+
+ [SetUp]
+ public void Initialize()
+ {
+ LoggerMock = new Mock();
+
+ // Load holdout test data
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ TestData = JObject.Parse(jsonContent);
+
+ // Use datafile with holdouts for proper config setup
+ var datafileWithHoldouts = TestData["datafileWithHoldouts"].ToString();
+ Config = DatafileProjectConfig.Create(datafileWithHoldouts, LoggerMock.Object,
+ new ErrorHandler.NoOpErrorHandler());
+ TestBucketer = new TestBucketer(LoggerMock.Object);
+
+ // Verify that the config contains holdouts
+ Assert.IsNotNull(Config.Holdouts, "Config should have holdouts");
+ Assert.IsTrue(Config.Holdouts.Length > 0, "Config should contain holdouts");
+ }
+
+ [Test]
+ public void TestBucketHoldout_ValidTrafficAllocation()
+ {
+ // Test user bucketed within traffic allocation range
+ // Use the global holdout from config which has multiple variations
+ var holdout = Config.GetHoldout("holdout_global_1");
+ Assert.IsNotNull(holdout, "Holdout should exist in config");
+
+ // Set bucket value to be within first variation's traffic allocation (0-5000 range)
+ TestBucketer.SetBucketValues(new[] { 2500 });
+
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNotNull(result.ResultObject);
+ Assert.AreEqual("var_1", result.ResultObject.Id);
+ Assert.AreEqual("control", result.ResultObject.Key);
+
+ // Verify logging
+ LoggerMock.Verify(l => l.Log(LogLevel.DEBUG,
+ It.Is(s => s.Contains($"Assigned bucket [2500] to user [{TestUserId}]"))),
+ Times.Once);
+ }
+
+ [Test]
+ public void TestBucketHoldout_UserOutsideAllocation()
+ {
+ // Test user not bucketed when outside traffic allocation range
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ // Modify traffic allocation to be smaller (0-1000 range = 10%)
+ holdout.TrafficAllocation[0].EndOfRange = 1000;
+
+ // Set bucket value outside traffic allocation range
+ TestBucketer.SetBucketValues(new[] { 1500 });
+
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNull(result.ResultObject.Id);
+ Assert.IsNull(result.ResultObject.Key);
+
+ // Verify user was assigned bucket value but no variation was found
+ LoggerMock.Verify(l => l.Log(LogLevel.DEBUG,
+ It.Is(s => s.Contains($"Assigned bucket [1500] to user [{TestUserId}]"))),
+ Times.Once);
+ }
+
+ [Test]
+ public void TestBucketHoldout_NoTrafficAllocation()
+ {
+ // Test holdout with empty traffic allocation
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ // Clear traffic allocation
+ holdout.TrafficAllocation = new TrafficAllocation[0];
+
+ TestBucketer.SetBucketValues(new[] { 5000 });
+
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNull(result.ResultObject.Id);
+ Assert.IsNull(result.ResultObject.Key);
+
+ // Verify bucket was assigned but no variation found
+ LoggerMock.Verify(l => l.Log(LogLevel.DEBUG,
+ It.Is(s => s.Contains($"Assigned bucket [5000] to user [{TestUserId}]"))),
+ Times.Once);
+ }
+
+ [Test]
+ public void TestBucketHoldout_InvalidVariationId()
+ {
+ // Test holdout with invalid variation ID in traffic allocation
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ // Set traffic allocation to point to non-existent variation
+ holdout.TrafficAllocation[0].EntityId = "invalid_variation_id";
+
+ TestBucketer.SetBucketValues(new[] { 5000 });
+
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNull(result.ResultObject.Id);
+ Assert.IsNull(result.ResultObject.Key);
+
+ // Verify bucket was assigned
+ LoggerMock.Verify(l => l.Log(LogLevel.DEBUG,
+ It.Is(s => s.Contains($"Assigned bucket [5000] to user [{TestUserId}]"))),
+ Times.Once);
+ }
+
+ [Test]
+ public void TestBucketHoldout_EmptyVariations()
+ {
+ // Test holdout with no variations - use holdout from datafile that has no variations
+ var holdout = Config.GetHoldout("holdout_empty_1");
+ Assert.IsNotNull(holdout, "Empty holdout should exist in config");
+ Assert.AreEqual(0, holdout.Variations?.Length ?? 0, "Holdout should have no variations");
+
+ TestBucketer.SetBucketValues(new[] { 5000 });
+
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNull(result.ResultObject.Id);
+ Assert.IsNull(result.ResultObject.Key);
+
+ // Verify bucket was assigned
+ LoggerMock.Verify(l => l.Log(LogLevel.DEBUG,
+ It.Is(s => s.Contains($"Assigned bucket [5000] to user [{TestUserId}]"))),
+ Times.Once);
+ }
+
+ [Test]
+ public void TestBucketHoldout_EmptyExperimentKey()
+ {
+ // Test holdout with empty key
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ // Clear holdout key
+ holdout.Key = "";
+
+ TestBucketer.SetBucketValues(new[] { 5000 });
+
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ // Should return empty variation for invalid experiment key
+ Assert.IsNotNull(result.ResultObject);
+ Assert.IsNull(result.ResultObject.Id);
+ Assert.IsNull(result.ResultObject.Key);
+ }
+
+ [Test]
+ public void TestBucketHoldout_NullExperimentKey()
+ {
+ // Test holdout with null key
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ // Set holdout key to null
+ holdout.Key = null;
+
+ TestBucketer.SetBucketValues(new[] { 5000 });
+
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ // Should return empty variation for null experiment key
+ Assert.IsNotNull(result.ResultObject);
+ Assert.IsNull(result.ResultObject.Id);
+ Assert.IsNull(result.ResultObject.Key);
+ }
+
+ [Test]
+ public void TestBucketHoldout_MultipleVariationsInRange()
+ {
+ // Test holdout with multiple variations and user buckets into first one
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ // Add a second variation
+ var variation2 = new Variation
+ {
+ Id = "var_2",
+ Key = "treatment",
+ FeatureEnabled = true
+ };
+ holdout.Variations = new[] { holdout.Variations[0], variation2 };
+
+ // Set traffic allocation for first variation (0-5000) and second (5000-10000)
+ holdout.TrafficAllocation = new[]
+ {
+ new TrafficAllocation { EntityId = "var_1", EndOfRange = 5000 },
+ new TrafficAllocation { EntityId = "var_2", EndOfRange = 10000 }
+ };
+
+ // Test user buckets into first variation
+ TestBucketer.SetBucketValues(new[] { 2500 });
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNotNull(result.ResultObject);
+ Assert.AreEqual("var_1", result.ResultObject.Id);
+ Assert.AreEqual("control", result.ResultObject.Key);
+ }
+
+ [Test]
+ public void TestBucketHoldout_MultipleVariationsInSecondRange()
+ {
+ // Test holdout with multiple variations and user buckets into second one
+ // Use the global holdout from config which now has multiple variations
+ var holdout = Config.GetHoldout("holdout_global_1");
+ Assert.IsNotNull(holdout, "Holdout should exist in config");
+
+ // Verify holdout has multiple variations
+ Assert.IsTrue(holdout.Variations.Length >= 2, "Holdout should have multiple variations");
+ Assert.AreEqual("var_1", holdout.Variations[0].Id);
+ Assert.AreEqual("var_2", holdout.Variations[1].Id);
+
+ // Test user buckets into second variation (bucket value 7500 should be in 5000-10000 range)
+ TestBucketer.SetBucketValues(new[] { 7500 });
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNotNull(result.ResultObject);
+ Assert.AreEqual("var_2", result.ResultObject.Id);
+ Assert.AreEqual("treatment", result.ResultObject.Key);
+ }
+
+ [Test]
+ public void TestBucketHoldout_EdgeCaseBoundaryValues()
+ {
+ // Test edge cases at traffic allocation boundaries
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ // Set traffic allocation to 5000 (50%)
+ holdout.TrafficAllocation[0].EndOfRange = 5000;
+
+ // Test exact boundary value (should be included)
+ TestBucketer.SetBucketValues(new[] { 4999 });
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNotNull(result.ResultObject);
+ Assert.AreEqual("var_1", result.ResultObject.Id);
+
+ // Test value just outside boundary (should not be included)
+ TestBucketer.SetBucketValues(new[] { 5000 });
+ result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNull(result.ResultObject.Id);
+ }
+
+ [Test]
+ public void TestBucketHoldout_ConsistentBucketingWithSameInputs()
+ {
+ // Test that same inputs produce consistent results
+ // Use holdout from config instead of creating at runtime
+ var holdout = Config.GetHoldout("holdout_global_1");
+ Assert.IsNotNull(holdout, "Holdout should exist in config");
+
+ // Create a real bucketer (not test bucketer) for consistent hashing
+ var realBucketer = new Bucketing.Bucketer(LoggerMock.Object);
+ var result1 = realBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+ var result2 = realBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ // Results should be identical
+ Assert.IsNotNull(result1);
+ Assert.IsNotNull(result2);
+
+ if (result1.ResultObject?.Id != null)
+ {
+ Assert.AreEqual(result1.ResultObject.Id, result2.ResultObject.Id);
+ Assert.AreEqual(result1.ResultObject.Key, result2.ResultObject.Key);
+ }
+ else
+ {
+ Assert.IsNull(result2.ResultObject?.Id);
+ }
+ }
+
+ [Test]
+ public void TestBucketHoldout_DifferentBucketingIdsProduceDifferentResults()
+ {
+ // Test that different bucketing IDs can produce different results
+ // Use holdout from config instead of creating at runtime
+ var holdout = Config.GetHoldout("holdout_global_1");
+ Assert.IsNotNull(holdout, "Holdout should exist in config");
+
+ // Create a real bucketer (not test bucketer) for real hashing behavior
+ var realBucketer = new Bucketing.Bucketer(LoggerMock.Object);
+ var result1 = realBucketer.Bucket(Config, holdout, "bucketingId1", TestUserId);
+ var result2 = realBucketer.Bucket(Config, holdout, "bucketingId2", TestUserId);
+
+ // Results may be different (though not guaranteed due to hashing)
+ // This test mainly ensures no exceptions are thrown with different inputs
+ Assert.IsNotNull(result1);
+ Assert.IsNotNull(result2);
+ Assert.IsNotNull(result1.ResultObject);
+ Assert.IsNotNull(result2.ResultObject);
+ }
+
+ [Test]
+ public void TestBucketHoldout_VerifyDecisionReasons()
+ {
+ // Test that decision reasons are properly populated
+ var holdoutJson = TestData["globalHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ TestBucketer.SetBucketValues(new[] { 5000 });
+ var result = TestBucketer.Bucket(Config, holdout, TestBucketingId, TestUserId);
+
+ Assert.IsNotNull(result.DecisionReasons);
+ // Decision reasons should be populated from the bucketing process
+ // The exact content depends on whether the user was bucketed or not
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ LoggerMock = null;
+ Bucketer = null;
+ TestBucketer = null;
+ Config = null;
+ TestData = null;
+ }
+ }
+}
diff --git a/OptimizelySDK.Tests/CmabTests/DecisionServiceCmabTest.cs b/OptimizelySDK.Tests/CmabTests/DecisionServiceCmabTest.cs
new file mode 100644
index 000000000..a08af1528
--- /dev/null
+++ b/OptimizelySDK.Tests/CmabTests/DecisionServiceCmabTest.cs
@@ -0,0 +1,655 @@
+/*
+* Copyright 2025, Optimizely
+*
+* 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.
+*/
+
+using System;
+using System.Collections.Generic;
+using Moq;
+using NUnit.Framework;
+using OptimizelySDK.Bucketing;
+using OptimizelySDK.Cmab;
+using OptimizelySDK.Config;
+using OptimizelySDK.Entity;
+using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Logger;
+using OptimizelySDK.OptimizelyDecisions;
+using OptimizelySDK.Odp;
+using AttributeEntity = OptimizelySDK.Entity.Attribute;
+
+namespace OptimizelySDK.Tests.CmabTests
+{
+ [TestFixture]
+ public class DecisionServiceCmabTest
+ {
+ private Mock _loggerMock;
+ private Mock _errorHandlerMock;
+ private Mock _bucketerMock;
+ private Mock _cmabServiceMock;
+ private DecisionService _decisionService;
+ private ProjectConfig _config;
+ private Optimizely _optimizely;
+
+ private const string TEST_USER_ID = "test_user_cmab";
+ private const string TEST_EXPERIMENT_KEY = "test_experiment";
+ private const string TEST_EXPERIMENT_ID = "111127";
+ private const string VARIATION_A_ID = "111128";
+ private const string VARIATION_A_KEY = "control";
+ private const string TEST_CMAB_UUID = "uuid-123-456";
+ private const string AGE_ATTRIBUTE_KEY = "age";
+
+ [SetUp]
+ public void SetUp()
+ {
+ _loggerMock = new Mock();
+ _errorHandlerMock = new Mock();
+ _bucketerMock = new Mock(_loggerMock.Object);
+ _cmabServiceMock = new Mock();
+
+ _config = DatafileProjectConfig.Create(TestData.Datafile, _loggerMock.Object,
+ _errorHandlerMock.Object);
+
+ _decisionService = new DecisionService(_bucketerMock.Object, _errorHandlerMock.Object,
+ null, _loggerMock.Object, _cmabServiceMock.Object);
+
+ _optimizely = new Optimizely(TestData.Datafile, null, _loggerMock.Object,
+ _errorHandlerMock.Object);
+ }
+
+ ///
+ /// Verifies that GetVariation returns correct variation with CMAB UUID
+ ///
+ [Test]
+ public void TestGetVariationWithCmabExperimentReturnsVariation()
+ {
+ var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000);
+ var userContext = _optimizely.CreateUserContext(TEST_USER_ID);
+ var variation = new Variation { Id = VARIATION_A_ID, Key = VARIATION_A_KEY };
+
+ _bucketerMock.Setup(b => b.BucketToEntityId(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )).Returns(Result.NewResult("$", new DecisionReasons()));
+
+ _cmabServiceMock.Setup(c => c.GetDecision(
+ It.IsAny(),
+ It.IsAny(),
+ TEST_EXPERIMENT_ID,
+ It.IsAny()
+ )).Returns(new CmabDecision(VARIATION_A_ID, TEST_CMAB_UUID));
+
+ var mockConfig = CreateMockConfig(experiment, variation);
+
+ var result = _decisionService.GetVariation(experiment, userContext, mockConfig.Object);
+
+ Assert.IsNotNull(result);
+ Assert.IsNotNull(result.ResultObject, "VariationDecisionResult should be returned");
+ Assert.IsNotNull(result.ResultObject.Variation, "Variation should be returned");
+ Assert.AreEqual(VARIATION_A_KEY, result.ResultObject.Variation.Key);
+ Assert.AreEqual(VARIATION_A_ID, result.ResultObject.Variation.Id);
+ Assert.AreEqual(TEST_CMAB_UUID, result.ResultObject.CmabUuid);
+
+ _cmabServiceMock.Verify(c => c.GetDecision(
+ It.IsAny(),
+ It.IsAny(),
+ TEST_EXPERIMENT_ID,
+ It.IsAny()
+ ), Times.Once);
+ }
+
+ ///
+ /// Verifies that with 0 traffic allocation, CMAB service is not called
+ ///
+ [Test]
+ public void TestGetVariationWithCmabExperimentZeroTrafficAllocation()
+ {
+ var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 0);
+ var userContext = _optimizely.CreateUserContext(TEST_USER_ID);
+
+ _bucketerMock.Setup(b => b.BucketToEntityId(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )).Returns(Result.NullResult(new DecisionReasons()));
+
+ var mockConfig = CreateMockConfig(experiment, null);
+
+ var result = _decisionService.GetVariation(experiment, userContext, mockConfig.Object);
+
+ Assert.IsNotNull(result);
+ Assert.IsNull(result.ResultObject.Variation, "No variation should be returned with 0 traffic");
+ Assert.IsNull(result.ResultObject.CmabUuid);
+
+ var reasons = result.DecisionReasons.ToReport(true);
+ var expectedMessage =
+ $"User [{TEST_USER_ID}] not in CMAB experiment [{TEST_EXPERIMENT_KEY}] due to traffic allocation.";
+ Assert.Contains(expectedMessage, reasons);
+
+ _cmabServiceMock.Verify(c => c.GetDecision(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ ), Times.Never);
+ }
+
+ ///
+ /// Verifies error handling when CMAB service throws exception
+ ///
+ [Test]
+ public void TestGetVariationWithCmabExperimentServiceError()
+ {
+ var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000);
+ var userContext = _optimizely.CreateUserContext(TEST_USER_ID);
+
+ _bucketerMock.Setup(b => b.BucketToEntityId(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )).Returns(Result.NewResult("$", new DecisionReasons()));
+
+ _cmabServiceMock.Setup(c => c.GetDecision(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ )).Throws(new Exception("CMAB service error"));
+
+ var mockConfig = CreateMockConfig(experiment, null);
+
+ var result = _decisionService.GetVariation(experiment, userContext, mockConfig.Object);
+
+ Assert.IsNotNull(result);
+ Assert.IsNull(result.ResultObject.Variation, "Should return null on error");
+ Assert.IsTrue(result.ResultObject.Error);
+
+ var reasonsList = result.DecisionReasons.ToReport(true);
+
+ Assert.IsTrue(reasonsList.Exists(reason =>
+ reason.Contains(
+ $"Failed to fetch CMAB data for experiment {TEST_EXPERIMENT_KEY}.")),
+ $"Decision reasons should include CMAB fetch failure. Actual reasons: {string.Join(", ", reasonsList)}");
+
+ _cmabServiceMock.Verify(c => c.GetDecision(
+ It.IsAny(),
+ It.IsAny(),
+ TEST_EXPERIMENT_ID,
+ It.IsAny()
+ ), Times.Once);
+ }
+
+ ///
+ /// Verifies behavior when CMAB service returns an unknown variation ID
+ ///
+ [Test]
+ public void TestGetVariationWithCmabExperimentUnknownVariationId()
+ {
+ var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000);
+ var userContext = _optimizely.CreateUserContext(TEST_USER_ID);
+
+ _bucketerMock.Setup(b => b.BucketToEntityId(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )).Returns(Result.NewResult("$", new DecisionReasons()));
+
+ const string unknownVariationId = "unknown_var";
+ _cmabServiceMock.Setup(c => c.GetDecision(
+ It.IsAny(),
+ It.IsAny(),
+ TEST_EXPERIMENT_ID,
+ It.IsAny()
+ )).Returns(new CmabDecision(unknownVariationId, TEST_CMAB_UUID));
+
+ var mockConfig = CreateMockConfig(experiment, null);
+
+ var result = _decisionService.GetVariation(experiment, userContext, mockConfig.Object);
+
+ Assert.IsNotNull(result);
+ Assert.IsNull(result.ResultObject.Variation, "Should return null on error");
+
+ var reasons = result.DecisionReasons.ToReport(true);
+ var expectedMessage =
+ $"User [{TEST_USER_ID}] bucketed into invalid variation [{unknownVariationId}] for CMAB experiment [{TEST_EXPERIMENT_KEY}].";
+ Assert.Contains(expectedMessage, reasons);
+
+ _cmabServiceMock.Verify(c => c.GetDecision(
+ It.IsAny(),
+ It.IsAny(),
+ TEST_EXPERIMENT_ID,
+ It.IsAny()
+ ), Times.Once);
+ }
+
+ ///
+ /// Verifies that cached decisions skip CMAB service call
+ ///
+ [Test]
+ public void TestGetVariationWithCmabExperimentCacheHit()
+ {
+ var attributeIds = new List { "age_attr_id" };
+ var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000,
+ attributeIds);
+ var variation = new Variation { Id = VARIATION_A_ID, Key = VARIATION_A_KEY };
+
+ _bucketerMock.Setup(b => b.BucketToEntityId(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )).Returns(Result.NewResult("$", new DecisionReasons()));
+
+ var attributeMap = new Dictionary
+ {
+ { "age_attr_id", new AttributeEntity { Id = "age_attr_id", Key = AGE_ATTRIBUTE_KEY } }
+ };
+ var mockConfig = CreateMockConfig(experiment, variation, attributeMap);
+
+ var cmabClientMock = new Mock(MockBehavior.Strict);
+ cmabClientMock.Setup(c => c.FetchDecision(
+ TEST_EXPERIMENT_ID,
+ TEST_USER_ID,
+ It.Is>(attrs =>
+ attrs.Count == 1 && attrs.ContainsKey(AGE_ATTRIBUTE_KEY) &&
+ (int)attrs[AGE_ATTRIBUTE_KEY] == 25),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(VARIATION_A_ID);
+
+ var cache = new LruCache(maxSize: 10,
+ itemTimeout: TimeSpan.FromMinutes(5),
+ logger: new NoOpLogger());
+ var cmabService = new DefaultCmabService(cache, cmabClientMock.Object, new NoOpLogger());
+ var decisionService = new DecisionService(_bucketerMock.Object, _errorHandlerMock.Object,
+ null, _loggerMock.Object, cmabService);
+
+ var userContext = _optimizely.CreateUserContext(TEST_USER_ID);
+ userContext.SetAttribute(AGE_ATTRIBUTE_KEY, 25);
+
+ var result1 = decisionService.GetVariation(experiment, userContext, mockConfig.Object);
+ var result2 = decisionService.GetVariation(experiment, userContext, mockConfig.Object);
+
+ Assert.IsNotNull(result1.ResultObject);
+ Assert.IsNotNull(result2.ResultObject);
+ Assert.AreEqual(result1.ResultObject.Variation.Key, result2.ResultObject.Variation.Key);
+ Assert.IsNotNull(result1.ResultObject.CmabUuid);
+ Assert.AreEqual(result1.ResultObject.CmabUuid, result2.ResultObject.CmabUuid);
+
+ cmabClientMock.Verify(c => c.FetchDecision(
+ TEST_EXPERIMENT_ID,
+ TEST_USER_ID,
+ It.Is>(attrs =>
+ attrs.Count == 1 && (int)attrs[AGE_ATTRIBUTE_KEY] == 25),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies that changing attributes invalidates cache
+ ///
+ [Test]
+ public void TestGetVariationWithCmabExperimentCacheMissAttributesChanged()
+ {
+ var attributeIds = new List { "age_attr_id" };
+ var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000,
+ attributeIds);
+ var variation = new Variation { Id = VARIATION_A_ID, Key = VARIATION_A_KEY };
+ var attributeMap = new Dictionary
+ {
+ { "age_attr_id", new AttributeEntity { Id = "age_attr_id", Key = AGE_ATTRIBUTE_KEY } }
+ };
+ var mockConfig = CreateMockConfig(experiment, variation, attributeMap);
+
+ _bucketerMock.Setup(b => b.BucketToEntityId(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )).Returns(Result.NewResult("$", new DecisionReasons()));
+
+ var cmabClientMock = new Mock(MockBehavior.Strict);
+ cmabClientMock.Setup(c => c.FetchDecision(
+ TEST_EXPERIMENT_ID,
+ TEST_USER_ID,
+ It.Is>(attrs => attrs.ContainsKey(AGE_ATTRIBUTE_KEY)),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(VARIATION_A_ID);
+
+ var cache = new LruCache(maxSize: 10,
+ itemTimeout: TimeSpan.FromMinutes(5),
+ logger: new NoOpLogger());
+ var cmabService = new DefaultCmabService(cache, cmabClientMock.Object, new NoOpLogger());
+ var decisionService = new DecisionService(_bucketerMock.Object, _errorHandlerMock.Object,
+ null, _loggerMock.Object, cmabService);
+
+ var userContext = _optimizely.CreateUserContext(TEST_USER_ID);
+
+ userContext.SetAttribute(AGE_ATTRIBUTE_KEY, 25);
+ var result1 = decisionService.GetVariation(experiment, userContext, mockConfig.Object);
+
+ userContext.SetAttribute(AGE_ATTRIBUTE_KEY, 30);
+ var result2 = decisionService.GetVariation(experiment, userContext, mockConfig.Object);
+
+ Assert.IsNotNull(result1.ResultObject);
+ Assert.IsNotNull(result2.ResultObject);
+ Assert.IsNotNull(result1.ResultObject.CmabUuid);
+ Assert.IsNotNull(result2.ResultObject.CmabUuid);
+ Assert.AreNotEqual(result1.ResultObject.CmabUuid, result2.ResultObject.CmabUuid);
+
+ cmabClientMock.Verify(c => c.FetchDecision(
+ TEST_EXPERIMENT_ID,
+ TEST_USER_ID,
+ It.Is>(attrs =>
+ attrs.ContainsKey(AGE_ATTRIBUTE_KEY) && (int)attrs[AGE_ATTRIBUTE_KEY] == 25),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ cmabClientMock.Verify(c => c.FetchDecision(
+ TEST_EXPERIMENT_ID,
+ TEST_USER_ID,
+ It.Is>(attrs =>
+ attrs.ContainsKey(AGE_ATTRIBUTE_KEY) && (int)attrs[AGE_ATTRIBUTE_KEY] == 30),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies GetVariationForFeatureExperiment works with CMAB
+ ///
+ [Test]
+ public void TestGetVariationForFeatureExperimentWithCmab()
+ {
+ var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000);
+ var userContext = _optimizely.CreateUserContext(TEST_USER_ID);
+ var variation = new Variation { Id = VARIATION_A_ID, Key = VARIATION_A_KEY };
+
+ _bucketerMock.Setup(b => b.BucketToEntityId(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )).Returns(Result.NewResult("$", new DecisionReasons()));
+
+ _cmabServiceMock.Setup(c => c.GetDecision(
+ It.IsAny(),
+ It.IsAny(),
+ TEST_EXPERIMENT_ID,
+ It.IsAny()
+ )).Returns(new CmabDecision(VARIATION_A_ID, TEST_CMAB_UUID));
+
+ var mockConfig = CreateMockConfig(experiment, variation);
+
+ // GetVariationForFeatureExperiment requires a FeatureFlag, not just an Experiment
+ // For this test, we'll use GetVariation instead since we're testing CMAB decision flow
+ var result = _decisionService.GetVariation(experiment, userContext, mockConfig.Object,
+ new OptimizelyDecideOption[] { });
+
+ // Assert
+ Assert.IsNotNull(result);
+ Assert.IsNotNull(result.ResultObject);
+ Assert.AreEqual(VARIATION_A_KEY, result.ResultObject.Variation.Key);
+ Assert.AreEqual(TEST_CMAB_UUID, result.ResultObject.CmabUuid);
+ }
+
+ ///
+ /// Verifies GetVariationForFeature works with CMAB experiments in feature flags
+ ///
+ [Test]
+ public void TestGetVariationForFeatureWithCmabExperiment()
+ {
+ // Arrange
+ var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000);
+ var userContext = _optimizely.CreateUserContext(TEST_USER_ID);
+ var variation = new Variation
+ {
+ Id = VARIATION_A_ID,
+ Key = VARIATION_A_KEY,
+ FeatureEnabled = true
+ };
+
+ _bucketerMock.Setup(b => b.BucketToEntityId(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )).Returns(Result.NewResult("$", new DecisionReasons()));
+
+ _cmabServiceMock.Setup(c => c.GetDecision(
+ It.IsAny(),
+ It.IsAny(),
+ TEST_EXPERIMENT_ID,
+ It.IsAny()
+ )).Returns(new CmabDecision(VARIATION_A_ID, TEST_CMAB_UUID));
+
+ var mockConfig = CreateMockConfig(experiment, variation);
+
+ var result = _decisionService.GetVariation(experiment, userContext, mockConfig.Object);
+
+ // Assert
+ Assert.IsNotNull(result);
+ Assert.IsNotNull(result.ResultObject);
+ Assert.IsTrue(result.ResultObject.Variation.FeatureEnabled == true);
+ Assert.AreEqual(TEST_CMAB_UUID, result.ResultObject.CmabUuid);
+ }
+
+ ///
+ /// Verifies only relevant attributes are sent to CMAB service
+ ///
+ [Test]
+ public void TestGetDecisionForCmabExperimentAttributeFiltering()
+ {
+ var attributeIds = new List { "age_attr_id", "location_attr_id" };
+ var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000,
+ attributeIds);
+ var variation = new Variation { Id = VARIATION_A_ID, Key = VARIATION_A_KEY };
+ var attributeMap = new Dictionary
+ {
+ { "age_attr_id", new AttributeEntity { Id = "age_attr_id", Key = "age" } },
+ { "location_attr_id", new AttributeEntity { Id = "location_attr_id", Key = "location" } }
+ };
+ var mockConfig = CreateMockConfig(experiment, variation, attributeMap);
+
+ _bucketerMock.Setup(b => b.BucketToEntityId(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )).Returns(Result.NewResult("$", new DecisionReasons()));
+
+ var cmabClientMock = new Mock(MockBehavior.Strict);
+ cmabClientMock.Setup(c => c.FetchDecision(
+ TEST_EXPERIMENT_ID,
+ TEST_USER_ID,
+ It.Is>(attrs =>
+ attrs.Count == 2 && (int)attrs["age"] == 25 &&
+ (string)attrs["location"] == "USA" &&
+ !attrs.ContainsKey("extra")),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(VARIATION_A_ID);
+
+ var cache = new LruCache(maxSize: 10,
+ itemTimeout: TimeSpan.FromMinutes(5),
+ logger: new NoOpLogger());
+ var cmabService = new DefaultCmabService(cache, cmabClientMock.Object, new NoOpLogger());
+ var decisionService = new DecisionService(_bucketerMock.Object, _errorHandlerMock.Object,
+ null, _loggerMock.Object, cmabService);
+
+ var userContext = _optimizely.CreateUserContext(TEST_USER_ID);
+ userContext.SetAttribute("age", 25);
+ userContext.SetAttribute("location", "USA");
+ userContext.SetAttribute("extra", "value");
+
+ var result = decisionService.GetVariation(experiment, userContext, mockConfig.Object);
+
+ Assert.IsNotNull(result);
+ Assert.IsNotNull(result.ResultObject);
+ Assert.IsNotNull(result.ResultObject.CmabUuid);
+
+ cmabClientMock.VerifyAll();
+ }
+
+ ///
+ /// Verifies CMAB service receives an empty attribute payload when no CMAB attribute IDs are
+ /// configured
+ ///
+ [Test]
+ public void TestGetDecisionForCmabExperimentNoAttributeIds()
+ {
+ var experiment = CreateCmabExperiment(TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY, 10000,
+ null);
+ var variation = new Variation { Id = VARIATION_A_ID, Key = VARIATION_A_KEY };
+ var mockConfig = CreateMockConfig(experiment, variation, new Dictionary());
+
+ _bucketerMock.Setup(b => b.BucketToEntityId(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()
+ )).Returns(Result.NewResult("$", new DecisionReasons()));
+
+ var cmabClientMock = new Mock(MockBehavior.Strict);
+ cmabClientMock.Setup(c => c.FetchDecision(
+ TEST_EXPERIMENT_ID,
+ TEST_USER_ID,
+ It.Is>(attrs => attrs.Count == 0),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(VARIATION_A_ID);
+
+ var cache = new LruCache(maxSize: 10,
+ itemTimeout: TimeSpan.FromMinutes(5),
+ logger: new NoOpLogger());
+ var cmabService = new DefaultCmabService(cache, cmabClientMock.Object, new NoOpLogger());
+ var decisionService = new DecisionService(_bucketerMock.Object, _errorHandlerMock.Object,
+ null, _loggerMock.Object, cmabService);
+
+ var userContext = _optimizely.CreateUserContext(TEST_USER_ID);
+ userContext.SetAttribute("age", 25);
+ userContext.SetAttribute("location", "USA");
+
+ var result = decisionService.GetVariation(experiment, userContext, mockConfig.Object);
+
+ Assert.IsNotNull(result);
+ Assert.IsNotNull(result.ResultObject);
+ Assert.IsNotNull(result.ResultObject.CmabUuid);
+
+ cmabClientMock.VerifyAll();
+ }
+
+ ///
+ /// Verifies regular experiments are not affected by CMAB logic
+ ///
+ [Test]
+ public void TestGetVariationNonCmabExperimentNotAffected()
+ {
+ var experiment = _config.GetExperimentFromKey(TEST_EXPERIMENT_KEY);
+ Assert.IsNotNull(experiment);
+ Assert.IsNull(experiment.Cmab, "Should be a non-CMAB experiment");
+
+ var userContext = _optimizely.CreateUserContext(TEST_USER_ID);
+ var variation = _config.GetVariationFromKey(TEST_EXPERIMENT_KEY, VARIATION_A_KEY);
+
+ // Create decision service WITHOUT CMAB service
+ var decisionServiceWithoutCmab = new DecisionService(
+ new Bucketer(_loggerMock.Object),
+ _errorHandlerMock.Object,
+ null,
+ _loggerMock.Object,
+ null // No CMAB service
+ );
+
+ var result = decisionServiceWithoutCmab.GetVariation(experiment, userContext, _config);
+
+ Assert.IsNotNull(result);
+ // Standard bucketing should work normally
+ // Verify CMAB service was never called
+ _cmabServiceMock.Verify(c => c.GetDecision(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ ), Times.Never);
+ }
+
+ #region Helper Methods
+
+ ///
+ /// Creates a CMAB experiment for testing
+ ///
+ private Experiment CreateCmabExperiment(string id, string key, int trafficAllocation,
+ List attributeIds = null)
+ {
+ return new Experiment
+ {
+ Id = id,
+ Key = key,
+ LayerId = "layer_1",
+ Status = "Running",
+ TrafficAllocation = new TrafficAllocation[0],
+ ForcedVariations = new Dictionary(), // UserIdToKeyVariations is an alias for this
+ Cmab = new Entity.Cmab(attributeIds ?? new List(), trafficAllocation)
+ };
+ }
+
+ ///
+ /// Creates a mock ProjectConfig with the experiment and variation
+ ///
+ private Mock CreateMockConfig(Experiment experiment, Variation variation,
+ Dictionary attributeMap = null)
+ {
+ var mockConfig = new Mock();
+
+ var experimentMap = new Dictionary
+ {
+ { experiment.Id, experiment }
+ };
+
+ mockConfig.Setup(c => c.ExperimentIdMap).Returns(experimentMap);
+ mockConfig.Setup(c => c.GetExperimentFromKey(experiment.Key)).Returns(experiment);
+ mockConfig.Setup(c => c.GetExperimentFromId(experiment.Id)).Returns(experiment);
+
+ if (variation != null)
+ {
+ mockConfig.Setup(c => c.GetVariationFromIdByExperimentId(experiment.Id,
+ variation.Id)).Returns(variation);
+ }
+
+ mockConfig.Setup(c => c.AttributeIdMap)
+ .Returns(attributeMap ?? new Dictionary());
+
+ return mockConfig;
+ }
+
+ #endregion
+ }
+}
diff --git a/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs
new file mode 100644
index 000000000..3ff1de966
--- /dev/null
+++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabClientTest.cs
@@ -0,0 +1,216 @@
+/*
+* Copyright 2025, Optimizely
+*
+* 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.
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Moq;
+using Moq.Protected;
+using NUnit.Framework;
+using OptimizelySDK.Cmab;
+using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Exceptions;
+using OptimizelySDK.Logger;
+
+namespace OptimizelySDK.Tests.CmabTests
+{
+ [TestFixture]
+ public class DefaultCmabClientTest
+ {
+ private class ResponseStep
+ {
+ public HttpStatusCode Status { get; private set; }
+ public string Body { get; private set; }
+ public ResponseStep(HttpStatusCode status, string body)
+ {
+ Status = status;
+ Body = body;
+ }
+ }
+
+ private static HttpClient MakeClient(params ResponseStep[] sequence)
+ {
+ var handler = new Mock(MockBehavior.Strict);
+ var queue = new Queue(sequence);
+
+ handler.Protected().Setup>("SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny()).Returns((HttpRequestMessage _, CancellationToken __) =>
+ {
+ if (queue.Count == 0)
+ throw new InvalidOperationException("No more mocked responses available.");
+
+ var step = queue.Dequeue();
+ var response = new HttpResponseMessage(step.Status);
+ if (step.Body != null)
+ {
+ response.Content = new StringContent(step.Body);
+ }
+ return Task.FromResult(response);
+ });
+
+ return new HttpClient(handler.Object);
+ }
+
+ private static HttpClient MakeClientExceptionSequence(params Exception[] sequence)
+ {
+ var handler = new Mock(MockBehavior.Strict);
+ var queue = new Queue(sequence);
+
+ handler.Protected().Setup>("SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny()).Returns((HttpRequestMessage _, CancellationToken __) =>
+ {
+ if (queue.Count == 0)
+ throw new InvalidOperationException("No more mocked exceptions available.");
+
+ var ex = queue.Dequeue();
+ var tcs = new TaskCompletionSource();
+ tcs.SetException(ex);
+ return tcs.Task;
+ });
+
+ return new HttpClient(handler.Object);
+ }
+
+ private static string ValidBody(string variationId = "v1")
+ => $"{{\"predictions\":[{{\"variation_id\":\"{variationId}\"}}]}}";
+
+ [Test]
+ public void FetchDecisionReturnsSuccessNoRetry()
+ {
+ var http = MakeClient(new ResponseStep(HttpStatusCode.OK, ValidBody("v1")));
+ var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null, logger: new NoOpLogger(), errorHandler: new NoOpErrorHandler());
+ var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");
+
+ Assert.AreEqual("v1", result);
+ }
+
+ [Test]
+ public void FetchDecisionHttpExceptionNoRetry()
+ {
+ var http = MakeClientExceptionSequence(new HttpRequestException("boom"));
+ var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null);
+
+ Assert.Throws(() =>
+ client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
+ }
+
+ [Test]
+ public void FetchDecisionNon2xxNoRetry()
+ {
+ var http = MakeClient(new ResponseStep(HttpStatusCode.InternalServerError, null));
+ var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null);
+
+ Assert.Throws(() =>
+ client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
+ }
+
+ [Test]
+ public void FetchDecisionInvalidJsonNoRetry()
+ {
+ var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "not json"));
+ var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null);
+
+ Assert.Throws(() =>
+ client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
+ }
+
+ [Test]
+ public void FetchDecisionInvalidStructureNoRetry()
+ {
+ var http = MakeClient(new ResponseStep(HttpStatusCode.OK, "{\"predictions\":[]}"));
+ var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retryConfig: null);
+
+ Assert.Throws(() =>
+ client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
+ }
+
+ [Test]
+ public void FetchDecisionSuccessWithRetryFirstTry()
+ {
+ var http = MakeClient(new ResponseStep(HttpStatusCode.OK, ValidBody("v2")));
+ var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
+ var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry);
+ var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");
+
+ Assert.AreEqual("v2", result);
+ }
+
+ [Test]
+ public void FetchDecisionSuccessWithRetryThirdTry()
+ {
+ var http = MakeClient(
+ new ResponseStep(HttpStatusCode.InternalServerError, null),
+ new ResponseStep(HttpStatusCode.InternalServerError, null),
+ new ResponseStep(HttpStatusCode.OK, ValidBody("v3"))
+ );
+ var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
+ var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry);
+ var result = client.FetchDecision("rule-1", "user-1", null, "uuid-1");
+
+ Assert.AreEqual("v3", result);
+ }
+
+ [Test]
+ public void FetchDecisionExhaustsAllRetries()
+ {
+ var http = MakeClient(
+ new ResponseStep(HttpStatusCode.InternalServerError, null),
+ new ResponseStep(HttpStatusCode.InternalServerError, null),
+ new ResponseStep(HttpStatusCode.InternalServerError, null)
+ );
+ var retry = new CmabRetryConfig(maxRetries: 2, initialBackoff: TimeSpan.Zero, maxBackoff: TimeSpan.FromSeconds(1), backoffMultiplier: 2.0);
+ var client = new DefaultCmabClient(CmabConstants.DEFAULT_PREDICTION_URL_TEMPLATE, http, retry);
+
+ Assert.Throws(() =>
+ client.FetchDecision("rule-1", "user-1", null, "uuid-1"));
+ }
+
+ [Test]
+ public void FetchDecision_CustomEndpoint_CallsCorrectUrl()
+ {
+ var customEndpoint = "https://custom.example.com/api/%7B0%7D";
+ string capturedUrl = null;
+
+ var handler = new Mock(MockBehavior.Strict);
+ handler.Protected()
+ .Setup>("SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .Returns((HttpRequestMessage req, CancellationToken _) =>
+ {
+ capturedUrl = req.RequestUri.ToString();
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(ValidBody("variation123"))
+ };
+ return Task.FromResult(response);
+ });
+
+ var http = new HttpClient(handler.Object);
+ var client = new DefaultCmabClient(customEndpoint, http, retryConfig: null);
+ var result = client.FetchDecision("rule-456", "user-1", null, "uuid-1");
+
+ Assert.AreEqual("variation123", result);
+ Assert.AreEqual("https://custom.example.com/api/rule-456", capturedUrl,
+ "Should call custom endpoint with rule ID formatted into template");
+ }
+ }
+}
diff --git a/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs
new file mode 100644
index 000000000..2e101f266
--- /dev/null
+++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs
@@ -0,0 +1,656 @@
+/*
+ * Copyright 2025, Optimizely
+ *
+ * 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.
+ */
+
+using System;
+using System.Collections.Generic;
+using Moq;
+using NUnit.Framework;
+using OptimizelySDK.Cmab;
+using OptimizelySDK.Config;
+using OptimizelySDK.Entity;
+using OptimizelySDK.ErrorHandler;
+using OptimizelySDK.Logger;
+using OptimizelySDK.Odp;
+using OptimizelySDK.OptimizelyDecisions;
+using OptimizelySDK.Tests.Utils;
+using OptimizelySDK.Utils;
+using AttributeEntity = OptimizelySDK.Entity.Attribute;
+
+namespace OptimizelySDK.Tests.CmabTests
+{
+ [TestFixture]
+ public class DefaultCmabServiceTest
+ {
+ [SetUp]
+ public void SetUp()
+ {
+ _mockCmabClient = new Mock(MockBehavior.Strict);
+ _logger = new NoOpLogger();
+ _cmabCache = new LruCache(10, TimeSpan.FromMinutes(5), _logger);
+ _cmabService = new DefaultCmabService(_cmabCache, _mockCmabClient.Object, _logger);
+
+ _config = DatafileProjectConfig.Create(TestData.Datafile, _logger,
+ new NoOpErrorHandler());
+ _optimizely = new Optimizely(TestData.Datafile, null, _logger, new NoOpErrorHandler());
+ }
+
+ private Mock _mockCmabClient;
+ private LruCache _cmabCache;
+ private DefaultCmabService _cmabService;
+ private ILogger _logger;
+ private ProjectConfig _config;
+ private Optimizely _optimizely;
+
+ private const string TEST_RULE_ID = "exp1";
+ private const string TEST_USER_ID = "user123";
+ private const string AGE_ATTRIBUTE_ID = "66";
+ private const string LOCATION_ATTRIBUTE_ID = "77";
+
+ [Test]
+ public void ReturnsDecisionFromCacheWhenHashMatches()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } },
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID,
+ new Dictionary { { "age", 25 } });
+ var filteredAttributes =
+ new UserAttributes(new Dictionary { { "age", 25 } });
+ var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+
+ _cmabCache.Save(cacheKey, new CmabCacheEntry
+ {
+ AttributesHash = DefaultCmabService.HashAttributes(filteredAttributes),
+ CmabUuid = "uuid-cached",
+ VariationId = "varA",
+ });
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID);
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varA", decision.VariationId);
+ Assert.AreEqual("uuid-cached", decision.CmabUuid);
+ _mockCmabClient.Verify(
+ c => c.FetchDecision(It.IsAny(), It.IsAny(),
+ It.IsAny>(), It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public void IgnoresCacheWhenOptionSpecified()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } },
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID,
+ new Dictionary { { "age", 25 } });
+ var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs =>
+ attrs != null && attrs.Count == 1 && attrs.ContainsKey("age") &&
+ (int)attrs["age"] == 25),
+ It.IsAny(),
+ It.IsAny())).Returns("varB");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID,
+ new[] { OptimizelyDecideOption.IGNORE_CMAB_CACHE });
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varB", decision.VariationId);
+ Assert.IsNull(_cmabCache.Lookup(cacheKey));
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void ResetsCacheWhenOptionSpecified()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } },
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID,
+ new Dictionary { { "age", 25 } });
+ var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+
+ _cmabCache.Save(cacheKey, new CmabCacheEntry
+ {
+ AttributesHash = "stale",
+ CmabUuid = "uuid-old",
+ VariationId = "varOld",
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs =>
+ attrs.Count == 1 && (int)attrs["age"] == 25),
+ It.IsAny(),
+ It.IsAny())).Returns("varNew");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID,
+ new[] { OptimizelyDecideOption.RESET_CMAB_CACHE });
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varNew", decision.VariationId);
+ var cachedEntry = _cmabCache.Lookup(cacheKey);
+ Assert.IsNotNull(cachedEntry);
+ Assert.AreEqual("varNew", cachedEntry.VariationId);
+ Assert.AreEqual(decision.CmabUuid, cachedEntry.CmabUuid);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void InvalidatesUserEntryWhenOptionSpecified()
+ {
+ var otherUserId = "other";
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } },
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID,
+ new Dictionary { { "age", 25 } });
+
+ var targetKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+ var otherKey = DefaultCmabService.GetCacheKey(otherUserId, TEST_RULE_ID);
+
+ _cmabCache.Save(targetKey, new CmabCacheEntry
+ {
+ AttributesHash = "old_hash",
+ CmabUuid = "uuid-old",
+ VariationId = "varOld",
+ });
+ _cmabCache.Save(otherKey, new CmabCacheEntry
+ {
+ AttributesHash = "other_hash",
+ CmabUuid = "uuid-other",
+ VariationId = "varOther",
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs =>
+ attrs.Count == 1 && (int)attrs["age"] == 25),
+ It.IsAny(),
+ It.IsAny())).Returns("varNew");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID,
+ new[] { OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE });
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varNew", decision.VariationId);
+ var updatedEntry = _cmabCache.Lookup(targetKey);
+ Assert.IsNotNull(updatedEntry);
+ Assert.AreEqual(decision.CmabUuid, updatedEntry.CmabUuid);
+ Assert.AreEqual("varNew", updatedEntry.VariationId);
+
+ var otherEntry = _cmabCache.Lookup(otherKey);
+ Assert.IsNotNull(otherEntry);
+ Assert.AreEqual("varOther", otherEntry.VariationId);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void FetchesNewDecisionWhenHashDiffers()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } },
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID,
+ new Dictionary { { "age", 25 } });
+
+ var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID);
+ _cmabCache.Save(cacheKey, new CmabCacheEntry
+ {
+ AttributesHash = "different_hash",
+ CmabUuid = "uuid-old",
+ VariationId = "varOld",
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs =>
+ attrs.Count == 1 && (int)attrs["age"] == 25),
+ It.IsAny(),
+ It.IsAny())).Returns("varUpdated");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID);
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varUpdated", decision.VariationId);
+ var cachedEntry = _cmabCache.Lookup(cacheKey);
+ Assert.IsNotNull(cachedEntry);
+ Assert.AreEqual("varUpdated", cachedEntry.VariationId);
+ Assert.AreEqual(decision.CmabUuid, cachedEntry.CmabUuid);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void FiltersAttributesBeforeCallingClient()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID,
+ new List { AGE_ATTRIBUTE_ID, LOCATION_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } },
+ {
+ LOCATION_ATTRIBUTE_ID,
+ new AttributeEntity { Id = LOCATION_ATTRIBUTE_ID, Key = "location" }
+ },
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID, new Dictionary
+ {
+ { "age", 25 },
+ { "location", "USA" },
+ { "extra", "value" },
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs => attrs.Count == 2 &&
+ (int)attrs["age"] == 25 &&
+ (string)attrs["location"] == "USA" &&
+ !attrs.ContainsKey("extra")),
+ It.IsAny(),
+ It.IsAny())).Returns("varFiltered");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID);
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varFiltered", decision.VariationId);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void HandlesMissingCmabConfiguration()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, null);
+ var attributeMap = new Dictionary();
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+ var userContext = CreateUserContext(TEST_USER_ID,
+ new Dictionary { { "age", 25 } });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.Is>(attrs => attrs.Count == 0),
+ It.IsAny(),
+ It.IsAny())).Returns("varDefault");
+
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID);
+
+ Assert.IsNotNull(decision);
+ Assert.AreEqual("varDefault", decision.VariationId);
+ _mockCmabClient.VerifyAll();
+ }
+
+ [Test]
+ public void AttributeHashIsStableRegardlessOfOrder()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID,
+ new List { AGE_ATTRIBUTE_ID, LOCATION_ATTRIBUTE_ID });
+ var attributeMap = new Dictionary
+ {
+ { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "a" } },
+ {
+ LOCATION_ATTRIBUTE_ID,
+ new AttributeEntity { Id = LOCATION_ATTRIBUTE_ID, Key = "b" }
+ },
+ };
+ var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap);
+
+ var firstContext = CreateUserContext(TEST_USER_ID, new Dictionary
+ {
+ { "b", 2 },
+ { "a", 1 },
+ });
+
+ _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny())).Returns("varStable");
+
+ var firstDecision = _cmabService.GetDecision(projectConfig, firstContext, TEST_RULE_ID);
+ Assert.IsNotNull(firstDecision);
+ Assert.AreEqual("varStable", firstDecision.VariationId);
+
+ var secondContext = CreateUserContext(TEST_USER_ID, new Dictionary
+ {
+ { "a", 1 },
+ { "b", 2 },
+ });
+
+ var secondDecision =
+ _cmabService.GetDecision(projectConfig, secondContext, TEST_RULE_ID);
+
+ Assert.IsNotNull(secondDecision);
+ Assert.AreEqual("varStable", secondDecision.VariationId);
+ _mockCmabClient.Verify(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
+ It.IsAny>(), It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ [Test]
+ public void UsesExpectedCacheKeyFormat()
+ {
+ var experiment = CreateExperiment(TEST_RULE_ID, new List