diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
index e441df53..4c3145c2 100644
--- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
+++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
@@ -230,6 +230,9 @@
Bucketing\UserProfileUtil
+
+ Bucketing\VariationDecisionResult.cs
+
Entity\FeatureVariableUsage
diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
index c1150280..6f2b3f23 100644
--- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
+++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
@@ -229,6 +229,9 @@
Bucketing\UserProfileUtil
+
+ Bucketing\VariationDecisionResult.cs
+
Entity\FeatureVariableUsage
diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
index 1490ba14..c1ba6d73 100644
--- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
+++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
@@ -79,6 +79,7 @@
+
diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
index f73e809c..e41c7fd7 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
@@ -196,6 +199,9 @@
Cmab\CmabRetryConfig.cs
+
+ Cmab\CmabConfig.cs
+
Cmab\CmabModels.cs
@@ -369,6 +375,9 @@
Utils\Validator.cs
+
+ Utils\ICacheWithRemove.cs
+
Event\BatchEventProcessor.cs
diff --git a/OptimizelySDK.Tests/BucketerBucketToEntityIdTest.cs b/OptimizelySDK.Tests/BucketerBucketToEntityIdTest.cs
new file mode 100644
index 00000000..837b4dcb
--- /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/CmabTests/DecisionServiceCmabTest.cs b/OptimizelySDK.Tests/CmabTests/DecisionServiceCmabTest.cs
new file mode 100644
index 00000000..a08af152
--- /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/DefaultCmabServiceTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs
index 9dac9699..5c891a9d 100644
--- a/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs
+++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs
@@ -1,18 +1,18 @@
-/*
-* 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.
-*/
+/*
+ * 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;
@@ -25,6 +25,8 @@
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
@@ -32,6 +34,19 @@ 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;
@@ -44,44 +59,37 @@ public class DefaultCmabServiceTest
private const string AGE_ATTRIBUTE_ID = "66";
private const string LOCATION_ATTRIBUTE_ID = "77";
- [SetUp]
- public void SetUp()
- {
- _mockCmabClient = new Mock(MockBehavior.Strict);
- _logger = new NoOpLogger();
- _cmabCache = new LruCache(maxSize: 10, itemTimeout: TimeSpan.FromMinutes(5), logger: _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());
- }
-
[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" } }
+ { 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 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"
+ VariationId = "varA",
});
- var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null);
+ 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);
+ _mockCmabClient.Verify(
+ c => c.FetchDecision(It.IsAny(), It.IsAny(),
+ It.IsAny>(), It.IsAny(),
+ It.IsAny()), Times.Never);
}
[Test]
@@ -90,14 +98,17 @@ 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" } }
+ { 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 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.Is>(attrs =>
+ attrs != null && attrs.Count == 1 && attrs.ContainsKey("age") &&
+ (int)attrs["age"] == 25),
It.IsAny(),
It.IsAny())).Returns("varB");
@@ -116,21 +127,23 @@ 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" } }
+ { 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 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"
+ VariationId = "varOld",
});
_mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
- It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25),
+ It.Is>(attrs =>
+ attrs.Count == 1 && (int)attrs["age"] == 25),
It.IsAny(),
It.IsAny())).Returns("varNew");
@@ -153,10 +166,11 @@ public void InvalidatesUserEntryWhenOptionSpecified()
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" } }
+ { 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 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);
@@ -165,17 +179,18 @@ public void InvalidatesUserEntryWhenOptionSpecified()
{
AttributesHash = "old_hash",
CmabUuid = "uuid-old",
- VariationId = "varOld"
+ VariationId = "varOld",
});
_cmabCache.Save(otherKey, new CmabCacheEntry
{
AttributesHash = "other_hash",
CmabUuid = "uuid-other",
- VariationId = "varOther"
+ VariationId = "varOther",
});
_mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
- It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25),
+ It.Is>(attrs =>
+ attrs.Count == 1 && (int)attrs["age"] == 25),
It.IsAny(),
It.IsAny())).Returns("varNew");
@@ -201,25 +216,27 @@ 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" } }
+ { 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 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"
+ VariationId = "varOld",
});
_mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
- It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25),
+ 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, null);
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID);
Assert.IsNotNull(decision);
Assert.AreEqual("varUpdated", decision.VariationId);
@@ -233,18 +250,22 @@ public void FetchesNewDecisionWhenHashDiffers()
[Test]
public void FiltersAttributesBeforeCallingClient()
{
- var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID, LOCATION_ATTRIBUTE_ID });
+ 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" } }
+ {
+ 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" }
+ { "extra", "value" },
});
_mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
@@ -255,7 +276,7 @@ public void FiltersAttributesBeforeCallingClient()
It.IsAny(),
It.IsAny())).Returns("varFiltered");
- var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null);
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID);
Assert.IsNotNull(decision);
Assert.AreEqual("varFiltered", decision.VariationId);
@@ -268,14 +289,15 @@ 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 } });
+ 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, null);
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID);
Assert.IsNotNull(decision);
Assert.AreEqual("varDefault", decision.VariationId);
@@ -285,18 +307,22 @@ public void HandlesMissingCmabConfiguration()
[Test]
public void AttributeHashIsStableRegardlessOfOrder()
{
- var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID, LOCATION_ATTRIBUTE_ID });
+ 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" } }
+ {
+ 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 }
+ { "a", 1 },
});
_mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
@@ -304,22 +330,25 @@ public void AttributeHashIsStableRegardlessOfOrder()
It.IsAny(),
It.IsAny())).Returns("varStable");
- var firstDecision = _cmabService.GetDecision(projectConfig, firstContext, TEST_RULE_ID, null);
+ 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 }
+ { "b", 2 },
});
- var secondDecision = _cmabService.GetDecision(projectConfig, secondContext, TEST_RULE_ID, null);
+ 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);
+ It.IsAny>(), It.IsAny(),
+ It.IsAny()),
+ Times.Once);
}
[Test]
@@ -328,17 +357,18 @@ public void UsesExpectedCacheKeyFormat()
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" } }
+ { 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 userContext = CreateUserContext(TEST_USER_ID,
+ new Dictionary { { "age", 25 } });
_mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID,
It.IsAny>(),
It.IsAny(),
It.IsAny())).Returns("varKey");
- var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null);
+ var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID);
Assert.IsNotNull(decision);
Assert.AreEqual("varKey", decision.VariationId);
@@ -348,7 +378,244 @@ public void UsesExpectedCacheKeyFormat()
Assert.AreEqual(decision.CmabUuid, cachedEntry.CmabUuid);
}
- private OptimizelyUserContext CreateUserContext(string userId, IDictionary attributes)
+ [Test]
+ public void ConstructorWithoutConfigUsesDefaultCacheSettings()
+ {
+ var cache = new LruCache(CmabConstants.DEFAULT_CACHE_SIZE,
+ CmabConstants.DEFAULT_CACHE_TTL, _logger);
+ var client = new DefaultCmabClient(null,
+ new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
+ var service = new DefaultCmabService(cache, client, _logger);
+ var internalCache = GetInternalCache(service) as LruCache;
+
+ Assert.IsNotNull(internalCache);
+ Assert.AreEqual(CmabConstants.DEFAULT_CACHE_SIZE, internalCache.MaxSizeForTesting);
+ Assert.AreEqual(CmabConstants.DEFAULT_CACHE_TTL, internalCache.TimeoutForTesting);
+ }
+
+ [Test]
+ public void ConstructorAppliesCustomCacheSize()
+ {
+ var cache = new LruCache(42, CmabConstants.DEFAULT_CACHE_TTL, _logger);
+ var client = new DefaultCmabClient(null,
+ new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
+ var service = new DefaultCmabService(cache, client, _logger);
+ var internalCache = GetInternalCache(service) as LruCache;
+
+ Assert.IsNotNull(internalCache);
+ Assert.AreEqual(42, internalCache.MaxSizeForTesting);
+ Assert.AreEqual(CmabConstants.DEFAULT_CACHE_TTL, internalCache.TimeoutForTesting);
+ }
+
+ [Test]
+ public void ConstructorAppliesCustomCacheTtl()
+ {
+ var expectedTtl = TimeSpan.FromMinutes(3);
+ var cache = new LruCache(CmabConstants.DEFAULT_CACHE_SIZE, expectedTtl,
+ _logger);
+ var client = new DefaultCmabClient(null,
+ new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
+ var service = new DefaultCmabService(cache, client, _logger);
+ var internalCache = GetInternalCache(service) as LruCache;
+
+ Assert.IsNotNull(internalCache);
+ Assert.AreEqual(CmabConstants.DEFAULT_CACHE_SIZE, internalCache.MaxSizeForTesting);
+ Assert.AreEqual(expectedTtl, internalCache.TimeoutForTesting);
+ }
+
+ [Test]
+ public void ConstructorAppliesCustomCacheSizeAndTtl()
+ {
+ var expectedTtl = TimeSpan.FromSeconds(90);
+ var cache = new LruCache(5, expectedTtl, _logger);
+ var client = new DefaultCmabClient(null,
+ new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
+ var service = new DefaultCmabService(cache, client, _logger);
+ var internalCache = GetInternalCache(service) as LruCache;
+
+ Assert.IsNotNull(internalCache);
+ Assert.AreEqual(5, internalCache.MaxSizeForTesting);
+ Assert.AreEqual(expectedTtl, internalCache.TimeoutForTesting);
+ }
+
+ [Test]
+ public void ConstructorUsesProvidedCustomCacheInstance()
+ {
+ var customCache = new LruCache(3, TimeSpan.FromSeconds(5), _logger);
+ var client = new DefaultCmabClient(null,
+ new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
+ var service = new DefaultCmabService(customCache, client, _logger);
+ var cache = GetInternalCache(service);
+
+ Assert.IsNotNull(cache);
+ Assert.AreSame(customCache, cache);
+ }
+
+ [Test]
+ public void ConstructorAcceptsAnyICacheImplementation()
+ {
+ var fakeCache = new FakeCache();
+ var client = new DefaultCmabClient(null,
+ new CmabRetryConfig(1, TimeSpan.FromMilliseconds(100)), _logger);
+ var service = new DefaultCmabService(fakeCache, client, _logger);
+ var cache = GetInternalCache(service);
+
+ Assert.IsNotNull(cache);
+ Assert.AreSame(fakeCache, cache);
+ Assert.IsInstanceOf>(cache);
+ }
+
+ [Test]
+ public void ConstructorCreatesDefaultClientWhenNoneProvided()
+ {
+ var cache = new LruCache