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..341ab132 --- /dev/null +++ b/OptimizelySDK.Tests/CmabTests/DecisionServiceCmabTest.cs @@ -0,0 +1,666 @@ +/* +* 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); + + var reasons = result.DecisionReasons.ToReport(true); + var expectedMessage = + $"CMAB decision fetched for user [{TEST_USER_ID}] in 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 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.CmabError); + + var reasonsList = result.DecisionReasons.ToReport(true); + Assert.IsTrue(reasonsList.Exists(reason => + reason.Contains( + $"Failed to fetch CMAB decision for experiment [{TEST_EXPERIMENT_KEY}].")), + $"Decision reasons should include CMAB fetch failure. Actual reasons: {string.Join(", ", reasonsList)}"); + Assert.IsTrue(reasonsList.Exists(reason => reason.Contains("Error: CMAB service error")), + $"Decision reasons should include CMAB service error text. 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); + + var reasons = result2.DecisionReasons.ToReport(true); + var expectedMessage = + $"CMAB decision fetched for user [{TEST_USER_ID}] in experiment [{TEST_EXPERIMENT_KEY}]."; + Assert.Contains(expectedMessage, reasons); + } + + /// + /// 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..cd3df471 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(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 internalClient = GetInternalClient(service); + + Assert.IsInstanceOf(internalClient); + } + + [Test] + public void ConstructorUsesProvidedClientInstance() + { + var mockClient = new Mock().Object; + var cache = new LruCache(CmabConstants.DEFAULT_CACHE_SIZE, + CmabConstants.DEFAULT_CACHE_TTL, _logger); + var service = new DefaultCmabService(cache, mockClient, _logger); + var client = GetInternalClient(service); + + Assert.AreSame(mockClient, client); + } + + [Test] + public void ConcurrentRequestsForSameUserUseCacheAfterFirstNetworkCall() + { + 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 clientCallCount = 0; + var clientCallLock = new object(); + + _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(() => + { + lock (clientCallLock) + { + clientCallCount++; + } + System.Threading.Thread.Sleep(100); + + return "varConcurrent"; + }); + + var tasks = new System.Threading.Tasks.Task[10]; + + for (int i = 0; i < tasks.Length; i++) + { + tasks[i] = System.Threading.Tasks.Task.Run(() => + _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID)); + } + + System.Threading.Tasks.Task.WaitAll(tasks); + + foreach (var task in tasks) + { + Assert.IsNotNull(task.Result); + Assert.AreEqual("varConcurrent", task.Result.VariationId); + } + + Assert.AreEqual(1, clientCallCount, + "Client should only be called once - subsequent requests should use cache"); + + _mockCmabClient.VerifyAll(); + } + + [Test] + public void SameUserRuleCombinationUsesConsistentLock() + { + var userId = "test_user"; + var ruleId = "test_rule"; + + var index1 = _cmabService.GetLockIndex(userId, ruleId); + var index2 = _cmabService.GetLockIndex(userId, ruleId); + var index3 = _cmabService.GetLockIndex(userId, ruleId); + + Assert.AreEqual(index1, index2, "Same user/rule should always use same lock"); + Assert.AreEqual(index2, index3, "Same user/rule should always use same lock"); + } + + [Test] + public void LockStripingDistribution() + { + var testCases = new[] + { + new { UserId = "user1", RuleId = "rule1" }, + new { UserId = "user2", RuleId = "rule1" }, + new { UserId = "user1", RuleId = "rule2" }, + new { UserId = "user3", RuleId = "rule3" }, + new { UserId = "user4", RuleId = "rule4" }, + }; + + var lockIndices = new HashSet(); + foreach (var testCase in testCases) + { + var index = _cmabService.GetLockIndex(testCase.UserId, testCase.RuleId); + + Assert.GreaterOrEqual(index, 0, "Lock index should be non-negative"); + Assert.Less(index, 1000, "Lock index should be less than NUM_LOCK_STRIPES (1000)"); + + lockIndices.Add(index); + } + + Assert.Greater(lockIndices.Count, 1, + "Different user/rule combinations should generally use different locks"); + } + + private static ICacheWithRemove GetInternalCache(DefaultCmabService service) + { + return Reflection.GetFieldValue, DefaultCmabService>(service, + "_cmabCache"); + } + + private static ICmabClient GetInternalClient(DefaultCmabService service) + { + return Reflection.GetFieldValue(service, + "_cmabClient"); + } + + private sealed class FakeCache : ICacheWithRemove + { + public void Save(string key, CmabCacheEntry value) { } + + public CmabCacheEntry Lookup(string key) + { + return null; + } + + public void Reset() { } + + public void Remove(string key) { } + } + + private OptimizelyUserContext CreateUserContext(string userId, + IDictionary attributes + ) { var userContext = _optimizely.CreateUserContext(userId); @@ -361,7 +628,8 @@ private OptimizelyUserContext CreateUserContext(string userId, IDictionary attributeMap) + Dictionary attributeMap + ) { var mockConfig = new Mock(); var experimentMap = new Dictionary(); @@ -371,7 +639,8 @@ private static ProjectConfig CreateProjectConfig(string ruleId, Experiment exper } mockConfig.SetupGet(c => c.ExperimentIdMap).Returns(experimentMap); - mockConfig.SetupGet(c => c.AttributeIdMap).Returns(attributeMap ?? new Dictionary()); + mockConfig.SetupGet(c => c.AttributeIdMap). + Returns(attributeMap ?? new Dictionary()); return mockConfig.Object; } @@ -380,9 +649,12 @@ private static Experiment CreateExperiment(string ruleId, List attribute return new Experiment { Id = ruleId, - Cmab = attributeIds == null ? null : new Entity.Cmab(attributeIds) + Cmab = attributeIds == null ? null : new Entity.Cmab(attributeIds, 10000), }; } + + + } } diff --git a/OptimizelySDK.Tests/CmabTests/ImpressionEventCmabTest.cs b/OptimizelySDK.Tests/CmabTests/ImpressionEventCmabTest.cs new file mode 100644 index 00000000..ccde294f --- /dev/null +++ b/OptimizelySDK.Tests/CmabTests/ImpressionEventCmabTest.cs @@ -0,0 +1,215 @@ +/* +* 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 Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using OptimizelySDK.Config; +using OptimizelySDK.Entity; +using OptimizelySDK.ErrorHandler; +using OptimizelySDK.Event; +using OptimizelySDK.Event.Entity; +using OptimizelySDK.Logger; + +namespace OptimizelySDK.Tests.CmabTests +{ + [TestFixture] + public class ImpressionEventCmabTest + { + private Mock _loggerMock; + private Mock _errorHandlerMock; + private ProjectConfig _config; + + private const string TEST_USER_ID = "test_user"; + private const string TEST_CMAB_UUID = "cmab-uuid-12345"; + private const string TEST_EXPERIMENT_KEY = "test_experiment"; + private const string TEST_VARIATION_ID = "77210100090"; + + [SetUp] + public void SetUp() + { + _loggerMock = new Mock(); + _errorHandlerMock = new Mock(); + + _config = DatafileProjectConfig.Create(TestData.Datafile, _loggerMock.Object, + _errorHandlerMock.Object); + } + + /// + /// Verifies that CreateImpressionEvent includes CMAB UUID in metadata + /// + [Test] + public void TestCreateImpressionEventWithCmabUuid() + { + var experiment = _config.GetExperimentFromKey(TEST_EXPERIMENT_KEY); + var variation = _config.GetVariationFromId(experiment.Key, TEST_VARIATION_ID); + + var impressionEvent = UserEventFactory.CreateImpressionEvent( + _config, + experiment, + variation, + TEST_USER_ID, + null, + TEST_EXPERIMENT_KEY, + "experiment", + true, + TEST_CMAB_UUID); + + Assert.IsNotNull(impressionEvent); + Assert.IsNotNull(impressionEvent.Metadata); + Assert.AreEqual(TEST_CMAB_UUID, impressionEvent.Metadata.CmabUuid); + Assert.AreEqual(experiment, impressionEvent.Experiment); + Assert.AreEqual(variation, impressionEvent.Variation); + Assert.AreEqual(TEST_USER_ID, impressionEvent.UserId); + } + + /// + /// Verifies that CreateImpressionEvent without CMAB UUID has null cmab_uuid + /// + [Test] + public void TestCreateImpressionEventWithoutCmabUuid() + { + var experiment = _config.GetExperimentFromKey(TEST_EXPERIMENT_KEY); + var variation = _config.GetVariationFromId(experiment.Key, TEST_VARIATION_ID); + + var impressionEvent = UserEventFactory.CreateImpressionEvent( + _config, + experiment, + variation, + TEST_USER_ID, + null, + TEST_EXPERIMENT_KEY, + "experiment"); + + Assert.IsNotNull(impressionEvent); + Assert.IsNotNull(impressionEvent.Metadata); + Assert.IsNull(impressionEvent.Metadata.CmabUuid); + Assert.AreEqual(experiment, impressionEvent.Experiment); + Assert.AreEqual(variation, impressionEvent.Variation); + } + + /// + /// Verifies that EventFactory includes cmab_uuid in the log event JSON + /// + [Test] + public void TestEventFactoryCreateLogEventWithCmabUuid() + { + // Arrange + var experiment = _config.GetExperimentFromKey(TEST_EXPERIMENT_KEY); + var variation = _config.GetVariationFromId(experiment.Key, TEST_VARIATION_ID); + + var impressionEvent = UserEventFactory.CreateImpressionEvent( + _config, + experiment, + variation, + TEST_USER_ID, + null, + TEST_EXPERIMENT_KEY, + "experiment", + true, + TEST_CMAB_UUID); + + var logEvent = EventFactory.CreateLogEvent(new UserEvent[] { impressionEvent }, _loggerMock.Object); + + Assert.IsNotNull(logEvent); + + var params_dict = logEvent.Params; + + Assert.IsNotNull(params_dict); + Assert.IsTrue(params_dict.ContainsKey("visitors")); + + var visitors = (JArray)params_dict["visitors"]; + + Assert.IsNotNull(visitors); + Assert.AreEqual(1, visitors.Count); + + var visitor = visitors[0] as JObject; + var snapshots = visitor["snapshots"] as JArray; + + Assert.IsNotNull(snapshots); + Assert.Greater(snapshots.Count, 0); + + var snapshot = snapshots[0] as JObject; + var decisions = snapshot["decisions"] as JArray; + + Assert.IsNotNull(decisions); + Assert.Greater(decisions.Count, 0); + + var decision = decisions[0] as JObject; + var metadata = decision["metadata"] as JObject; + + Assert.IsNotNull(metadata); + + Assert.IsTrue(metadata.ContainsKey("cmab_uuid")); + Assert.AreEqual(TEST_CMAB_UUID, metadata["cmab_uuid"].ToString()); + } + + /// + /// Verifies that EventFactory does not include cmab_uuid when not provided + /// + [Test] + public void TestEventFactoryCreateLogEventWithoutCmabUuid() + { + var experiment = _config.GetExperimentFromKey(TEST_EXPERIMENT_KEY); + var variation = _config.GetVariationFromId(experiment.Key, TEST_VARIATION_ID); + + var impressionEvent = UserEventFactory.CreateImpressionEvent( + _config, + experiment, + variation, + TEST_USER_ID, + null, + TEST_EXPERIMENT_KEY, + "experiment"); + + var logEvent = EventFactory.CreateLogEvent(new UserEvent[] { impressionEvent }, _loggerMock.Object); + + Assert.IsNotNull(logEvent); + + var params_dict = logEvent.Params; + + Assert.IsNotNull(params_dict); + Assert.IsTrue(params_dict.ContainsKey("visitors")); + + var visitors = (JArray)params_dict["visitors"]; + + Assert.IsNotNull(visitors); + Assert.AreEqual(1, visitors.Count); + + var visitor = visitors[0] as JObject; + var snapshots = visitor["snapshots"] as JArray; + + Assert.IsNotNull(snapshots); + Assert.Greater(snapshots.Count, 0); + + var snapshot = snapshots[0] as JObject; + var decisions = snapshot["decisions"] as JArray; + + Assert.IsNotNull(decisions); + Assert.Greater(decisions.Count, 0); + + var decision = decisions[0] as JObject; + var metadata = decision["metadata"] as JObject; + + Assert.IsNotNull(metadata); + + Assert.IsFalse(metadata.ContainsKey("cmab_uuid") && + metadata["cmab_uuid"].Type != JTokenType.Null, + "cmab_uuid should be absent or null when no CMAB UUID is provided."); + } + } +} diff --git a/OptimizelySDK.Tests/CmabTests/OptimizelyUserContextCmabTest.cs b/OptimizelySDK.Tests/CmabTests/OptimizelyUserContextCmabTest.cs new file mode 100644 index 00000000..50f9e18e --- /dev/null +++ b/OptimizelySDK.Tests/CmabTests/OptimizelyUserContextCmabTest.cs @@ -0,0 +1,582 @@ +/* +* 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.Linq; +using System.Reflection; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using OptimizelySDK.Bucketing; +using OptimizelySDK.Cmab; +using OptimizelySDK.Config; +using OptimizelySDK.Entity; +using OptimizelySDK.ErrorHandler; +using OptimizelySDK.Event; +using OptimizelySDK.Event.Dispatcher; +using OptimizelySDK.Logger; +using OptimizelySDK.Notifications; +using OptimizelySDK.OptimizelyDecisions; +using OptimizelySDK.Tests.NotificationTests; +using OptimizelySDK.Utils; + +namespace OptimizelySDK.Tests.CmabTests +{ + [TestFixture] + public class OptimizelyUserContextCmabTest + { + private Mock _loggerMock; + private Mock _errorHandlerMock; + private Mock _eventDispatcherMock; + private TestCmabService _cmabService; + private Mock _notificationCallbackMock; + private Optimizely _optimizely; + private ProjectConfig _config; + + private const string TEST_USER_ID = "test_user_cmab"; + private const string TEST_FEATURE_KEY = "multi_variate_feature"; + private const string TEST_EXPERIMENT_KEY = "test_experiment_multivariate"; + private const string TEST_EXPERIMENT_ID = "122230"; + private const string VARIATION_A_ID = "122231"; + private const string VARIATION_A_KEY = "Fred"; + private const string TEST_CMAB_UUID = "uuid-cmab-123"; + private const string DEVICE_TYPE_ATTRIBUTE_ID = "7723280020"; + private const string DEVICE_TYPE_ATTRIBUTE_KEY = "device_type"; + private const string BROWSER_TYPE_ATTRIBUTE_KEY = "browser_type"; + + [SetUp] + public void SetUp() + { + _loggerMock = new Mock(); + _errorHandlerMock = new Mock(); + _eventDispatcherMock = new Mock(); + _cmabService = new TestCmabService + { + DefaultDecision = new CmabDecision(VARIATION_A_ID, TEST_CMAB_UUID) + }; + _notificationCallbackMock = new Mock(); + + _config = DatafileProjectConfig.Create(TestData.Datafile, _loggerMock.Object, + _errorHandlerMock.Object); + + ConfigureCmabExperiment(_config, TEST_EXPERIMENT_ID, TEST_EXPERIMENT_KEY); + + // Create Optimizely with mocked CMAB service using ConfigManager + var configManager = new FallbackProjectConfigManager(_config); + _optimizely = new Optimizely(configManager, null, _eventDispatcherMock.Object, + _loggerMock.Object, _errorHandlerMock.Object); + + // Replace decision service with one that has our mock CMAB service + var decisionService = new DecisionService(new Bucketer(_loggerMock.Object), + _errorHandlerMock.Object, null, _loggerMock.Object, _cmabService); + + SetDecisionService(_optimizely, decisionService); + } + + /// + /// Verifies Decide returns decision for CMAB experiment + /// + [Test] + public void TestDecideWithCmabExperimentReturnsDecision() + { + var userContext = CreateCmabUserContext(); + var decision = userContext.Decide(TEST_FEATURE_KEY); + + Assert.IsNotNull(decision); + Assert.AreEqual(VARIATION_A_KEY, decision.VariationKey); + Assert.IsTrue(decision.Enabled, "Feature flag should be enabled for CMAB variation."); + Assert.AreEqual(TEST_FEATURE_KEY, decision.FlagKey); + Assert.AreEqual(TEST_EXPERIMENT_KEY, decision.RuleKey); + Assert.IsTrue(decision.Reasons == null || decision.Reasons.Length == 0); + + Assert.AreEqual(1, _cmabService.CallCount); + Assert.AreEqual(TEST_EXPERIMENT_ID, _cmabService.LastRuleId); + } + + /// + /// Verifies impression event is sent with CMAB UUID in metadata + /// + [Test] + public void TestDecideWithCmabExperimentVerifyImpressionEvent() + { + var userContext = CreateCmabUserContext(); + LogEvent impressionEvent = null; + + _eventDispatcherMock.Setup(d => d.DispatchEvent(It.IsAny())) + .Callback(e => impressionEvent = e); + + var decision = userContext.Decide(TEST_FEATURE_KEY); + + Assert.IsNotNull(decision); + _eventDispatcherMock.Verify(d => d.DispatchEvent(It.IsAny()), Times.Once); + Assert.IsNotNull(impressionEvent, "Impression event should be dispatched."); + + var payload = JObject.Parse(impressionEvent.GetParamsAsJson()); + var cmabUuidToken = + payload.SelectToken("visitors[0].snapshots[0].decisions[0].metadata.cmab_uuid"); + + Assert.IsNotNull(cmabUuidToken, "Metadata should include CMAB UUID."); + Assert.AreEqual(TEST_CMAB_UUID, cmabUuidToken.Value()); + Assert.AreEqual(1, _cmabService.CallCount); + } + + /// + /// Verifies IsFeatureEnabled sends impression event including CMAB UUID in metadata + /// + [Test] + public void TestIsFeatureEnabledDispatchesCmabUuidInImpressionEvent() + { + LogEvent impressionEvent = null; + + _eventDispatcherMock.Setup(d => d.DispatchEvent(It.IsAny())) + .Callback(e => impressionEvent = e); + + var attributes = new UserAttributes + { + { DEVICE_TYPE_ATTRIBUTE_KEY, "mobile" }, + { BROWSER_TYPE_ATTRIBUTE_KEY, "chrome" }, + }; + + var featureEnabled = _optimizely.IsFeatureEnabled(TEST_FEATURE_KEY, TEST_USER_ID, + attributes); + + Assert.IsTrue(featureEnabled, "Feature flag should be enabled for CMAB variation."); + _eventDispatcherMock.Verify(d => d.DispatchEvent(It.IsAny()), Times.Once, + "Impression event should be dispatched for IsFeatureEnabled calls."); + Assert.IsNotNull(impressionEvent, "Impression event should be captured."); + + var payload = JObject.Parse(impressionEvent.GetParamsAsJson()); + var cmabUuidToken = + payload.SelectToken("visitors[0].snapshots[0].decisions[0].metadata.cmab_uuid"); + + Assert.IsNotNull(cmabUuidToken, "Metadata should include CMAB UUID."); + Assert.AreEqual(TEST_CMAB_UUID, cmabUuidToken.Value()); + Assert.AreEqual(1, _cmabService.CallCount); + } + + /// + /// Verifies no impression event sent when DISABLE_DECISION_EVENT option is used + /// + [Test] + public void TestDecideWithCmabExperimentDisableDecisionEvent() + { + var userContext = CreateCmabUserContext(); + + var decision = userContext.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.DISABLE_DECISION_EVENT }); + + Assert.IsNotNull(decision); + _eventDispatcherMock.Verify(d => d.DispatchEvent(It.IsAny()), Times.Never, + "No impression event should be sent with DISABLE_DECISION_EVENT"); + Assert.AreEqual(1, _cmabService.CallCount); + Assert.IsTrue(_cmabService.OptionsPerCall[0] + .Contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)); + } + + /// + /// Verifies DecideForKeys works with mix of CMAB and non-CMAB flags + /// + [Test] + public void TestDecideForKeysMixedCmabAndNonCmab() + { + var userContext = CreateCmabUserContext(); + var featureKeys = new[] { TEST_FEATURE_KEY, "boolean_single_variable_feature" }; + var decisions = userContext.DecideForKeys(featureKeys); + + Assert.IsNotNull(decisions); + Assert.AreEqual(2, decisions.Count); + Assert.IsTrue(decisions.ContainsKey(TEST_FEATURE_KEY)); + Assert.IsTrue(decisions.ContainsKey("boolean_single_variable_feature")); + + var cmabDecision = decisions[TEST_FEATURE_KEY]; + var nonCmabDecision = decisions["boolean_single_variable_feature"]; + + Assert.IsNotNull(cmabDecision); + Assert.AreEqual(VARIATION_A_KEY, cmabDecision.VariationKey); + + Assert.IsNotNull(nonCmabDecision); + Assert.AreEqual(1, _cmabService.CallCount); + } + + /// + /// Verifies DecideAll includes CMAB experiment decisions + /// + [Test] + public void TestDecideAllIncludesCmabExperiments() + { + var userContext = CreateCmabUserContext(); + var decisions = userContext.DecideAll(); + + Assert.IsNotNull(decisions); + Assert.IsTrue(decisions.Count > 0, "Should return decisions for all feature flags"); + Assert.IsTrue(decisions.TryGetValue(TEST_FEATURE_KEY, out var cmabDecision)); + Assert.IsNotNull(cmabDecision); + Assert.AreEqual(VARIATION_A_KEY, cmabDecision.VariationKey); + Assert.GreaterOrEqual(_cmabService.CallCount, 1); + } + + /// + /// Verifies IGNORE_CMAB_CACHE option is passed correctly to decision flow + /// + [Test] + public void TestDecideWithCmabExperimentIgnoreCmabCache() + { + var userContext = CreateCmabUserContext(); + + var decision1 = userContext.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.IGNORE_CMAB_CACHE }); + var decision2 = userContext.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.IGNORE_CMAB_CACHE }); + + Assert.IsNotNull(decision1); + Assert.IsNotNull(decision2); + Assert.AreEqual(VARIATION_A_KEY, decision1.VariationKey); + Assert.AreEqual(VARIATION_A_KEY, decision2.VariationKey); + Assert.AreEqual(2, _cmabService.CallCount); + Assert.IsTrue(_cmabService.OptionsPerCall.All(options => + options.Contains(OptimizelyDecideOption.IGNORE_CMAB_CACHE))); + } + + /// + /// Verifies RESET_CMAB_CACHE option clears entire cache + /// + [Test] + public void TestDecideWithCmabExperimentResetCmabCache() + { + var userContext = CreateCmabUserContext(); + + var decision1 = userContext.Decide(TEST_FEATURE_KEY); + + var decision2 = userContext.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.RESET_CMAB_CACHE }); + + Assert.IsNotNull(decision1); + Assert.IsNotNull(decision2); + Assert.AreEqual(VARIATION_A_KEY, decision1.VariationKey); + Assert.AreEqual(VARIATION_A_KEY, decision2.VariationKey); + Assert.AreEqual(2, _cmabService.CallCount); + Assert.IsFalse(_cmabService.OptionsPerCall[0] + .Contains(OptimizelyDecideOption.RESET_CMAB_CACHE)); + Assert.IsTrue(_cmabService.OptionsPerCall[1] + .Contains(OptimizelyDecideOption.RESET_CMAB_CACHE)); + } + + /// + /// Verifies INVALIDATE_USER_CMAB_CACHE option is passed correctly to decision flow + /// + [Test] + public void TestDecideWithCmabExperimentInvalidateUserCmabCache() + { + // Arrange + var userContext1 = CreateCmabUserContext(); + var userContext2 = CreateCmabUserContext("other_user"); + + var decision1 = userContext1.Decide(TEST_FEATURE_KEY); + + var decision2 = userContext2.Decide(TEST_FEATURE_KEY); + + var decision3 = userContext1.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE }); + + Assert.IsNotNull(decision1); + Assert.IsNotNull(decision2); + Assert.IsNotNull(decision3); + Assert.AreEqual(3, _cmabService.CallCount); + Assert.IsTrue(_cmabService.OptionsPerCall[2] + .Contains(OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE)); + } + + /// + /// Verifies User Profile Service integration with CMAB experiments + /// + [Test] + public void TestDecideWithCmabExperimentUserProfileService() + { + var userProfileServiceMock = new Mock(); + userProfileServiceMock.Setup(ups => ups.Save(It.IsAny>())) + .Callback>(_ => { }); + + var configManager = new FallbackProjectConfigManager(_config); + var optimizelyWithUps = new Optimizely(configManager, null, _eventDispatcherMock.Object, + _loggerMock.Object, _errorHandlerMock.Object, userProfileServiceMock.Object); + + var cmabService = new TestCmabService + { + DefaultDecision = new CmabDecision(VARIATION_A_ID, TEST_CMAB_UUID) + }; + var decisionService = new DecisionService(new Bucketer(_loggerMock.Object), + _errorHandlerMock.Object, userProfileServiceMock.Object, _loggerMock.Object, + cmabService); + SetDecisionService(optimizelyWithUps, decisionService); + + var userContext = CreateCmabUserContext(optimizely: optimizelyWithUps); + + var decision = userContext.Decide(TEST_FEATURE_KEY); + + Assert.IsNotNull(decision); + Assert.AreEqual(VARIATION_A_KEY, decision.VariationKey); + userProfileServiceMock.Verify(ups => ups.Save(It.IsAny>()), + Times.Never); + Assert.AreEqual(1, cmabService.CallCount); + } + + /// + /// Verifies IGNORE_USER_PROFILE_SERVICE option skips UPS lookup + /// + [Test] + public void TestDecideWithCmabExperimentIgnoreUserProfileService() + { + var userProfileServiceMock = new Mock(); + + var configManager = new FallbackProjectConfigManager(_config); + var optimizelyWithUps = new Optimizely(configManager, null, _eventDispatcherMock.Object, + _loggerMock.Object, _errorHandlerMock.Object, userProfileServiceMock.Object); + + var cmabService = new TestCmabService + { + DefaultDecision = new CmabDecision(VARIATION_A_ID, TEST_CMAB_UUID) + }; + var decisionService = new DecisionService(new Bucketer(_loggerMock.Object), + _errorHandlerMock.Object, userProfileServiceMock.Object, _loggerMock.Object, + cmabService); + SetDecisionService(optimizelyWithUps, decisionService); + + var userContext = CreateCmabUserContext(optimizely: optimizelyWithUps); + + var decision = userContext.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE }); + + Assert.IsNotNull(decision); + Assert.AreEqual(VARIATION_A_KEY, decision.VariationKey); + + userProfileServiceMock.Verify(ups => ups.Lookup(It.IsAny()), Times.Never); + Assert.AreEqual(1, cmabService.CallCount); + Assert.IsTrue(cmabService.OptionsPerCall[0] + .Contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE)); + } + + /// + /// Verifies INCLUDE_REASONS option includes CMAB decision info + /// + [Test] + public void TestDecideWithCmabExperimentIncludeReasons() + { + var userContext = CreateCmabUserContext(); + + var decision = userContext.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.INCLUDE_REASONS }); + + Assert.IsNotNull(decision); + Assert.IsNotNull(decision.Reasons); + var expectedMessage = string.Format(CmabConstants.CMAB_DECISION_FETCHED, TEST_USER_ID, + TEST_EXPERIMENT_KEY); + Assert.IsTrue(decision.Reasons.Any(r => r.Contains(expectedMessage)), + "Decision reasons should include CMAB fetch success message."); + Assert.AreEqual(1, _cmabService.CallCount); + } + + /// + /// Verifies error handling when CMAB service fails + /// + [Test] + public void TestDecideWithCmabErrorReturnsErrorDecision() + { + var userContext = CreateCmabUserContext(); + + _cmabService.ReturnNullNext = true; + + var decision = userContext.Decide(TEST_FEATURE_KEY, + new[] { OptimizelyDecideOption.INCLUDE_REASONS }); + + Assert.IsNotNull(decision); + Assert.IsNull(decision.VariationKey); + Assert.IsTrue(decision.Reasons.Any(r => r.Contains( + string.Format(CmabConstants.CMAB_FETCH_FAILED, TEST_EXPERIMENT_KEY)))); + Assert.AreEqual(1, _cmabService.CallCount); + } + + /// + /// Verifies decision notification is called for CMAB experiments + /// + [Test] + public void TestDecideWithCmabExperimentDecisionNotification() + { + var notificationCenter = new NotificationCenter(_loggerMock.Object); + Dictionary capturedDecisionInfo = null; + + _notificationCallbackMock.Setup(nc => nc.TestDecisionCallback( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Callback((string type, string userId, UserAttributes attrs, + Dictionary decisionInfo) => capturedDecisionInfo = decisionInfo); + + notificationCenter.AddNotification( + NotificationCenter.NotificationType.Decision, + _notificationCallbackMock.Object.TestDecisionCallback); + + var configManager = new FallbackProjectConfigManager(_config); + var optimizelyWithNotifications = new Optimizely(configManager, notificationCenter, + _eventDispatcherMock.Object, _loggerMock.Object, _errorHandlerMock.Object); + + var cmabService = new TestCmabService + { + DefaultDecision = new CmabDecision(VARIATION_A_ID, TEST_CMAB_UUID) + }; + var decisionService = new DecisionService(new Bucketer(_loggerMock.Object), + _errorHandlerMock.Object, null, _loggerMock.Object, cmabService); + SetDecisionService(optimizelyWithNotifications, decisionService); + + var userContext = CreateCmabUserContext(optimizely: optimizelyWithNotifications); + + var decision = userContext.Decide(TEST_FEATURE_KEY); + + Assert.IsNotNull(decision); + Assert.AreEqual(TEST_FEATURE_KEY, decision.FlagKey); + Assert.AreEqual(VARIATION_A_KEY, decision.VariationKey); + _notificationCallbackMock.Verify(nc => nc.TestDecisionCallback( + DecisionNotificationTypes.FLAG, + TEST_USER_ID, + It.IsAny(), + It.IsAny>()), + Times.Once); + Assert.IsNotNull(capturedDecisionInfo); + Assert.AreEqual(TEST_FEATURE_KEY, capturedDecisionInfo["flagKey"]); + Assert.AreEqual(VARIATION_A_KEY, capturedDecisionInfo["variationKey"]); + Assert.AreEqual(1, cmabService.CallCount); + } + + private OptimizelyUserContext CreateCmabUserContext(string userId = TEST_USER_ID, + Optimizely optimizely = null, + IDictionary additionalAttributes = null) + { + var client = optimizely ?? _optimizely; + var userContext = client.CreateUserContext(userId); + + userContext.SetAttribute(BROWSER_TYPE_ATTRIBUTE_KEY, "chrome"); + userContext.SetAttribute(DEVICE_TYPE_ATTRIBUTE_KEY, "mobile"); + + if (additionalAttributes != null) + { + foreach (var kvp in additionalAttributes) + { + userContext.SetAttribute(kvp.Key, kvp.Value); + } + } + + return userContext; + } + + private static void SetDecisionService(Optimizely optimizely, DecisionService decisionService) + { + var decisionServiceField = typeof(Optimizely).GetField("DecisionService", + BindingFlags.NonPublic | BindingFlags.Instance); + decisionServiceField?.SetValue(optimizely, decisionService); + } + + private void ConfigureCmabExperiment(ProjectConfig config, + string experimentId, + string experimentKey, + int trafficAllocation = 10000, + IEnumerable attributeIds = null) + { + Assert.IsNotNull(config, "Project config should be available for CMAB tests."); + + var attributeList = attributeIds?.ToList() ?? + new List { DEVICE_TYPE_ATTRIBUTE_ID }; + + var experiment = config.ExperimentIdMap.TryGetValue(experimentId, out var existing) + ? existing + : config.GetExperimentFromKey(experimentKey); + + Assert.IsNotNull(experiment, $"Experiment {experimentKey} should exist for CMAB tests."); + + experiment.Cmab = new Entity.Cmab(attributeList, trafficAllocation); + + config.ExperimentIdMap[experiment.Id] = experiment; + if (config.ExperimentKeyMap.ContainsKey(experiment.Key)) + { + config.ExperimentKeyMap[experiment.Key] = experiment; + } + } + + private class TestCmabService : ICmabService + { + public int CallCount { get; private set; } + + public string LastRuleId { get; private set; } + + public OptimizelyUserContext LastUserContext { get; private set; } + + public List OptionsPerCall { get; } = + new List(); + + public Queue DecisionsQueue { get; } = new Queue(); + + public CmabDecision DefaultDecision { get; set; } + + public Exception ExceptionToThrow { get; set; } + + public bool ReturnNullNext { get; set; } + + public Func Handler { get; set; } + + public void EnqueueDecision(CmabDecision decision) + { + DecisionsQueue.Enqueue(decision); + } + + public CmabDecision GetDecision(ProjectConfig projectConfig, + OptimizelyUserContext userContext, + string ruleId, + OptimizelyDecideOption[] options = null) + { + CallCount++; + LastRuleId = ruleId; + LastUserContext = userContext; + var copiedOptions = options?.ToArray() ?? new OptimizelyDecideOption[0]; + OptionsPerCall.Add(copiedOptions); + + if (ExceptionToThrow != null) + { + var ex = ExceptionToThrow; + ExceptionToThrow = null; + throw ex; + } + + if (Handler != null) + { + return Handler(projectConfig, userContext, ruleId, copiedOptions); + } + + if (ReturnNullNext) + { + ReturnNullNext = false; + return null; + } + + if (DecisionsQueue.Count > 0) + { + return DecisionsQueue.Dequeue(); + } + + return DefaultDecision; + } + } + } +} diff --git a/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs b/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs index 3d34e151..5d8be677 100644 --- a/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs +++ b/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs @@ -65,7 +65,7 @@ public void Initialize() // Use real Bucketer instead of mock var realBucketer = new Bucketer(LoggerMock.Object); DecisionService = new DecisionService(realBucketer, - new ErrorHandler.NoOpErrorHandler(), null, LoggerMock.Object); + new ErrorHandler.NoOpErrorHandler(), null, LoggerMock.Object, null); // Create an Optimizely instance for creating user contexts var eventDispatcher = new Event.Dispatcher.DefaultEventDispatcher(LoggerMock.Object); diff --git a/OptimizelySDK.Tests/DecisionServiceTest.cs b/OptimizelySDK.Tests/DecisionServiceTest.cs index 8fbedf23..0378b93d 100644 --- a/OptimizelySDK.Tests/DecisionServiceTest.cs +++ b/OptimizelySDK.Tests/DecisionServiceTest.cs @@ -63,9 +63,9 @@ public void SetUp() WhitelistedVariation = WhitelistedExperiment.VariationKeyToVariationMap["vtag5"]; DecisionService = new DecisionService(new Bucketer(LoggerMock.Object), - ErrorHandlerMock.Object, null, LoggerMock.Object); + ErrorHandlerMock.Object, null, LoggerMock.Object, null); DecisionServiceMock = new Mock(BucketerMock.Object, - ErrorHandlerMock.Object, null, LoggerMock.Object) + ErrorHandlerMock.Object, null, LoggerMock.Object, null) { CallBase = true }; DecisionReasons = new DecisionReasons(); @@ -82,7 +82,7 @@ public void SetUp() public void TestFindValidatedForcedDecisionReturnsCorrectDecisionWithNullVariation() { var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), LoggerMock.Object); @@ -104,7 +104,7 @@ public void TestFindValidatedForcedDecisionReturnsCorrectDecisionWithNullVariati public void TestGetVariationForcedVariationPrecedesAudienceEval() { var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var experiment = ProjectConfig.Experiments[8]; var expectedVariation = experiment.Variations[0]; @@ -129,7 +129,7 @@ public void TestGetVariationForcedVariationPrecedesAudienceEval() WhitelistedUserId)), Times.Once); // no attributes provided for a experiment that has an audience - Assertions.AreEqual(expectedVariation, actualVariation.ResultObject); + Assertions.AreEqual(expectedVariation, actualVariation.ResultObject.Variation); BucketerMock.Verify( _ => _.Bucket(It.IsAny(), It.IsAny(), It.IsAny(), @@ -148,7 +148,7 @@ public void TestGetVariationLogsErrorWhenUserProfileMapItsNull() UserProfileServiceMock.Setup(up => up.Lookup(WhitelistedUserId)).Returns(userProfile); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + UserProfileServiceMock.Object, LoggerMock.Object, null); var options = new OptimizelyDecideOption[] { OptimizelyDecideOption.INCLUDE_REASONS }; var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), LoggerMock.Object); @@ -192,7 +192,7 @@ public void TestGetVariationEvaluatesUserProfileBeforeAudienceTargeting() Returns(userProfile.ToMap()); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + UserProfileServiceMock.Object, LoggerMock.Object, null); decisionService.GetVariation(experiment, OptimizelyUserContextMock.Object, ProjectConfig); @@ -216,7 +216,7 @@ public void TestGetVariationEvaluatesUserProfileBeforeAudienceTargeting() public void TestGetForcedVariationReturnsForcedVariation() { var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var expectedVariation = decisionService. GetWhitelistedVariation(WhitelistedExperiment, WhitelistedUserId). ResultObject; @@ -241,7 +241,7 @@ public void TestGetForcedVariationWithInvalidVariation() var invalidVariationKey = "invalidVarKey"; var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var variations = new Variation[] { @@ -286,7 +286,7 @@ public void TestGetForcedVariationReturnsNullWhenUserIsNotWhitelisted() { var bucketer = new Bucketer(LoggerMock.Object); var decisionService = new DecisionService(bucketer, ErrorHandlerMock.Object, null, - LoggerMock.Object); + LoggerMock.Object, null); Assert.IsNull(decisionService. GetWhitelistedVariation(WhitelistedExperiment, GenericUserId). @@ -323,11 +323,11 @@ public void TestBucketReturnsVariationStoredInUserProfile() OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns(UserProfileId); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + UserProfileServiceMock.Object, LoggerMock.Object, null); var actualVariation = decisionService.GetVariation(experiment, OptimizelyUserContextMock.Object, ProjectConfig); - Assertions.AreEqual(variation, actualVariation.ResultObject); + Assertions.AreEqual(variation, actualVariation.ResultObject.Variation); Assert.AreEqual(actualVariation.DecisionReasons.ToReport(true).Count, 1); Assert.AreEqual(actualVariation.DecisionReasons.ToReport(true)[0], @@ -352,7 +352,7 @@ public void TestGetStoredVariationLogsWhenLookupReturnsNull() UserProfileServiceMock.Setup(_ => _.Lookup(UserProfileId)).Returns(userProfile.ToMap()); var decisionService = new DecisionService(bucketer, - ErrorHandlerMock.Object, userProfileService, LoggerMock.Object); + ErrorHandlerMock.Object, userProfileService, LoggerMock.Object, null); Assert.IsNull(decisionService. GetStoredVariation(experiment, userProfile, ProjectConfig). @@ -382,7 +382,7 @@ public void TestGetStoredVariationReturnsNullWhenVariationIsNoLongerInConfig() Returns(storedUserProfile.ToMap()); var decisionService = new DecisionService(bucketer, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + UserProfileServiceMock.Object, LoggerMock.Object, null); Assert.IsNull(decisionService. GetStoredVariation(experiment, storedUserProfile, ProjectConfig). ResultObject); @@ -416,14 +416,14 @@ public void TestGetVariationSavesBucketedVariationIntoUserProfile() Returns(variation); var decisionService = new DecisionService(mockBucketer.Object, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + UserProfileServiceMock.Object, LoggerMock.Object, null); OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns(UserProfileId); Assert.IsTrue(TestData.CompareObjects(variation.ResultObject, decisionService. GetVariation(experiment, OptimizelyUserContextMock.Object, ProjectConfig). - ResultObject)); + ResultObject.Variation)); LoggerMock.Verify(l => l.Log(LogLevel.INFO, string.Format( "Saved variation \"{0}\" of experiment \"{1}\" for user \"{2}\".", @@ -453,7 +453,7 @@ public void TestBucketLogsCorrectlyWhenUserProfileFailsToSave() new UserProfile(UserProfileId, new Dictionary()); var decisionService = new DecisionService(bucketer, - ErrorHandlerMock.Object, UserProfileServiceMock.Object, LoggerMock.Object); + ErrorHandlerMock.Object, UserProfileServiceMock.Object, LoggerMock.Object, null); decisionService.SaveVariation(experiment, variation, saveUserProfile); @@ -489,13 +489,12 @@ public void TestGetVariationSavesANewUserProfile() UserProfileServiceMock.Setup(up => up.Lookup(UserProfileId)).Returns(userProfile); var decisionService = new DecisionService(mockBucketer.Object, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + UserProfileServiceMock.Object, LoggerMock.Object, null); OptimizelyUserContextMock.Setup(ouc => ouc.GetUserId()).Returns(UserProfileId); var actualVariation = decisionService.GetVariation(experiment, OptimizelyUserContextMock.Object, ProjectConfig); - - Assertions.AreEqual(variation.ResultObject, actualVariation.ResultObject); + Assertions.AreEqual(variation.ResultObject, actualVariation.ResultObject.Variation); UserProfileServiceMock.Verify(_ => _.Save(It.IsAny>()), Times.Once); @@ -650,7 +649,7 @@ public void TestGetVariationWithBucketingId() UserProfileServiceMock.Setup(up => up.Lookup(userId)). Returns(storedUserProfile.ToMap()); var decisionService = new DecisionService(bucketerMock.Object, ErrorHandlerMock.Object, - UserProfileServiceMock.Object, LoggerMock.Object); + UserProfileServiceMock.Object, LoggerMock.Object, null); actualVariation = optlyObject.GetVariation(experimentKey, userId, userAttributesWithBucketingId); @@ -732,10 +731,11 @@ public void TestGetVariationForFeatureExperimentGivenNonMutexGroupAndUserNotBuck public void TestGetVariationForFeatureExperimentGivenNonMutexGroupAndUserIsBucketed() { var experiment = ProjectConfig.GetExperimentFromKey("test_experiment_multivariate"); - var variation = Result.NewResult( - ProjectConfig.GetVariationFromId("test_experiment_multivariate", "122231"), + var variationObj = ProjectConfig.GetVariationFromId("test_experiment_multivariate", "122231"); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), DecisionReasons); - var expectedDecision = new FeatureDecision(experiment, variation.ResultObject, + var expectedDecision = new FeatureDecision(experiment, variationObj, FeatureDecision.DECISION_SOURCE_FEATURE_TEST); var userAttributes = new UserAttributes(); @@ -770,10 +770,12 @@ public void TestGetVariationForFeatureExperimentGivenNonMutexGroupAndUserIsBucke public void TestGetVariationForFeatureExperimentGivenMutexGroupAndUserIsBucketed() { var mutexExperiment = ProjectConfig.GetExperimentFromKey("group_experiment_1"); - var variation = - Result.NewResult(mutexExperiment.Variations[0], DecisionReasons); + var variationObj = mutexExperiment.Variations[0]; + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), + DecisionReasons); var userAttributes = new UserAttributes(); - var expectedDecision = new FeatureDecision(mutexExperiment, variation.ResultObject, + var expectedDecision = new FeatureDecision(mutexExperiment, variationObj, FeatureDecision.DECISION_SOURCE_FEATURE_TEST); var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), @@ -816,7 +818,7 @@ public void TestGetVariationForFeatureExperimentGivenMutexGroupAndUserNotBuckete It.IsAny(), ProjectConfig, It.IsAny(), It.IsAny(), It.IsAny())). - Returns(Result.NullResult(null)); + Returns(Result.NullResult(null)); var featureFlag = ProjectConfig.GetFeatureFlagFromKey("boolean_feature"); var actualDecision = DecisionServiceMock.Object.GetVariationForFeatureExperiment( @@ -850,7 +852,7 @@ public void TestGetVariationForFeatureRolloutWhenNoRuleInRollouts() var optimizelyUserContext = new OptimizelyUserContext(optlyObject, "userId1", null, ErrorHandlerMock.Object, LoggerMock.Object); var decisionService = new DecisionService(new Bucketer(new NoOpLogger()), - new NoOpErrorHandler(), null, new NoOpLogger()); + new NoOpErrorHandler(), null, new NoOpLogger(), null); var variation = decisionService.GetVariationForFeatureRollout(featureFlag, optimizelyUserContext, @@ -914,7 +916,7 @@ public void TestGetVariationForFeatureRolloutWhenUserIsBucketedInTheTargetingRul It.IsAny())). Returns(variation); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), LoggerMock.Object); @@ -957,7 +959,7 @@ public void It.IsAny(), It.IsAny())). Returns(variation); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), LoggerMock.Object); @@ -989,7 +991,7 @@ public void It.IsAny(), It.IsAny())). Returns(Result.NullResult(null)); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), LoggerMock.Object); @@ -1027,7 +1029,7 @@ public void TestGetVariationForFeatureRolloutWhenUserDoesNotQualifyForAnyTargeti It.IsAny(), It.IsAny())). Returns(variation); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); // Provide null attributes so that user does not qualify for audience. var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), @@ -1063,7 +1065,7 @@ public void TestGetVariationForFeatureRolloutAudienceAndTrafficeAllocationCheck( var mockBucketer = new Mock(LoggerMock.Object) { CallBase = true }; mockBucketer.Setup(bm => bm.GenerateBucketValue(It.IsAny())).Returns(980); var decisionService = new DecisionService(mockBucketer.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); // Calling with audience iPhone users in San Francisco. var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), @@ -1148,7 +1150,7 @@ public void TestGetVariationForFeatureRolloutCheckAudienceInEveryoneElseRule() Returns(Result.NullResult(DecisionReasons)); var decisionService = new DecisionService(BucketerMock.Object, ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); var optlyObject = new Optimizely(TestData.Datafile, new ValidEventDispatcher(), LoggerMock.Object); @@ -1312,10 +1314,11 @@ public void TestGetVariationForFeatureWhenTheUserIsBuckedtedInBothExperimentAndR var featureFlag = ProjectConfig.GetFeatureFlagFromKey("string_single_variable_feature"); var experiment = ProjectConfig.GetExperimentFromKey("test_experiment_with_feature_rollout"); - var variation = Result.NewResult( - ProjectConfig.GetVariationFromId("test_experiment_with_feature_rollout", "122236"), + var variationObj = ProjectConfig.GetVariationFromId("test_experiment_with_feature_rollout", "122236"); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), DecisionReasons); - var expectedDecision = new FeatureDecision(experiment, variation.ResultObject, + var expectedDecision = new FeatureDecision(experiment, variationObj, FeatureDecision.DECISION_SOURCE_FEATURE_TEST); var userAttributes = new UserAttributes { @@ -1332,7 +1335,7 @@ public void TestGetVariationForFeatureWhenTheUserIsBuckedtedInBothExperimentAndR BucketerMock. Setup(bm => bm.Bucket(ProjectConfig, experiment, It.IsAny(), It.IsAny())). - Returns(variation); + Returns(Result.NewResult(variationObj, DecisionReasons)); DecisionServiceMock.Setup(ds => ds.GetVariation(experiment, OptimizelyUserContextMock.Object, ProjectConfig, diff --git a/OptimizelySDK.Tests/OptimizelyFactoryTest.cs b/OptimizelySDK.Tests/OptimizelyFactoryTest.cs index e1cc7ebf..b52ad568 100644 --- a/OptimizelySDK.Tests/OptimizelyFactoryTest.cs +++ b/OptimizelySDK.Tests/OptimizelyFactoryTest.cs @@ -16,14 +16,20 @@ */ using System; +using System.Reflection; using Moq; using NUnit.Framework; +using OptimizelySDK.Bucketing; +using OptimizelySDK.Cmab; using OptimizelySDK.Config; using OptimizelySDK.Event; using OptimizelySDK.Event.Dispatcher; +using OptimizelySDK.ErrorHandler; using OptimizelySDK.Logger; using OptimizelySDK.Notifications; +using OptimizelySDK.Odp; using OptimizelySDK.Tests.ConfigTest; +using OptimizelySDK.Utils; using OptimizelySDK.Tests.EventTest; using OptimizelySDK.Tests.Utils; @@ -39,6 +45,13 @@ public void Initialize() { LoggerMock = new Mock(); LoggerMock.Setup(i => i.Log(It.IsAny(), It.IsAny())); + ResetCmabConfiguration(); + } + + [TearDown] + public void Cleanup() + { + ResetCmabConfiguration(); } [Test] @@ -244,5 +257,84 @@ public void TestGetFeatureVariableJSONEmptyDatafileTest() "userId")); optimizely.Dispose(); } + + [Test] + public void SetCmabConfigStoresCacheSizeAndTtl() + { + const int cacheSize = 1234; + var cacheTtl = TimeSpan.FromSeconds(45); + + var cmabConfig = new CmabConfig() + .SetCacheSize(cacheSize) + .SetCacheTtl(cacheTtl); + OptimizelyFactory.SetCmabConfig(cmabConfig); + + var config = GetCurrentCmabConfiguration(); + + Assert.IsNotNull(config); + Assert.AreEqual(cacheSize, config.CacheSize); + Assert.AreEqual(cacheTtl, config.CacheTtl); + Assert.IsNull(config.Cache); + } + + [Test] + public void SetCmabConfigStoresCustomCacheInstance() + { + var customCache = new LruCache(maxSize: 10, itemTimeout: TimeSpan.FromMinutes(2)); + + var cmabConfig = new CmabConfig() + .SetCache(customCache); + OptimizelyFactory.SetCmabConfig(cmabConfig); + + var config = GetCurrentCmabConfiguration(); + + Assert.IsNotNull(config); + Assert.AreSame(customCache, config.Cache); + Assert.IsNull(config.CacheSize); + Assert.IsNull(config.CacheTtl); + } + + [Test] + public void NewDefaultInstanceUsesConfiguredCmabCache() + { + const int cacheSize = 7; + var cacheTtl = TimeSpan.FromSeconds(30); + var cmabConfig = new CmabConfig() + .SetCacheSize(cacheSize) + .SetCacheTtl(cacheTtl); + OptimizelyFactory.SetCmabConfig(cmabConfig); + + var logger = new NoOpLogger(); + var errorHandler = new NoOpErrorHandler(); + var projectConfig = DatafileProjectConfig.Create(TestData.Datafile, logger, errorHandler); + var configManager = new FallbackProjectConfigManager(projectConfig); + + var optimizely = OptimizelyFactory.NewDefaultInstance(configManager, logger: logger, errorHandler: errorHandler); + + var decisionService = Reflection.GetFieldValue(optimizely, "DecisionService"); + Assert.IsNotNull(decisionService); + + var cmabService = Reflection.GetFieldValue(decisionService, "CmabService"); + Assert.IsInstanceOf(cmabService); + + var cache = Reflection.GetFieldValue, DefaultCmabService>((DefaultCmabService)cmabService, "_cmabCache") as LruCache; + Assert.IsNotNull(cache); + Assert.AreEqual(cacheSize, cache.MaxSizeForTesting); + Assert.AreEqual(cacheTtl, cache.TimeoutForTesting); + + optimizely.Dispose(); + } + + private static void ResetCmabConfiguration() + { + var field = typeof(OptimizelyFactory).GetField("CmabConfiguration", BindingFlags.NonPublic | BindingFlags.Static); + field?.SetValue(null, null); + } + + private static CmabConfig GetCurrentCmabConfiguration() + { + var field = typeof(OptimizelyFactory).GetField("CmabConfiguration", BindingFlags.NonPublic | BindingFlags.Static); + return field?.GetValue(null) as CmabConfig; + } } } diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj index 1b0b882e..c250d0e1 100644 --- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj +++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj @@ -24,7 +24,7 @@ full false bin\Debug\ - DEBUG;TRACE + DEBUG;TRACE;USE_CMAB prompt 4 @@ -32,7 +32,7 @@ pdbonly true bin\Release\ - TRACE + TRACE;USE_CMAB prompt 4 @@ -70,8 +70,12 @@ + + + + diff --git a/OptimizelySDK.Tests/OptimizelyTest.cs b/OptimizelySDK.Tests/OptimizelyTest.cs index 034b4bc0..3025dc89 100644 --- a/OptimizelySDK.Tests/OptimizelyTest.cs +++ b/OptimizelySDK.Tests/OptimizelyTest.cs @@ -115,7 +115,7 @@ public void Initialize() DecisionServiceMock = new Mock(new Bucketer(LoggerMock.Object), ErrorHandlerMock.Object, - null, LoggerMock.Object); + null, LoggerMock.Object, null); NotificationCenter = new NotificationCenter(LoggerMock.Object); NotificationCallbackMock = new Mock(); @@ -3371,12 +3371,13 @@ public void TestActivateListener(UserAttributes userAttributes) var variationKey = "group_exp_1_var_1"; var featureKey = "boolean_feature"; var experiment = Config.GetExperimentFromKey(experimentKey); - var variation = - Result.NewResult(Config.GetVariationFromKey(experimentKey, variationKey), - DecisionReasons); + var variationObj = Config.GetVariationFromKey(experimentKey, variationKey); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), + DecisionReasons); var featureFlag = Config.GetFeatureFlagFromKey(featureKey); var decision = Result.NewResult( - new FeatureDecision(experiment, variation.ResultObject, + new FeatureDecision(experiment, variationObj, FeatureDecision.DECISION_SOURCE_FEATURE_TEST), DecisionReasons); // Mocking objects. @@ -3424,10 +3425,10 @@ public void TestActivateListener(UserAttributes userAttributes) NotificationCallbackMock.Verify( nc => nc.TestActivateCallback(experiment, TestUserId, userAttributes, - variation.ResultObject, It.IsAny()), Times.Exactly(2)); + variation.ResultObject.Variation, It.IsAny()), Times.Exactly(2)); NotificationCallbackMock.Verify( nc => nc.TestAnotherActivateCallback(experiment, TestUserId, userAttributes, - variation.ResultObject, It.IsAny()), Times.Exactly(2)); + variation.ResultObject.Variation, It.IsAny()), Times.Exactly(2)); } [Test] @@ -3487,9 +3488,10 @@ public void TestTrackListener(UserAttributes userAttributes, EventTags eventTags var variationKey = "control"; var eventKey = "purchase"; var experiment = Config.GetExperimentFromKey(experimentKey); - var variation = - Result.NewResult(Config.GetVariationFromKey(experimentKey, variationKey), - DecisionReasons); + var variationObj = Config.GetVariationFromKey(experimentKey, variationKey); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), + DecisionReasons); var logEvent = new LogEvent(EventFactory.EventEndpoints["US"], OptimizelyHelper.SingleParameter, "POST", new Dictionary()); @@ -3545,9 +3547,10 @@ public void TestActivateSendsDecisionNotificationWithActualVariationKey() var experimentKey = "test_experiment"; var variationKey = "variation"; var experiment = Config.GetExperimentFromKey(experimentKey); - var variation = - Result.NewResult(Config.GetVariationFromKey(experimentKey, variationKey), - DecisionReasons); + var variationObj = Config.GetVariationFromKey(experimentKey, variationKey); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), + DecisionReasons); var userAttributes = new UserAttributes { { @@ -3602,9 +3605,10 @@ public void var experimentKey = "group_experiment_1"; var variationKey = "group_exp_1_var_1"; var experiment = Config.GetExperimentFromKey(experimentKey); - var variation = - Result.NewResult(Config.GetVariationFromKey(experimentKey, variationKey), - DecisionReasons); + var variationObj = Config.GetVariationFromKey(experimentKey, variationKey); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), + DecisionReasons); var userAttributes = new UserAttributes { { @@ -3668,7 +3672,7 @@ public void TestActivateSendsDecisionNotificationWithNullVariationKey() DecisionServiceMock.Setup(ds => ds.GetVariation(experiment, It.IsAny(), It.IsAny(), null)) - .Returns(Result.NullResult(null)); + .Returns(Result.NullResult(null)); optStronglyTyped.NotificationCenter.AddNotification( NotificationCenter.NotificationType.Decision, @@ -3697,9 +3701,10 @@ public void TestGetVariationSendsDecisionNotificationWithActualVariationKey() var experimentKey = "test_experiment"; var variationKey = "variation"; var experiment = Config.GetExperimentFromKey(experimentKey); - var variation = - Result.NewResult(Config.GetVariationFromKey(experimentKey, variationKey), - DecisionReasons); + var variationObj = Config.GetVariationFromKey(experimentKey, variationKey); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), + DecisionReasons); var userAttributes = new UserAttributes { { @@ -3760,9 +3765,10 @@ public void var experimentKey = "group_experiment_1"; var variationKey = "group_exp_1_var_1"; var experiment = Config.GetExperimentFromKey(experimentKey); - var variation = - Result.NewResult(Config.GetVariationFromKey(experimentKey, variationKey), - DecisionReasons); + var variationObj = Config.GetVariationFromKey(experimentKey, variationKey); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), + DecisionReasons); var userAttributes = new UserAttributes { { @@ -3830,7 +3836,7 @@ public void TestGetVariationSendsDecisionNotificationWithNullVariationKey() DecisionServiceMock.Setup(ds => ds.GetVariation(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Result.NullResult(null)); + .Returns(Result.NullResult(null)); //DecisionServiceMock.Setup(ds => ds.GetVariation(experiment, TestUserId, Config, null)).Returns(Result.NullResult(null)); optStronglyTyped.NotificationCenter.AddNotification( @@ -3859,12 +3865,13 @@ public void { var featureKey = "double_single_variable_feature"; var experiment = Config.GetExperimentFromKey("test_experiment_double_feature"); - var variation = Result.NewResult( - Config.GetVariationFromKey("test_experiment_double_feature", "control"), + var variationObj = Config.GetVariationFromKey("test_experiment_double_feature", "control"); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), DecisionReasons); var featureFlag = Config.GetFeatureFlagFromKey(featureKey); var decision = Result.NewResult( - new FeatureDecision(experiment, variation.ResultObject, + new FeatureDecision(experiment, variationObj, FeatureDecision.DECISION_SOURCE_FEATURE_TEST), DecisionReasons); DecisionServiceMock.Setup(ds => @@ -3920,12 +3927,13 @@ public void { var featureKey = "double_single_variable_feature"; var experiment = Config.GetExperimentFromKey("test_experiment_double_feature"); - var variation = Result.NewResult( - Config.GetVariationFromKey("test_experiment_double_feature", "variation"), + var variationObj = Config.GetVariationFromKey("test_experiment_double_feature", "variation"); + var variation = Result.NewResult( + new VariationDecisionResult(variationObj, null, false), DecisionReasons); var featureFlag = Config.GetFeatureFlagFromKey(featureKey); var decision = Result.NewResult( - new FeatureDecision(experiment, variation.ResultObject, + new FeatureDecision(experiment, variationObj, FeatureDecision.DECISION_SOURCE_FEATURE_TEST), DecisionReasons); DecisionServiceMock.Setup(ds => diff --git a/OptimizelySDK/Bucketing/Bucketer.cs b/OptimizelySDK/Bucketing/Bucketer.cs index f891fc76..d354d4be 100644 --- a/OptimizelySDK/Bucketing/Bucketer.cs +++ b/OptimizelySDK/Bucketing/Bucketer.cs @@ -105,34 +105,34 @@ IEnumerable trafficAllocations } /// - /// Determine variation the user should be put in. + /// Bucket user to an entity ID based on traffic allocations. + /// This method is used for CMAB experiments where we need to determine if a user + /// is in the traffic allocation before fetching the CMAB decision. /// /// ProjectConfig Configuration for the project - /// Experiment Experiment in which user is to be bucketed + /// Experiment in which user is to be bucketed /// A customer-assigned value used to create the key for the murmur hash. /// User identifier - /// Variation which will be shown to the user - public virtual Result Bucket(ProjectConfig config, ExperimentCore experiment, - string bucketingId, string userId + /// Traffic allocations to use for bucketing + /// Entity ID (string) if user is bucketed, null otherwise + public virtual Result BucketToEntityId(ProjectConfig config, ExperimentCore experiment, + string bucketingId, string userId, IEnumerable trafficAllocations ) { string message; - Variation variation; - var reasons = new DecisionReasons(); - if (string.IsNullOrEmpty(experiment.Key)) + if (string.IsNullOrEmpty(experiment?.Key)) { - return Result.NewResult(new Variation(), reasons); + return Result.NullResult(reasons); } - // Determine if experiment is in a mutually exclusive group. if (experiment is Experiment exp && exp.IsInMutexGroup) { var group = config.GetGroup(exp.GroupId); - if (string.IsNullOrEmpty(group.Id)) + if (string.IsNullOrEmpty(group?.Id)) { - return Result.NewResult(new Variation(), reasons); + return Result.NullResult(reasons); } var userExperimentId = @@ -141,7 +141,7 @@ public virtual Result Bucket(ProjectConfig config, ExperimentCore exp { message = $"User [{userId}] is in no experiment."; Logger.Log(LogLevel.INFO, reasons.AddInfo(message)); - return Result.NewResult(new Variation(), reasons); + return Result.NullResult(reasons); } if (userExperimentId != experiment.Id) @@ -149,7 +149,7 @@ public virtual Result Bucket(ProjectConfig config, ExperimentCore exp message = $"User [{userId}] is not in experiment [{exp.Key}] of group [{exp.GroupId}]."; Logger.Log(LogLevel.INFO, reasons.AddInfo(message)); - return Result.NewResult(new Variation(), reasons); + return Result.NullResult(reasons); } message = @@ -157,21 +157,58 @@ public virtual Result Bucket(ProjectConfig config, ExperimentCore exp Logger.Log(LogLevel.INFO, reasons.AddInfo(message)); } - // Bucket user if not in whitelist and in group (if any). - var variationId = FindBucket(bucketingId, userId, experiment.Id, + var entityId = FindBucket(bucketingId, userId, experiment.Id, trafficAllocations); + + if (string.IsNullOrEmpty(entityId)) + { + Logger.Log(LogLevel.INFO, reasons.AddInfo($"User [{userId}] is in no variation.")); + return Result.NullResult(reasons); + } + + return Result.NewResult(entityId, reasons); + } + + /// + /// Determine variation the user should be put in. + /// + /// ProjectConfig Configuration for the project + /// Experiment Experiment in which user is to be bucketed + /// A customer-assigned value used to create the key for the murmur hash. + /// User identifier + /// Variation which will be shown to the user + public virtual Result Bucket(ProjectConfig config, ExperimentCore experiment, + string bucketingId, string userId + ) + { + string message; + var reasons = new DecisionReasons(); + + if (string.IsNullOrEmpty(experiment?.Key)) + { + return Result.NewResult(new Variation(), reasons); + } + + var bucketResult = BucketToEntityId(config, experiment, bucketingId, userId, experiment.TrafficAllocation); + + reasons += bucketResult.DecisionReasons; + + var variationId = bucketResult.ResultObject; + if (string.IsNullOrEmpty(variationId)) { - Logger.Log(LogLevel.INFO, reasons.AddInfo($"User [{userId}] is in no variation.")); return Result.NewResult(new Variation(), reasons); } - // success! - variation = config.GetVariationFromIdByExperimentId(experiment.Id, variationId); + var variation = config.GetVariationFromIdByExperimentId(experiment.Id, variationId); message = $"User [{userId}] is in variation [{variation.Key}] of experiment [{experiment.Key}]."; + Logger.Log(LogLevel.INFO, reasons.AddInfo(message)); - return Result.NewResult(variation, reasons); + + var result = Result.NewResult(variation, reasons); + + return result; } } } diff --git a/OptimizelySDK/Bucketing/DecisionService.cs b/OptimizelySDK/Bucketing/DecisionService.cs index 7bc8054b..5dce0dfe 100644 --- a/OptimizelySDK/Bucketing/DecisionService.cs +++ b/OptimizelySDK/Bucketing/DecisionService.cs @@ -14,6 +14,11 @@ * limitations under the License. */ +#if !(NET35 || NET40 || NETSTANDARD1_6) +#define USE_CMAB +#endif + + using System; using System.Collections.Generic; using System.Linq; @@ -24,6 +29,11 @@ using OptimizelySDK.Utils; using static OptimizelySDK.Entity.Holdout; +#if USE_CMAB +using OptimizelySDK.Cmab; +#endif + + namespace OptimizelySDK.Bucketing { /// @@ -45,6 +55,9 @@ public class DecisionService private IErrorHandler ErrorHandler; private UserProfileService UserProfileService; private ILogger Logger; +#if USE_CMAB + private ICmabService CmabService; +#endif /// /// Associative array of user IDs to an associative array @@ -64,16 +77,23 @@ public class DecisionService /// /// Base bucketer to allocate new users to an experiment. /// The error handler of the Optimizely client. - /// - /// < param name= "logger" > UserProfileService implementation for storing user info. + /// UserProfileService implementation for storing user info. + /// Logger for logging messages. + /// CMAB service for fetching CMAB decisions. Optional. public DecisionService(Bucketer bucketer, IErrorHandler errorHandler, UserProfileService userProfileService, ILogger logger +#if USE_CMAB + , ICmabService cmabService = null +#endif ) { Bucketer = bucketer; ErrorHandler = errorHandler; UserProfileService = userProfileService; Logger = logger; +#if USE_CMAB + CmabService = cmabService; +#endif #if NET35 ForcedVariationMap = new Dictionary>(); #else @@ -89,8 +109,8 @@ public DecisionService(Bucketer bucketer, IErrorHandler errorHandler, /// The Experiment the user will be bucketed into. /// Optimizely user context. /// Project config. - /// The Variation the user is allocated into. - public virtual Result GetVariation(Experiment experiment, + /// The VariationDecisionResult containing variation and CMAB metadata. + public virtual Result GetVariation(Experiment experiment, OptimizelyUserContext user, ProjectConfig config ) @@ -105,8 +125,8 @@ ProjectConfig config /// Optimizely user context. /// Project Config. /// An array of decision options. - /// - public virtual Result GetVariation(Experiment experiment, + /// The VariationDecisionResult containing variation and CMAB metadata. + public virtual Result GetVariation(Experiment experiment, OptimizelyUserContext user, ProjectConfig config, OptimizelyDecideOption[] options @@ -145,8 +165,8 @@ OptimizelyDecideOption[] options /// An array of decision options. /// A UserProfileTracker object. /// Set of reasons for the decision. - /// The Variation the user is allocated into. - public virtual Result GetVariation(Experiment experiment, + /// The VariationDecisionResult containing variation and CMAB metadata. + public virtual Result GetVariation(Experiment experiment, OptimizelyUserContext user, ProjectConfig config, OptimizelyDecideOption[] options, @@ -163,7 +183,7 @@ public virtual Result GetVariation(Experiment experiment, { var message = reasons.AddInfo($"Experiment {experiment.Key} is not running."); Logger.Log(LogLevel.INFO, message); - return Result.NullResult(reasons); + return Result.NullResult(reasons); } var userId = user.GetUserId(); @@ -181,8 +201,8 @@ public virtual Result GetVariation(Experiment experiment, if (variation != null) { - decisionVariation.SetReasons(reasons); - return decisionVariation; + return Result.NewResult( + new VariationDecisionResult(variation), reasons); } if (userProfileTracker != null) @@ -193,7 +213,8 @@ public virtual Result GetVariation(Experiment experiment, variation = decisionVariation.ResultObject; if (variation != null) { - return decisionVariation; + return Result.NewResult( + new VariationDecisionResult(variation), reasons); } } @@ -205,6 +226,19 @@ public virtual Result GetVariation(Experiment experiment, { var bucketingId = GetBucketingId(userId, user.GetAttributes()).ResultObject; +#if USE_CMAB + if (experiment.Cmab != null) + { + var cmabDecisionResult = + GetDecisionForCmabExperiment(experiment, user, config, bucketingId, options); + reasons += cmabDecisionResult.DecisionReasons; + + return Result.NewResult( + cmabDecisionResult.ResultObject, reasons); + } +#endif + + // Standard (non-CMAB) bucketing decisionVariation = Bucketer.Bucket(config, experiment, bucketingId, userId); reasons += decisionVariation.DecisionReasons; variation = decisionVariation.ResultObject; @@ -220,18 +254,101 @@ public virtual Result GetVariation(Experiment experiment, Logger.Log(LogLevel.INFO, "This decision will not be saved since the UserProfileService is null."); } + + return Result.NewResult( + new VariationDecisionResult(variation), reasons); } - return decisionVariation.SetReasons(reasons); + return Result.NullResult(reasons); } Logger.Log(LogLevel.INFO, reasons.AddInfo( $"User \"{user.GetUserId()}\" does not meet conditions to be in experiment \"{experiment.Key}\".")); - return Result.NullResult(reasons); + return Result.NullResult(reasons); } + /// + /// Get decision for CMAB (Contextual Multi-Armed Bandit) experiment. + /// This method checks if the user is in the CMAB traffic allocation and fetches + /// the variation from the CMAB service. + /// + /// The CMAB experiment + /// Optimizely user context + /// Project config + /// Bucketing ID for the user + /// Decision options + /// Result containing VariationDecisionResult with variation, CMAB UUID, and error status +#if USE_CMAB + private Result GetDecisionForCmabExperiment( + Experiment experiment, + OptimizelyUserContext user, + ProjectConfig config, + string bucketingId, + OptimizelyDecideOption[] options + ) + { + var reasons = new DecisionReasons(); + var userId = user.GetUserId(); + + // dummy traffic allocation for CMAB + var cmabTrafficAllocation = new List + { + new TrafficAllocation + { + EntityId = "$", + EndOfRange = experiment.Cmab.TrafficAllocation, + }, + }; + + var bucketResult = Bucketer.BucketToEntityId(config, experiment, bucketingId, userId, + cmabTrafficAllocation); + reasons += bucketResult.DecisionReasons; + + var entityId = bucketResult.ResultObject; + if (string.IsNullOrEmpty(entityId)) + { + var message = string.Format(CmabConstants.USER_NOT_IN_CMAB_EXPERIMENT, userId, + experiment.Key); + Logger.Log(LogLevel.INFO, reasons.AddInfo(message)); + return Result.NewResult( + new VariationDecisionResult(null), reasons); + } + + try + { + var cmabDecision = CmabService.GetDecision(config, user, experiment.Id, options); + + var variation = config.GetVariationFromIdByExperimentId(experiment.Id, + cmabDecision.VariationId); + + if (variation == null) + { + var message = + $"User [{userId}] bucketed into invalid variation [{cmabDecision.VariationId}] for CMAB experiment [{experiment.Key}]."; + Logger.Log(LogLevel.INFO, reasons.AddInfo(message)); + return Result.NewResult( + new VariationDecisionResult(null), reasons); + } + + var successMessage = string.Format(CmabConstants.CMAB_DECISION_FETCHED, userId, + experiment.Key); + Logger.Log(LogLevel.INFO, reasons.AddInfo(successMessage)); + + return Result.NewResult( + new VariationDecisionResult(variation, cmabDecision.CmabUuid), reasons); + } + catch (Exception ex) + { + var message = string.Format(CmabConstants.CMAB_FETCH_FAILED, experiment.Key); + Logger.Log(LogLevel.ERROR, reasons.AddInfo($"{message} Error: {ex.Message}")); + return Result.NewResult( + new VariationDecisionResult(null, null, true), reasons); + } + } +#endif + /// /// Gets the forced variation for the given user and experiment. /// @@ -669,6 +786,9 @@ public virtual Result GetVariationForFeatureExperiment( { var experiment = config.GetExperimentFromId(experimentId); Variation decisionVariation = null; +#if USE_CMAB + string cmabUuid = null; +#endif if (string.IsNullOrEmpty(experiment.Key)) { @@ -691,7 +811,22 @@ public virtual Result GetVariationForFeatureExperiment( userProfileTracker); reasons += decisionResponse?.DecisionReasons; - decisionVariation = decisionResponse.ResultObject; + var variationResult = decisionResponse.ResultObject; + decisionVariation = variationResult?.Variation; +#if USE_CMAB + cmabUuid = variationResult?.CmabUuid; + + if (variationResult?.CmabError == true) + { + Logger.Log(LogLevel.ERROR, + reasons.AddInfo( + $"Failed to fetch CMAB decision for user \"{userId}\" in experiment \"{experiment.Key}\" of feature \"{featureFlag.Key}\".")); + + var errorDecision = new FeatureDecision(experiment, null, + FeatureDecision.DECISION_SOURCE_FEATURE_TEST, null, error: true); + return Result.NewResult(errorDecision, reasons); + } +#endif } if (!string.IsNullOrEmpty(decisionVariation?.Id)) @@ -700,8 +835,13 @@ public virtual Result GetVariationForFeatureExperiment( reasons.AddInfo( $"The user \"{userId}\" is bucketed into experiment \"{experiment.Key}\" of feature \"{featureFlag.Key}\".")); +#if USE_CMAB + var featureDecision = new FeatureDecision(experiment, decisionVariation, + FeatureDecision.DECISION_SOURCE_FEATURE_TEST, cmabUuid); +#else var featureDecision = new FeatureDecision(experiment, decisionVariation, FeatureDecision.DECISION_SOURCE_FEATURE_TEST); +#endif return Result.NewResult(featureDecision, reasons); } } diff --git a/OptimizelySDK/Bucketing/VariationDecisionResult.cs b/OptimizelySDK/Bucketing/VariationDecisionResult.cs new file mode 100644 index 00000000..b2e8f2d9 --- /dev/null +++ b/OptimizelySDK/Bucketing/VariationDecisionResult.cs @@ -0,0 +1,51 @@ +/* + * 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 OptimizelySDK.Entity; + +namespace OptimizelySDK.Bucketing +{ + /// + /// Represents the result of a variation decision, including CMAB-specific fields. + /// + public class VariationDecisionResult + { + public VariationDecisionResult(Variation variation, string cmabUuid = null, + bool cmabError = false + ) + { + Variation = variation; + CmabUuid = cmabUuid; + CmabError = cmabError; + } + + /// + /// The variation selected for the user. Null if no variation was selected. + /// + public Variation Variation { get; set; } + + /// + /// The CMAB UUID associated with this decision. Null for non-CMAB experiments. + /// + public string CmabUuid { get; set; } + + /// + /// Indicates whether an error occurred during the CMAB decision process. + /// False for non-CMAB experiments or successful CMAB decisions. + /// + public bool CmabError { get; set; } + } +} diff --git a/OptimizelySDK/Cmab/CmabConfig.cs b/OptimizelySDK/Cmab/CmabConfig.cs new file mode 100644 index 00000000..55b7fc11 --- /dev/null +++ b/OptimizelySDK/Cmab/CmabConfig.cs @@ -0,0 +1,80 @@ +/* + * 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 OptimizelySDK.Cmab; +using OptimizelySDK.Utils; + +namespace OptimizelySDK +{ + /// + /// Configuration options for CMAB (Contextual Multi-Armed Bandit) functionality. + /// + public class CmabConfig + { + /// + /// Gets or sets the maximum number of entries in the CMAB cache. + /// If null, the default value (1000) will be used. + /// + public int? CacheSize { get; private set; } + + /// + /// Gets or sets the time-to-live for CMAB cache entries. + /// If null, the default value (30 minutes) will be used. + /// + public TimeSpan? CacheTtl { get; private set; } + + /// + /// Gets or sets the custom cache implementation for CMAB decisions. + /// If provided, CacheSize and CacheTtl will be ignored. + /// + public ICacheWithRemove Cache { get; private set; } + + /// + /// Sets the maximum number of entries in the CMAB cache. + /// + /// Maximum number of entries in the cache. + /// This CmabConfig instance for method chaining. + public CmabConfig SetCacheSize(int cacheSize) + { + CacheSize = cacheSize; + return this; + } + + /// + /// Sets the time-to-live for CMAB cache entries. + /// + /// Time-to-live for cache entries. + /// This CmabConfig instance for method chaining. + public CmabConfig SetCacheTtl(TimeSpan cacheTtl) + { + CacheTtl = cacheTtl; + return this; + } + + /// + /// Sets a custom cache implementation for CMAB decisions. + /// When set, CacheSize and CacheTtl will be ignored. + /// + /// Custom cache implementation for CMAB decisions. + /// This CmabConfig instance for method chaining. + public CmabConfig SetCache(ICacheWithRemove cache) + { + Cache = cache ?? throw new ArgumentNullException(nameof(cache)); + return this; + } + } +} diff --git a/OptimizelySDK/Cmab/CmabConstants.cs b/OptimizelySDK/Cmab/CmabConstants.cs index 8c3659a1..a695314c 100644 --- a/OptimizelySDK/Cmab/CmabConstants.cs +++ b/OptimizelySDK/Cmab/CmabConstants.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; @@ -20,13 +20,30 @@ namespace OptimizelySDK.Cmab { internal static class CmabConstants { - public const string PredictionUrl = "https://prediction.cmab.optimizely.com/predict"; - public static readonly TimeSpan MaxTimeout = TimeSpan.FromSeconds(10); + public const string PREDICTION_URL = "https://prediction.cmab.optimizely.com/predict"; + public const int DEFAULT_CACHE_SIZE = 10_000; + public const string CONTENT_TYPE = "application/json"; - public const string ContentTypeJson = "application/json"; + public const string ERROR_FETCH_FAILED_FMT = "CMAB decision fetch failed with status: {0}"; + public const string ERROR_INVALID_RESPONSE = "Invalid CMAB fetch response"; + public const string EXHAUST_RETRY_MESSAGE = "Exhausted all retries for CMAB request"; - public const string ErrorFetchFailedFmt = "CMAB decision fetch failed with status: {0}"; - public const string ErrorInvalidResponse = "Invalid CMAB fetch response"; - public const string ExhaustRetryMessage = "Exhausted all retries for CMAB request"; + public const string USER_NOT_IN_CMAB_EXPERIMENT = + "User [{0}] not in CMAB experiment [{1}] due to traffic allocation."; + + public const string CMAB_FETCH_FAILED = + "Failed to fetch CMAB decision for experiment [{0}]."; + + public const string CMAB_DECISION_FETCHED = + "CMAB decision fetched for user [{0}] in experiment [{1}]."; + + public const string CMAB_EXPERIMENT_NOT_PROPERLY_CONFIGURED = + "CMAB experiment [{0}] is not properly configured."; + + public static readonly TimeSpan MAX_TIMEOUT = TimeSpan.FromSeconds(10); + public static readonly TimeSpan DEFAULT_CACHE_TTL = TimeSpan.FromMinutes(10); + + public const int CMAB_MAX_RETRIES = 1; + public static readonly TimeSpan CMAB_INITIAL_BACKOFF = TimeSpan.FromMilliseconds(100); } } diff --git a/OptimizelySDK/Cmab/DefaultCmabClient.cs b/OptimizelySDK/Cmab/DefaultCmabClient.cs index 3faaec75..a06f2149 100644 --- a/OptimizelySDK/Cmab/DefaultCmabClient.cs +++ b/OptimizelySDK/Cmab/DefaultCmabClient.cs @@ -58,9 +58,9 @@ private async Task FetchDecisionAsync( string cmabUuid, TimeSpan? timeout = null) { - var url = $"{CmabConstants.PredictionUrl}/{ruleId}"; + var url = $"{CmabConstants.PREDICTION_URL}/{ruleId}"; var body = BuildRequestBody(ruleId, userId, attributes, cmabUuid); - var perAttemptTimeout = timeout ?? CmabConstants.MaxTimeout; + var perAttemptTimeout = timeout ?? CmabConstants.MAX_TIMEOUT; if (_retryConfig == null) { @@ -90,7 +90,7 @@ public string FetchDecision( private static StringContent BuildContent(object payload) { var json = JsonConvert.SerializeObject(payload); - return new StringContent(json, Encoding.UTF8, CmabConstants.ContentTypeJson); + return new StringContent(json, Encoding.UTF8, CmabConstants.CONTENT_TYPE); } private static CmabRequest BuildRequestBody(string ruleId, string userId, IDictionary attributes, string cmabUuid) @@ -135,8 +135,8 @@ private async Task DoFetchOnceAsync(string url, CmabRequest request, Tim if (!response.IsSuccessStatusCode) { var status = (int)response.StatusCode; - _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, status)); - throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, status)); + _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, status)); + throw new CmabFetchException(string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, status)); } var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false); @@ -144,8 +144,8 @@ private async Task DoFetchOnceAsync(string url, CmabRequest request, Tim var j = JObject.Parse(responseText); if (!ValidateResponse(j)) { - _logger.Log(LogLevel.ERROR, CmabConstants.ErrorInvalidResponse); - throw new CmabInvalidResponseException(CmabConstants.ErrorInvalidResponse); + _logger.Log(LogLevel.ERROR, CmabConstants.ERROR_INVALID_RESPONSE); + throw new CmabInvalidResponseException(CmabConstants.ERROR_INVALID_RESPONSE); } var variationIdToken = j["predictions"][0]["variation_id"]; @@ -153,7 +153,7 @@ private async Task DoFetchOnceAsync(string url, CmabRequest request, Tim } catch (JsonException ex) { - _logger.Log(LogLevel.ERROR, CmabConstants.ErrorInvalidResponse); + _logger.Log(LogLevel.ERROR, CmabConstants.ERROR_INVALID_RESPONSE); throw new CmabInvalidResponseException(ex.Message); } catch (CmabInvalidResponseException) @@ -162,13 +162,13 @@ private async Task DoFetchOnceAsync(string url, CmabRequest request, Tim } catch (HttpRequestException ex) { - _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); - throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); + _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, ex.Message)); + throw new CmabFetchException(string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, ex.Message)); } catch (Exception ex) { - _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); - throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, ex.Message)); + _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, ex.Message)); + throw new CmabFetchException(string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, ex.Message)); } } } @@ -187,8 +187,8 @@ private async Task DoFetchWithRetryAsync(string url, CmabRequest request { if (attempt >= _retryConfig.MaxRetries) { - _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ErrorFetchFailedFmt, CmabConstants.ExhaustRetryMessage)); - throw new CmabFetchException(string.Format(CmabConstants.ErrorFetchFailedFmt, CmabConstants.ExhaustRetryMessage)); + _logger.Log(LogLevel.ERROR, string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, CmabConstants.EXHAUST_RETRY_MESSAGE)); + throw new CmabFetchException(string.Format(CmabConstants.ERROR_FETCH_FAILED_FMT, CmabConstants.EXHAUST_RETRY_MESSAGE)); } _logger.Log(LogLevel.INFO, $"Retrying CMAB request (attempt: {attempt + 1}) after {backoff.TotalSeconds} seconds..."); diff --git a/OptimizelySDK/Cmab/DefaultCmabService.cs b/OptimizelySDK/Cmab/DefaultCmabService.cs index 2cdf18c3..86b4d649 100644 --- a/OptimizelySDK/Cmab/DefaultCmabService.cs +++ b/OptimizelySDK/Cmab/DefaultCmabService.cs @@ -23,8 +23,8 @@ using OptimizelySDK; using OptimizelySDK.Entity; using OptimizelySDK.Logger; -using OptimizelySDK.Odp; using OptimizelySDK.OptimizelyDecisions; +using OptimizelySDK.Utils; using AttributeEntity = OptimizelySDK.Entity.Attribute; namespace OptimizelySDK.Cmab @@ -83,29 +83,70 @@ public class CmabCacheEntry /// public class DefaultCmabService : ICmabService { - private readonly LruCache _cmabCache; + /// + /// Number of lock stripes to use for concurrency control. + /// Using multiple locks reduces contention while ensuring the same user/rule combination always uses the same lock. + /// + private const int NUM_LOCK_STRIPES = 1000; + + private readonly ICacheWithRemove _cmabCache; private readonly ICmabClient _cmabClient; private readonly ILogger _logger; + private readonly object[] _locks; /// /// Initializes a new instance of the DefaultCmabService class. /// - /// LRU cache for storing CMAB decisions. + /// Cache for storing CMAB decisions. /// Client for fetching decisions from the CMAB prediction service. - /// Optional logger for recording service operations. - public DefaultCmabService(LruCache cmabCache, + /// Logger for recording service operations. + public DefaultCmabService(ICacheWithRemove cmabCache, ICmabClient cmabClient, - ILogger logger = null) + ILogger logger) { _cmabCache = cmabCache; _cmabClient = cmabClient; - _logger = logger ?? new NoOpLogger(); + _logger = logger; + _locks = Enumerable.Range(0, NUM_LOCK_STRIPES).Select(_ => new object()).ToArray(); + } + + /// + /// Calculate the lock index for a given user and rule combination. + /// Uses MurmurHash to ensure consistent lock selection for the same user/rule while distributing different combinations across locks. + /// + /// The user ID. + /// The experiment/rule ID. + /// The lock index in the range [0, NUM_LOCK_STRIPES). + internal int GetLockIndex(string userId, string ruleId) + { + var hashInput = $"{userId}{ruleId}"; + var murmer32 = Murmur.MurmurHash.Create32(0, true); + var data = Encoding.UTF8.GetBytes(hashInput); + var hash = murmer32.ComputeHash(data); + var hashValue = BitConverter.ToUInt32(hash, 0); + return (int)(hashValue % NUM_LOCK_STRIPES); } public CmabDecision GetDecision(ProjectConfig projectConfig, OptimizelyUserContext userContext, string ruleId, OptimizelyDecideOption[] options = null) + { + var lockIndex = GetLockIndex(userContext.GetUserId(), ruleId); + lock (_locks[lockIndex]) + { + return GetDecisionInternal(projectConfig, userContext, ruleId, options); + } + } + + /// + /// Internal implementation of GetDecision that performs the actual decision logic. + /// This method should only be called while holding the appropriate lock. + /// + private CmabDecision GetDecisionInternal(ProjectConfig projectConfig, + OptimizelyUserContext userContext, + string ruleId, + OptimizelyDecideOption[] options = null) { var optionSet = options ?? new OptimizelyDecideOption[0]; var filteredAttributes = FilterAttributes(projectConfig, userContext, ruleId); diff --git a/OptimizelySDK/Entity/Cmab.cs b/OptimizelySDK/Entity/Cmab.cs index f8caec87..ebe846bc 100644 --- a/OptimizelySDK/Entity/Cmab.cs +++ b/OptimizelySDK/Entity/Cmab.cs @@ -36,14 +36,14 @@ public class Cmab /// Determines what portion of traffic should be allocated to CMAB decision making. /// [JsonProperty("trafficAllocation")] - public int? TrafficAllocation { get; set; } + public int TrafficAllocation { get; set; } /// /// Initializes a new instance of the Cmab class with specified values. /// /// List of attribute IDs for CMAB /// Traffic allocation value - public Cmab(List attributeIds, int? trafficAllocation = null) + public Cmab(List attributeIds, int trafficAllocation) { AttributeIds = attributeIds ?? new List(); TrafficAllocation = trafficAllocation; diff --git a/OptimizelySDK/Entity/FeatureDecision.cs b/OptimizelySDK/Entity/FeatureDecision.cs index 6bdd8f4c..a536def6 100644 --- a/OptimizelySDK/Entity/FeatureDecision.cs +++ b/OptimizelySDK/Entity/FeatureDecision.cs @@ -24,12 +24,16 @@ public class FeatureDecision public ExperimentCore Experiment { get; } public Variation Variation { get; } public string Source { get; } + public string CmabUuid { get; } + public bool Error { get; } - public FeatureDecision(ExperimentCore experiment, Variation variation, string source) + public FeatureDecision(ExperimentCore experiment, Variation variation, string source, string cmabUuid = null, bool error = false) { Experiment = experiment; Variation = variation; Source = source; + CmabUuid = cmabUuid; + Error = error; } } } diff --git a/OptimizelySDK/Event/Entity/DecisionMetadata.cs b/OptimizelySDK/Event/Entity/DecisionMetadata.cs index 88b0a27c..0fc8f7d2 100644 --- a/OptimizelySDK/Event/Entity/DecisionMetadata.cs +++ b/OptimizelySDK/Event/Entity/DecisionMetadata.cs @@ -39,8 +39,11 @@ public class DecisionMetadata [JsonProperty("enabled")] public bool Enabled { get; private set; } + [JsonProperty("cmab_uuid", NullValueHandling = NullValueHandling.Ignore)] + public string CmabUuid { get; private set; } + public DecisionMetadata(string flagKey, string ruleKey, string ruleType, - string variationKey = "", bool enabled = false + string variationKey = "", bool enabled = false, string cmabUuid = null ) { FlagKey = flagKey; @@ -48,6 +51,7 @@ public DecisionMetadata(string flagKey, string ruleKey, string ruleType, RuleType = ruleType; VariationKey = variationKey; Enabled = enabled; + CmabUuid = cmabUuid; } } } diff --git a/OptimizelySDK/Event/UserEventFactory.cs b/OptimizelySDK/Event/UserEventFactory.cs index adb9c87b..c8063b88 100644 --- a/OptimizelySDK/Event/UserEventFactory.cs +++ b/OptimizelySDK/Event/UserEventFactory.cs @@ -59,6 +59,7 @@ public static ImpressionEvent CreateImpressionEvent(ProjectConfig projectConfig, /// The user's attributes /// experiment key or feature key /// experiment or featureDecision source + /// Optional CMAB UUID for contextual multi-armed bandit experiments /// ImpressionEvent instance public static ImpressionEvent CreateImpressionEvent(ProjectConfig projectConfig, ExperimentCore activatedExperiment, @@ -67,7 +68,8 @@ public static ImpressionEvent CreateImpressionEvent(ProjectConfig projectConfig, UserAttributes userAttributes, string flagKey, string ruleType, - bool enabled = false + bool enabled = false, + string cmabUuid = null ) { if ((ruleType == FeatureDecision.DECISION_SOURCE_ROLLOUT || variation == null) && @@ -91,7 +93,7 @@ public static ImpressionEvent CreateImpressionEvent(ProjectConfig projectConfig, ruleKey = activatedExperiment?.Key ?? string.Empty; } - var metadata = new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey, enabled); + var metadata = new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey, enabled, cmabUuid); return new ImpressionEvent.Builder().WithEventContext(eventContext). WithBotFilteringEnabled(projectConfig.BotFiltering). diff --git a/OptimizelySDK/Odp/LruCache.cs b/OptimizelySDK/Odp/LruCache.cs index 45b9be5d..e3f85754 100644 --- a/OptimizelySDK/Odp/LruCache.cs +++ b/OptimizelySDK/Odp/LruCache.cs @@ -22,7 +22,7 @@ namespace OptimizelySDK.Odp { - public class LruCache : ICache where T : class + public class LruCache : ICacheWithRemove where T : class { /// /// The maximum number of elements that should be stored diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index 4e0a0bce..727ee546 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -16,6 +16,7 @@ #if !(NET35 || NET40 || NETSTANDARD1_6) #define USE_ODP +#define USE_CMAB #endif using OptimizelySDK.Bucketing; @@ -40,6 +41,10 @@ using OptimizelySDK.Odp; #endif +#if USE_CMAB +using OptimizelySDK.Cmab; +#endif + namespace OptimizelySDK { #if NET35 @@ -193,6 +198,7 @@ public Optimizely(string datafile, /// EventProcessor /// Default Decide options /// Optional ODP Manager + /// Optional CMAB Configuration public Optimizely(ProjectConfigManager configManager, NotificationCenter notificationCenter = null, IEventDispatcher eventDispatcher = null, @@ -203,14 +209,23 @@ public Optimizely(ProjectConfigManager configManager, OptimizelyDecideOption[] defaultDecideOptions = null #if USE_ODP , IOdpManager odpManager = null +#endif +#if USE_CMAB + , CmabConfig cmabConfig = null #endif ) { ProjectConfigManager = configManager; -#if USE_ODP +#if USE_ODP && USE_CMAB + InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService, + notificationCenter, eventProcessor, defaultDecideOptions, odpManager, null, cmabConfig); +#elif USE_ODP InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService, notificationCenter, eventProcessor, defaultDecideOptions, odpManager); +#elif USE_CMAB + InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService, + notificationCenter, eventProcessor, defaultDecideOptions, null, cmabConfig); var projectConfig = ProjectConfigManager.CachedProjectConfig; @@ -237,7 +252,11 @@ public Optimizely(ProjectConfigManager configManager, #else InitializeComponents(eventDispatcher, logger, errorHandler, userProfileService, - notificationCenter, eventProcessor, defaultDecideOptions); + notificationCenter, eventProcessor, defaultDecideOptions +#if USE_CMAB + , null, cmabConfig +#endif + ); #endif } @@ -251,6 +270,10 @@ private void InitializeComponents(IEventDispatcher eventDispatcher = null, OptimizelyDecideOption[] defaultDecideOptions = null #if USE_ODP , IOdpManager odpManager = null +#endif +#if USE_CMAB + , ICmabService cmabService = null + , CmabConfig cmabConfig = null #endif ) { @@ -261,8 +284,35 @@ private void InitializeComponents(IEventDispatcher eventDispatcher = null, EventBuilder = new EventBuilder(Bucketer, Logger); UserProfileService = userProfileService; NotificationCenter = notificationCenter ?? new NotificationCenter(Logger); + +#if USE_CMAB + var config = cmabConfig ?? new CmabConfig(); + ICacheWithRemove cache; + + if (config.Cache != null) + { + cache = config.Cache; + } + else + { + var cacheSize = config.CacheSize ?? CmabConstants.DEFAULT_CACHE_SIZE; + var cacheTtl = config.CacheTtl ?? CmabConstants.DEFAULT_CACHE_TTL; + cache = new LruCache(cacheSize, cacheTtl, Logger); + } + + var cmabRetryConfig = new CmabRetryConfig(CmabConstants.CMAB_MAX_RETRIES, + CmabConstants.CMAB_INITIAL_BACKOFF); + var cmabClient = new DefaultCmabClient(null, cmabRetryConfig, Logger); + + cmabService = new DefaultCmabService(cache, cmabClient, Logger); + + DecisionService = + new DecisionService(Bucketer, ErrorHandler, userProfileService, Logger, + cmabService); +#else DecisionService = new DecisionService(Bucketer, ErrorHandler, userProfileService, Logger); +#endif EventProcessor = eventProcessor ?? new ForwardingEventProcessor(EventDispatcher, NotificationCenter, Logger); @@ -442,8 +492,9 @@ private Variation GetVariation(string experimentKey, string userId, ProjectConfi userAttributes = userAttributes ?? new UserAttributes(); var userContext = CreateUserContextCopy(userId, userAttributes); - var variation = DecisionService.GetVariation(experiment, userContext, config) + var variationResult = DecisionService.GetVariation(experiment, userContext, config) ?.ResultObject; + var variation = variationResult?.Variation; var decisionInfo = new Dictionary { { "experimentKey", experimentKey }, { "variationKey", variation?.Key }, @@ -598,7 +649,11 @@ public virtual bool IsFeatureEnabled(string featureKey, string userId, }; SendImpressionEvent(decision?.Experiment, variation, userId, userAttributes, config, - featureKey, decisionSource, featureEnabled); + featureKey, decisionSource, featureEnabled +#if USE_CMAB + , decision?.CmabUuid +#endif + ); NotificationCenter.SendNotifications(NotificationCenter.NotificationType.Decision, DecisionNotificationTypes.FEATURE, userId, @@ -986,6 +1041,27 @@ internal Dictionary DecideForKeys(OptimizelyUserCont var flagDecision = flagDecisions[key]; var decisionReasons = decisionReasonsMap[key]; + if (flagDecision?.Error == true) + { + var includeReasons = allOptions.Contains(OptimizelyDecideOption.INCLUDE_REASONS); + var reasonsToReport = decisionReasons.ToReport(includeReasons).ToArray(); + + var errorDecision = OptimizelyDecision.NewErrorDecision( + key, + user, + reasonsToReport, + ErrorHandler, + Logger + ); + + if (!allOptions.Contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || + errorDecision.Enabled) + { + decisionDictionary.Add(key, errorDecision); + } + continue; + } + var optimizelyDecision = CreateOptimizelyDecision(user, key, flagDecision, decisionReasons, allOptions.ToList(), projectConfig); if (!allOptions.Contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || @@ -1063,7 +1139,11 @@ ProjectConfig projectConfig projectConfig, flagKey, decisionSource, - flagEnabled); + flagEnabled +#if USE_CMAB + , flagDecision.CmabUuid +#endif + ); } var decisionInfo = new Dictionary @@ -1089,7 +1169,8 @@ ProjectConfig projectConfig ruleKey, flagKey, user, - reasonsToReport); + reasonsToReport + ); } private Result> GetDecisionVariableMap(FeatureFlag flag, Variation variation, bool featureEnabled) @@ -1162,9 +1243,13 @@ private void SendImpressionEvent(Experiment experiment, Variation variation, str /// The user's attributes /// It can either be experiment key in case if ruleType is experiment or it's feature key in case ruleType is feature-test or rollout /// It can either be experiment in case impression event is sent from activate or it's feature-test or rollout + /// Optional CMAB UUID for contextual multi-armed bandit experiments private bool SendImpressionEvent(ExperimentCore experiment, Variation variation, string userId, UserAttributes userAttributes, ProjectConfig config, string flagKey, string ruleType, bool enabled +#if USE_CMAB + , string cmabUuid = null +#endif ) { if (experiment != null && !experiment.isRunning) @@ -1174,7 +1259,11 @@ private bool SendImpressionEvent(ExperimentCore experiment, Variation variation, } var userEvent = UserEventFactory.CreateImpressionEvent(config, experiment, variation, - userId, userAttributes, flagKey, ruleType, enabled); + userId, userAttributes, flagKey, ruleType, enabled +#if USE_CMAB + , cmabUuid +#endif + ); if (userEvent == null) { return false; diff --git a/OptimizelySDK/OptimizelyDecisions/OptimizelyDecision.cs b/OptimizelySDK/OptimizelyDecisions/OptimizelyDecision.cs index 7fe6c0c8..5e26432e 100644 --- a/OptimizelySDK/OptimizelyDecisions/OptimizelyDecision.cs +++ b/OptimizelySDK/OptimizelyDecisions/OptimizelyDecision.cs @@ -100,5 +100,28 @@ ILogger logger optimizelyUserContext, new string[] { error }); } + + /// + /// Static function to return OptimizelyDecision with multiple error reasons. + /// Similar to the single error overload but accepts an array of reasons. + /// OptimizelyDecision will have null variation key, false enabled, empty variables, null rule key + /// and the provided reasons array. + /// + public static OptimizelyDecision NewErrorDecision(string key, + OptimizelyUserContext optimizelyUserContext, + string[] reasons, + IErrorHandler errorHandler, + ILogger logger + ) + { + return new OptimizelyDecision( + null, + false, + new OptimizelyJSON(new Dictionary(), errorHandler, logger), + null, + key, + optimizelyUserContext, + reasons); + } } } diff --git a/OptimizelySDK/OptimizelyFactory.cs b/OptimizelySDK/OptimizelyFactory.cs index d42d85fd..7e2b682e 100644 --- a/OptimizelySDK/OptimizelyFactory.cs +++ b/OptimizelySDK/OptimizelyFactory.cs @@ -18,6 +18,10 @@ #define USE_ODP #endif +#if !(NET35 || NET40 || NETSTANDARD1_6) +#define USE_CMAB +#endif + using System; #if !NETSTANDARD1_6 && !NET35 using System.Configuration; @@ -35,6 +39,11 @@ using OptimizelySDK.Odp; #endif +#if USE_CMAB +using OptimizelySDK.Cmab; +using OptimizelySDK.Utils; +#endif + namespace OptimizelySDK { @@ -49,6 +58,9 @@ public static class OptimizelyFactory private static TimeSpan BlockingTimeOutPeriod; private static ILogger OptimizelyLogger; private const string ConfigSectionName = "optlySDKConfigSection"; +#if USE_CMAB + private static CmabConfig CmabConfiguration; +#endif #if !NETSTANDARD1_6 && !NET35 public static void SetBatchSize(int batchSize) @@ -76,6 +88,17 @@ public static void SetLogger(ILogger logger) OptimizelyLogger = logger; } +#if USE_CMAB + /// + /// Sets the CMAB (Contextual Multi-Armed Bandit) configuration. + /// + /// CMAB configuration with cache settings. + public static void SetCmabConfig(CmabConfig config) + { + CmabConfiguration = config; + } +#endif + public static Optimizely NewDefaultInstance() { var logger = OptimizelyLogger ?? new DefaultLogger(); @@ -224,13 +247,23 @@ public static Optimizely NewDefaultInstance(ProjectConfigManager configManager, UserProfileService userprofileService = null, EventProcessor eventProcessor = null ) { -#if USE_ODP +#if USE_ODP && USE_CMAB + var odpManager = new OdpManager.Builder() + .WithErrorHandler(errorHandler) + .WithLogger(logger) + .Build(); + return new Optimizely(configManager, notificationCenter, eventDispatcher, logger, + errorHandler, userprofileService, eventProcessor, null, odpManager, CmabConfiguration); +#elif USE_ODP var odpManager = new OdpManager.Builder() .WithErrorHandler(errorHandler) .WithLogger(logger) .Build(); return new Optimizely(configManager, notificationCenter, eventDispatcher, logger, errorHandler, userprofileService, eventProcessor, null, odpManager); +#elif USE_CMAB + return new Optimizely(configManager, notificationCenter, eventDispatcher, logger, + errorHandler, userprofileService, eventProcessor, null, CmabConfiguration); #else return new Optimizely(configManager, notificationCenter, eventDispatcher, logger, errorHandler, userprofileService, eventProcessor); diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj index 7091cf01..df6c0c1c 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -76,6 +76,7 @@ + @@ -182,6 +183,7 @@ + @@ -209,6 +211,7 @@ + diff --git a/OptimizelySDK/Utils/ICacheWithRemove.cs b/OptimizelySDK/Utils/ICacheWithRemove.cs new file mode 100644 index 00000000..969286c9 --- /dev/null +++ b/OptimizelySDK/Utils/ICacheWithRemove.cs @@ -0,0 +1,36 @@ +/* + * 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 + * + * https://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 OptimizelySDK.Odp; + +namespace OptimizelySDK.Utils +{ + /// + /// Extended cache interface that adds the ability to remove individual entries. + /// This interface extends ICache with additional removal functionality needed for + /// certain use cases like CMAB decision caching. + /// + /// The type of values stored in the cache + public interface ICacheWithRemove : ICache + where T : class + { + /// + /// Remove the element associated with the provided key from the cache + /// + /// Key of element to remove from the cache + void Remove(string key); + } +}