diff --git a/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs b/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs index 5d8be677..61c3829e 100644 --- a/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs +++ b/OptimizelySDK.Tests/DecisionServiceHoldoutTest.cs @@ -29,6 +29,7 @@ using OptimizelySDK.Event.Entity; using OptimizelySDK.Logger; using OptimizelySDK.OptimizelyDecisions; +using OptimizelySDK.Utils; namespace OptimizelySDK.Tests { @@ -331,5 +332,316 @@ public void TestImpressionEventForHoldout_DisableDecisionEvent() EventProcessorMock.Verify(ep => ep.Process(It.IsAny()), Times.Never, "No impression event should be processed when DISABLE_DECISION_EVENT option is used"); } + + // ===================================================================== + // Level 2: Decision Service Tests for Local Holdouts (FSSDK-12369) + // ===================================================================== + + private DatafileProjectConfig LocalHoldoutsConfig; + private Optimizely LocalHoldoutsOptimizely; + + private void InitializeLocalHoldoutsConfig() + { + var datafileWithLocalHoldouts = TestData["datafileWithLocalHoldouts"].ToString(); + LocalHoldoutsConfig = DatafileProjectConfig.Create( + datafileWithLocalHoldouts, + LoggerMock.Object, + new NoOpErrorHandler()) as DatafileProjectConfig; + + var eventDispatcher = new Event.Dispatcher.DefaultEventDispatcher(LoggerMock.Object); + LocalHoldoutsOptimizely = new Optimizely( + datafileWithLocalHoldouts, + eventDispatcher, + LoggerMock.Object, + new NoOpErrorHandler()); + } + + [Test] + public void TestLocalHoldouts_GlobalHoldoutEvaluatedBeforePerRuleLogic() + { + // Global holdout is evaluated at flag level, before any per-rule logic. + // datafileWithLocalHoldouts has a global holdout (holdout_global_2) with 100% traffic allocation. + // test_flag_1 has a rollout with delivery rules. The global holdout should fire first. + InitializeLocalHoldoutsConfig(); + + Assert.IsNotNull(LocalHoldoutsConfig, "Config with local holdouts should be created"); + + var globalHoldouts = LocalHoldoutsConfig.GetGlobalHoldouts(); + Assert.AreEqual(1, globalHoldouts.Count, "Should have exactly one global holdout"); + Assert.AreEqual("holdout_global_2", globalHoldouts[0].Id, "Global holdout id should match"); + + // Verify that the global holdout is classified correctly + Assert.IsTrue(globalHoldouts[0].IsGlobal, + "holdout_global_2 should be global (IncludedRules is null)"); + + // Verify GetHoldoutsForRule returns empty for a delivery rule since it's global + var localForRule1 = LocalHoldoutsConfig.GetHoldoutsForRule("rule_id_1"); + Assert.IsTrue(localForRule1.Any(h => h.Id == "holdout_local_rule1"), + "rule_id_1 should be targeted by holdout_local_rule1"); + Assert.IsFalse(localForRule1.Any(h => h.Id == "holdout_global_2"), + "Global holdout should not appear in per-rule local holdouts list"); + + // Use the decision service to get a decision for test_flag_1 (has rollout) + var realBucketer = new Bucketer(LoggerMock.Object); + var decisionService = new DecisionService(realBucketer, + new NoOpErrorHandler(), null, LoggerMock.Object, null); + + var featureFlag = LocalHoldoutsConfig.FeatureKeyMap["test_flag_1"]; + var userContext = new OptimizelyUserContext(LocalHoldoutsOptimizely, TestUserId, null, + new NoOpErrorHandler(), LoggerMock.Object); + + var result = decisionService.GetVariationsForFeatureList( + new List { featureFlag }, + userContext, + LocalHoldoutsConfig, + new UserAttributes(), + new OptimizelyDecideOption[0]); + + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Count); + + // The global holdout has 100% traffic — user should be bucketed into it + var decision = result[0].ResultObject; + Assert.IsNotNull(decision, "Decision should not be null"); + Assert.AreEqual(FeatureDecision.DECISION_SOURCE_HOLDOUT, decision.Source, + "Decision source should be holdout since global holdout has 100% traffic"); + Assert.AreEqual("holdout_global_2", decision.Experiment?.Id, + "Decision should be from the global holdout"); + } + + [Test] + public void TestLocalHoldouts_UserBucketedIntoLocalHoldoutForDeliveryRuleReturnsHoldoutVariation() + { + // When a user hits a local holdout targeting delivery rule X, the holdout variation + // is returned and the rule's own audience/traffic checks are not evaluated. + // holdout_local_rule1 targets rule_id_1 with 100% traffic. + // To test this without global holdout interference, we use a config where + // global holdout has 0% traffic — we manipulate the global holdout in-place. + InitializeLocalHoldoutsConfig(); + + // Remove global holdout traffic so it doesn't intercept + var globalHoldout = LocalHoldoutsConfig.GetGlobalHoldouts()[0]; + globalHoldout.TrafficAllocation = new TrafficAllocation[0]; // 0% traffic + + var realBucketer = new Bucketer(LoggerMock.Object); + var decisionService = new DecisionService(realBucketer, + new NoOpErrorHandler(), null, LoggerMock.Object, null); + + var featureFlag = LocalHoldoutsConfig.FeatureKeyMap["test_flag_1"]; // has rollout_1 with rule_id_1 + var userContext = new OptimizelyUserContext(LocalHoldoutsOptimizely, TestUserId, null, + new NoOpErrorHandler(), LoggerMock.Object); + + var result = decisionService.GetVariationsForFeatureList( + new List { featureFlag }, + userContext, + LocalHoldoutsConfig, + new UserAttributes(), + new OptimizelyDecideOption[0]); + + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Count); + + var decision = result[0].ResultObject; + Assert.IsNotNull(decision, "Decision should not be null"); + Assert.AreEqual(FeatureDecision.DECISION_SOURCE_HOLDOUT, decision.Source, + "Decision source should be holdout for local holdout hit"); + Assert.AreEqual("holdout_local_rule1", decision.Experiment?.Key, + "Decision should be from local holdout targeting rule_id_1"); + Assert.AreEqual("local_holdout_off", decision.Variation?.Key, + "Variation should be the holdout's variation"); + } + + [Test] + public void TestLocalHoldouts_UserNotBucketedIntoLocalHoldoutFallsThroughToRegularRuleEvaluation() + { + // When a user is NOT bucketed into a local holdout, the regular rule evaluation proceeds. + // We set the local holdout for rule_id_1 to 0% traffic so user falls through. + InitializeLocalHoldoutsConfig(); + + // Set global holdout to 0% traffic (bypass) + var globalHoldout = LocalHoldoutsConfig.GetGlobalHoldouts()[0]; + globalHoldout.TrafficAllocation = new TrafficAllocation[0]; + + // Set local holdout for rule_id_1 to 0% traffic (bypass) + var localHoldoutsForRule1 = LocalHoldoutsConfig.GetHoldoutsForRule("rule_id_1"); + Assert.AreEqual(1, localHoldoutsForRule1.Count, "Should have one local holdout for rule_id_1"); + localHoldoutsForRule1[0].TrafficAllocation = new TrafficAllocation[0]; // 0% traffic + + var realBucketer = new Bucketer(LoggerMock.Object); + var decisionService = new DecisionService(realBucketer, + new NoOpErrorHandler(), null, LoggerMock.Object, null); + + var featureFlag = LocalHoldoutsConfig.FeatureKeyMap["test_flag_1"]; // has rollout with rule_id_1 + var userContext = new OptimizelyUserContext(LocalHoldoutsOptimizely, TestUserId, null, + new NoOpErrorHandler(), LoggerMock.Object); + + var result = decisionService.GetVariationsForFeatureList( + new List { featureFlag }, + userContext, + LocalHoldoutsConfig, + new UserAttributes(), + new OptimizelyDecideOption[0]); + + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Count); + + var decision = result[0].ResultObject; + Assert.IsNotNull(decision, "Decision should not be null when falling through to rollout rules"); + // User falls through local holdout and hits the regular rule with 100% traffic + Assert.AreNotEqual(FeatureDecision.DECISION_SOURCE_HOLDOUT, decision.Source, + "Decision source should NOT be holdout when local holdout has 0% traffic"); + Assert.AreEqual(FeatureDecision.DECISION_SOURCE_ROLLOUT, decision.Source, + "Decision source should be rollout after falling through local holdout"); + } + + [Test] + public void TestLocalHoldouts_RuleSpecificity_LocalHoldoutTargetingRuleXDoesNotAffectRuleY() + { + // A local holdout targeting rule_id_1 should only apply to rule_id_1, + // not to rule_id_2 in the same rollout. + InitializeLocalHoldoutsConfig(); + + // Verify that holdout_local_rule1 targets only rule_id_1 + var holdoutsForRule1 = LocalHoldoutsConfig.GetHoldoutsForRule("rule_id_1"); + var holdoutsForRule2 = LocalHoldoutsConfig.GetHoldoutsForRule("rule_id_2"); + + Assert.AreEqual(1, holdoutsForRule1.Count, "rule_id_1 should have exactly one local holdout"); + Assert.AreEqual("holdout_local_rule1", holdoutsForRule1[0].Key, + "Local holdout for rule_id_1 should be holdout_local_rule1"); + + Assert.AreEqual(1, holdoutsForRule2.Count, "rule_id_2 should have exactly one local holdout"); + Assert.AreEqual("holdout_local_rule2", holdoutsForRule2[0].Key, + "Local holdout for rule_id_2 should be holdout_local_rule2"); + + // Verify the holdouts for rule_id_1 and rule_id_2 are distinct + Assert.AreNotEqual(holdoutsForRule1[0].Id, holdoutsForRule2[0].Id, + "Holdouts for rule_id_1 and rule_id_2 should be different holdouts"); + + // Verify holdout_local_rule1 does NOT appear in rule_id_2 list + Assert.IsFalse(holdoutsForRule2.Any(h => h.Key == "holdout_local_rule1"), + "holdout_local_rule1 should NOT target rule_id_2"); + + // Verify holdout_local_rule2 does NOT appear in rule_id_1 list + Assert.IsFalse(holdoutsForRule1.Any(h => h.Key == "holdout_local_rule2"), + "holdout_local_rule2 should NOT target rule_id_1"); + } + + [Test] + public void TestLocalHoldouts_AppliesToDeliveryRules() + { + // Verify local holdout check applies to delivery rules (rollout rules). + // holdout_local_rule1 targets rule_id_1 which is a delivery rule in rollout_1. + InitializeLocalHoldoutsConfig(); + + var holdoutsForDeliveryRule = LocalHoldoutsConfig.GetHoldoutsForRule("rule_id_1"); + Assert.AreEqual(1, holdoutsForDeliveryRule.Count, + "Delivery rule rule_id_1 should have one local holdout"); + Assert.AreEqual("holdout_local_rule1", holdoutsForDeliveryRule[0].Key, + "The local holdout for delivery rule rule_id_1 should be holdout_local_rule1"); + + // Verify the delivery rule exists in the rollout + var rollout = LocalHoldoutsConfig.GetRolloutFromId("rollout_1"); + Assert.IsNotNull(rollout, "rollout_1 should exist in config"); + var deliveryRule = rollout.Experiments?.FirstOrDefault(r => r.Id == "rule_id_1"); + Assert.IsNotNull(deliveryRule, + "rule_id_1 should be a delivery rule within rollout_1"); + } + + /// + /// Mandatory enforcement test (cross-SDK): forced decision takes precedence over a 100% traffic local holdout. + /// Ordering: Forced Decision → Local Holdout → Regular Rule. If forced decision is set, + /// it must win even when a 100% local holdout targets the same rule. + /// + [Test] + public void TestForcedDecisionBeats100PercentLocalHoldout() + { + // Setup: local holdout 'holdout_local_exp_rule1' targets exp_rule_id_1 (experiment_rule_1) with 100% traffic. + // User also has a forced decision set for test_flag_2 / experiment_rule_1. + // Expected: forced decision wins; source is FEATURE_TEST, not HOLDOUT. + InitializeLocalHoldoutsConfig(); + + // Remove global holdout traffic so it doesn't interfere + var globalHoldout = LocalHoldoutsConfig.GetGlobalHoldouts()[0]; + globalHoldout.TrafficAllocation = new TrafficAllocation[0]; + + var realBucketer = new Bucketer(LoggerMock.Object); + var decisionService = new DecisionService(realBucketer, + new NoOpErrorHandler(), null, LoggerMock.Object, null); + + var featureFlag = LocalHoldoutsConfig.FeatureKeyMap["test_flag_2"]; + var userContext = new OptimizelyUserContext(LocalHoldoutsOptimizely, TestUserId, null, + new NoOpErrorHandler(), LoggerMock.Object); + + // Set forced decision for experiment_rule_1 → variation_a + userContext.SetForcedDecision( + new OptimizelyDecisionContext("test_flag_2", "experiment_rule_1"), + new OptimizelyForcedDecision("variation_a") + ); + + var result = decisionService.GetVariationsForFeatureList( + new List { featureFlag }, + userContext, + LocalHoldoutsConfig, + new UserAttributes(), + new OptimizelyDecideOption[0]); + + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Count); + + var decision = result[0].ResultObject; + Assert.IsNotNull(decision, "Forced decision should produce a result"); + Assert.AreNotEqual(FeatureDecision.DECISION_SOURCE_HOLDOUT, decision.Source, + "Forced decision must NOT return holdout source — forced decision takes priority over local holdout"); + Assert.AreEqual("variation_a", decision.Variation?.Key, + "Forced decision variation should be returned, not holdout variation"); + } + + [Test] + public void TestLocalHoldouts_AppliesToExperimentRules() + { + // Verify local holdout check applies to experiment rules (A/B test experiments). + // holdout_local_exp_rule1 targets exp_rule_id_1 which is an experiment for test_flag_2. + InitializeLocalHoldoutsConfig(); + + var holdoutsForExpRule = LocalHoldoutsConfig.GetHoldoutsForRule("exp_rule_id_1"); + Assert.AreEqual(1, holdoutsForExpRule.Count, + "Experiment rule exp_rule_id_1 should have one local holdout"); + Assert.AreEqual("holdout_local_exp_rule1", holdoutsForExpRule[0].Key, + "The local holdout for experiment rule exp_rule_id_1 should be holdout_local_exp_rule1"); + + // Verify the experiment exists as an experiment rule for test_flag_2 + var featureFlag = LocalHoldoutsConfig.FeatureKeyMap["test_flag_2"]; + Assert.IsNotNull(featureFlag, "test_flag_2 should exist in config"); + Assert.IsTrue(featureFlag.ExperimentIds.Contains("exp_rule_id_1"), + "exp_rule_id_1 should be an experiment rule for test_flag_2"); + + // Set global holdout to 0% traffic so it doesn't intercept + var globalHoldout = LocalHoldoutsConfig.GetGlobalHoldouts()[0]; + globalHoldout.TrafficAllocation = new TrafficAllocation[0]; + + var realBucketer = new Bucketer(LoggerMock.Object); + var decisionService = new DecisionService(realBucketer, + new NoOpErrorHandler(), null, LoggerMock.Object, null); + + var userContext = new OptimizelyUserContext(LocalHoldoutsOptimizely, TestUserId, null, + new NoOpErrorHandler(), LoggerMock.Object); + + var result = decisionService.GetVariationsForFeatureList( + new List { featureFlag }, + userContext, + LocalHoldoutsConfig, + new UserAttributes(), + new OptimizelyDecideOption[0]); + + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Count); + + var decision = result[0].ResultObject; + Assert.IsNotNull(decision, "Decision should not be null"); + Assert.AreEqual(FeatureDecision.DECISION_SOURCE_HOLDOUT, decision.Source, + "Decision source should be holdout since local holdout targets the experiment rule"); + Assert.AreEqual("holdout_local_exp_rule1", decision.Experiment?.Key, + "Decision experiment should be the local holdout targeting exp_rule_id_1"); + } } } diff --git a/OptimizelySDK.Tests/TestData/HoldoutTestData.json b/OptimizelySDK.Tests/TestData/HoldoutTestData.json index 777c0a3a..eb65ccc6 100644 --- a/OptimizelySDK.Tests/TestData/HoldoutTestData.json +++ b/OptimizelySDK.Tests/TestData/HoldoutTestData.json @@ -210,5 +210,280 @@ "excludedFlags": [] } ] + }, + "datafileWithLocalHoldouts": { + "version": "4", + "rollouts": [ + { + "id": "rollout_1", + "experiments": [ + { + "id": "rule_id_1", + "key": "delivery_rule_1", + "status": "Running", + "layerId": "rollout_layer_1", + "variations": [ + { + "id": "rule1_var_1", + "key": "enabled", + "featureEnabled": true, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "rule1_var_1", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "forcedVariations": {} + }, + { + "id": "rule_id_2", + "key": "delivery_rule_2", + "status": "Running", + "layerId": "rollout_layer_1", + "variations": [ + { + "id": "rule2_var_1", + "key": "enabled", + "featureEnabled": true, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "rule2_var_1", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "forcedVariations": {} + }, + { + "id": "rule_id_everyone_else", + "key": "everyone_else_rule", + "status": "Running", + "layerId": "rollout_layer_1", + "variations": [ + { + "id": "everyone_var_1", + "key": "enabled", + "featureEnabled": true, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "everyone_var_1", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "forcedVariations": {} + } + ] + } + ], + "projectId": "test_project", + "experiments": [ + { + "id": "exp_rule_id_1", + "key": "experiment_rule_1", + "status": "Running", + "layerId": "exp_layer_1", + "variations": [ + { + "id": "exp1_var_1", + "key": "variation_a", + "featureEnabled": true, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "exp1_var_1", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "forcedVariations": {} + }, + { + "id": "exp_rule_id_2", + "key": "experiment_rule_2", + "status": "Running", + "layerId": "exp_layer_2", + "variations": [ + { + "id": "exp2_var_1", + "key": "variation_b", + "featureEnabled": true, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "exp2_var_1", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "forcedVariations": {} + } + ], + "groups": [], + "attributes": [], + "audiences": [], + "layers": [], + "events": [], + "revision": "2", + "accountId": "12345", + "anonymizeIP": false, + "featureFlags": [ + { + "id": "flag_1", + "key": "test_flag_1", + "experimentIds": [], + "rolloutId": "rollout_1", + "variables": [] + }, + { + "id": "flag_2", + "key": "test_flag_2", + "experimentIds": ["exp_rule_id_1", "exp_rule_id_2"], + "rolloutId": "", + "variables": [] + }, + { + "id": "flag_3", + "key": "test_flag_3", + "experimentIds": [], + "rolloutId": "", + "variables": [] + } + ], + "holdouts": [ + { + "id": "holdout_global_2", + "key": "global_holdout_2", + "status": "Running", + "layerId": "layer_g2", + "variations": [ + { + "id": "gvar_1", + "key": "holdout_off", + "featureEnabled": false, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "gvar_1", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [] + }, + { + "id": "holdout_local_rule1", + "key": "local_holdout_rule1", + "status": "Running", + "layerId": "layer_local_1", + "variations": [ + { + "id": "lvar_1", + "key": "local_holdout_off", + "featureEnabled": false, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "lvar_1", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "includedRules": ["rule_id_1"] + }, + { + "id": "holdout_local_rule2", + "key": "local_holdout_rule2", + "status": "Running", + "layerId": "layer_local_2", + "variations": [ + { + "id": "lvar_2", + "key": "local_holdout_off_2", + "featureEnabled": false, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "lvar_2", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "includedRules": ["rule_id_2"] + }, + { + "id": "holdout_local_empty_rules", + "key": "local_holdout_empty_rules", + "status": "Running", + "layerId": "layer_local_empty", + "variations": [ + { + "id": "lvar_empty", + "key": "local_empty_off", + "featureEnabled": false, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "lvar_empty", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "includedRules": [] + }, + { + "id": "holdout_local_exp_rule1", + "key": "local_holdout_exp_rule1", + "status": "Running", + "layerId": "layer_local_exp1", + "variations": [ + { + "id": "exp_lvar_1", + "key": "local_exp_holdout_off", + "featureEnabled": false, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "exp_lvar_1", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "includedRules": ["exp_rule_id_1"] + } + ] } } diff --git a/OptimizelySDK.Tests/UtilsTests/HoldoutConfigBasicTests.cs b/OptimizelySDK.Tests/UtilsTests/HoldoutConfigBasicTests.cs index 443b872c..47857b44 100644 --- a/OptimizelySDK.Tests/UtilsTests/HoldoutConfigBasicTests.cs +++ b/OptimizelySDK.Tests/UtilsTests/HoldoutConfigBasicTests.cs @@ -125,6 +125,168 @@ public void TestNullHoldouts() Assert.AreEqual(0, config.HoldoutCount); } + // ===================================================================== + // Level 1: Local Holdout / IsGlobal Classification Tests (FSSDK-12369) + // ===================================================================== + + [Test] + public void TestIsGlobal_NullIncludedRules_IsGlobal() + { + // A holdout with IncludedRules == null is a global holdout + var holdout = CreateTestHoldout("h1", "global_holdout"); + holdout.IncludedRules = null; + + Assert.IsTrue(holdout.IsGlobal, "Holdout with null IncludedRules should be global"); + } + + [Test] + public void TestIsGlobal_EmptyIncludedRules_IsNotGlobal() + { + // A holdout with IncludedRules == [] is LOCAL (empty array, not null) + var holdout = CreateTestHoldout("h1", "local_holdout_empty"); + holdout.IncludedRules = new string[0]; + + Assert.IsFalse(holdout.IsGlobal, "Holdout with empty array IncludedRules should NOT be global"); + } + + [Test] + public void TestIsGlobal_NonEmptyIncludedRules_IsNotGlobal() + { + // A holdout with IncludedRules = ["rule_1"] is a local holdout + var holdout = CreateTestHoldout("h1", "local_holdout"); + holdout.IncludedRules = new[] { "rule_1" }; + + Assert.IsFalse(holdout.IsGlobal, "Holdout with non-empty IncludedRules should NOT be global"); + } + + [Test] + public void TestGetGlobalHoldouts_ReturnsOnlyGlobalHoldouts() + { + var globalHoldout = CreateTestHoldout("global_id", "global_key"); + globalHoldout.IncludedRules = null; // global + + var localHoldout = CreateTestHoldout("local_id", "local_key"); + localHoldout.IncludedRules = new[] { "rule_1" }; // local + + var config = new HoldoutConfig(new[] { globalHoldout, localHoldout }); + + var globals = config.GetGlobalHoldouts(); + Assert.AreEqual(1, globals.Count, "Should return exactly one global holdout"); + Assert.AreEqual("global_id", globals[0].Id); + } + + [Test] + public void TestGetGlobalHoldouts_NoGlobalHoldouts_ReturnsEmpty() + { + var localHoldout = CreateTestHoldout("local_id", "local_key"); + localHoldout.IncludedRules = new[] { "rule_1" }; // local + + var config = new HoldoutConfig(new[] { localHoldout }); + + var globals = config.GetGlobalHoldouts(); + Assert.AreEqual(0, globals.Count, "Should return empty list when no global holdouts exist"); + } + + [Test] + public void TestGetHoldoutsForRule_ReturnsMatchingLocalHoldout() + { + var localHoldout = CreateTestHoldout("local_id", "local_key"); + localHoldout.IncludedRules = new[] { "rule_1", "rule_2" }; + + var config = new HoldoutConfig(new[] { localHoldout }); + + var holdoutsForRule1 = config.GetHoldoutsForRule("rule_1"); + Assert.AreEqual(1, holdoutsForRule1.Count, "Should return one holdout for rule_1"); + Assert.AreEqual("local_id", holdoutsForRule1[0].Id); + + var holdoutsForRule2 = config.GetHoldoutsForRule("rule_2"); + Assert.AreEqual(1, holdoutsForRule2.Count, "Should return one holdout for rule_2"); + } + + [Test] + public void TestGetHoldoutsForRule_UnknownRuleId_ReturnsEmpty() + { + var localHoldout = CreateTestHoldout("local_id", "local_key"); + localHoldout.IncludedRules = new[] { "rule_1" }; + + var config = new HoldoutConfig(new[] { localHoldout }); + + var holdoutsForUnknown = config.GetHoldoutsForRule("unknown_rule_id"); + Assert.AreEqual(0, holdoutsForUnknown.Count, "Should return empty list for unknown rule ID"); + } + + [Test] + public void TestGetHoldoutsForRule_NullOrEmptyRuleId_ReturnsEmpty() + { + var localHoldout = CreateTestHoldout("local_id", "local_key"); + localHoldout.IncludedRules = new[] { "rule_1" }; + + var config = new HoldoutConfig(new[] { localHoldout }); + + Assert.AreEqual(0, config.GetHoldoutsForRule(null).Count, "Should return empty for null rule ID"); + Assert.AreEqual(0, config.GetHoldoutsForRule("").Count, "Should return empty for empty rule ID"); + } + + [Test] + public void TestGetHoldoutsForRule_EmptyIncludedRules_NoRuleMatches() + { + // A local holdout with IncludedRules == [] matches NO rules + var localHoldout = CreateTestHoldout("local_id", "local_key"); + localHoldout.IncludedRules = new string[0]; // empty array = local, but no rules + + var config = new HoldoutConfig(new[] { localHoldout }); + + // Should not appear in global holdouts + Assert.AreEqual(0, config.GetGlobalHoldouts().Count, "Empty-array holdout should not be global"); + + // Should not match any rule + Assert.AreEqual(0, config.GetHoldoutsForRule("any_rule").Count, "Empty-array holdout should match no rules"); + } + + [Test] + public void TestBackwardCompatibility_NullIncludedRulesDefaultsToGlobal() + { + // Old datafile holdouts have no includedRules field → IncludedRules is null → global + var legacyHoldout = CreateTestHoldout("legacy_id", "legacy_key"); + // IncludedRules is null by default (not set) + + var config = new HoldoutConfig(new[] { legacyHoldout }); + + var globals = config.GetGlobalHoldouts(); + Assert.AreEqual(1, globals.Count, "Legacy holdout (null IncludedRules) should be treated as global"); + Assert.AreEqual("legacy_id", globals[0].Id); + } + + [Test] + public void TestMultipleLocalHoldoutsForSameRule() + { + var holdout1 = CreateTestHoldout("local_id_1", "local_key_1"); + holdout1.IncludedRules = new[] { "rule_shared" }; + + var holdout2 = CreateTestHoldout("local_id_2", "local_key_2"); + holdout2.IncludedRules = new[] { "rule_shared" }; + + var config = new HoldoutConfig(new[] { holdout1, holdout2 }); + + var holdoutsForSharedRule = config.GetHoldoutsForRule("rule_shared"); + Assert.AreEqual(2, holdoutsForSharedRule.Count, "Both local holdouts should be returned for the shared rule"); + } + + [Test] + public void TestCrossRuleTargeting_OneHoldoutTargetsMultipleRules() + { + // A single local holdout can target rules from multiple flags + var crossFlagHoldout = CreateTestHoldout("cross_id", "cross_key"); + crossFlagHoldout.IncludedRules = new[] { "rule_flag_a", "rule_flag_b", "rule_flag_c" }; + + var config = new HoldoutConfig(new[] { crossFlagHoldout }); + + Assert.AreEqual(1, config.GetHoldoutsForRule("rule_flag_a").Count, "Should match rule_flag_a"); + Assert.AreEqual(1, config.GetHoldoutsForRule("rule_flag_b").Count, "Should match rule_flag_b"); + Assert.AreEqual(1, config.GetHoldoutsForRule("rule_flag_c").Count, "Should match rule_flag_c"); + Assert.AreEqual(0, config.GetHoldoutsForRule("rule_flag_d").Count, "Should not match unrelated rule"); + } + // Helper method to create test holdouts private Holdout CreateTestHoldout(string id, string key) { diff --git a/OptimizelySDK/Bucketing/DecisionService.cs b/OptimizelySDK/Bucketing/DecisionService.cs index dec76444..77a650a2 100644 --- a/OptimizelySDK/Bucketing/DecisionService.cs +++ b/OptimizelySDK/Bucketing/DecisionService.cs @@ -686,6 +686,21 @@ ProjectConfig config reasons); } + // Check local holdouts targeting this specific delivery rule (FSSDK-12369) + var localRuleHoldouts = config.GetHoldoutsForRule(rule.Id); + foreach (var localHoldout in localRuleHoldouts) + { + var localHoldoutDecision = GetVariationForHoldout(localHoldout, user, config); + reasons += localHoldoutDecision.DecisionReasons; + if (localHoldoutDecision.ResultObject != null) + { + Logger.Log(LogLevel.INFO, + reasons.AddInfo( + $"The user \"{userId}\" is bucketed into local holdout \"{localHoldout.Key}\" for delivery rule \"{rule.Key}\".")); + return Result.NewResult(localHoldoutDecision.ResultObject, reasons); + } + } + // Regular decision // Get Bucketing ID from user attributes. @@ -803,6 +818,22 @@ public virtual Result GetVariationForFeatureExperiment( } else { + // Check local holdouts targeting this specific experiment rule (FSSDK-12369) + var localHoldouts = config.GetHoldoutsForRule(experiment.Id); + Result localHoldoutDecision = null; + foreach (var localHoldout in localHoldouts) + { + localHoldoutDecision = GetVariationForHoldout(localHoldout, user, config); + reasons += localHoldoutDecision.DecisionReasons; + if (localHoldoutDecision.ResultObject != null) + { + Logger.Log(LogLevel.INFO, + reasons.AddInfo( + $"The user \"{userId}\" is bucketed into local holdout \"{localHoldout.Key}\" for experiment rule \"{experiment.Key}\".")); + return Result.NewResult(localHoldoutDecision.ResultObject, reasons); + } + } + var decisionResponse = GetVariation(experiment, user, config, options, userProfileTracker); @@ -894,9 +925,9 @@ public virtual Result GetDecisionForFlag( var userId = user.GetUserId(); - // Check holdouts first (highest priority) - var holdouts = projectConfig.Holdouts ?? new Holdout[0]; - foreach (var holdout in holdouts) + // Check global holdouts first (highest priority — evaluated at flag level, before any rules) + var globalHoldouts = projectConfig.GetGlobalHoldouts(); + foreach (var holdout in globalHoldouts) { var holdoutDecision = GetVariationForHoldout(holdout, user, projectConfig); reasons += holdoutDecision.DecisionReasons; diff --git a/OptimizelySDK/Config/DatafileProjectConfig.cs b/OptimizelySDK/Config/DatafileProjectConfig.cs index a215c918..dc9eff28 100644 --- a/OptimizelySDK/Config/DatafileProjectConfig.cs +++ b/OptimizelySDK/Config/DatafileProjectConfig.cs @@ -909,6 +909,27 @@ public Holdout GetHoldout(string holdoutId) return _holdoutConfig.GetHoldout(holdoutId); } + /// + /// Returns all global holdouts (holdouts where IncludedRules is null). + /// Global holdouts apply to all rules across all flags and are evaluated at flag level. + /// + /// Read-only list of global holdouts + public IReadOnlyList GetGlobalHoldouts() + { + return _holdoutConfig.GetGlobalHoldouts(); + } + + /// + /// Returns local holdouts that target a specific rule ID. + /// Local holdouts are evaluated per-rule, after forced decisions but before regular rule evaluation. + /// + /// The rule ID to look up holdouts for + /// Read-only list of local holdouts targeting the given rule, or empty list if none + public IReadOnlyList GetHoldoutsForRule(string ruleId) + { + return _holdoutConfig.GetHoldoutsForRule(ruleId); + } + /// /// Get attribute ID for the provided attribute key /// diff --git a/OptimizelySDK/Entity/Holdout.cs b/OptimizelySDK/Entity/Holdout.cs index b0af884c..7ed44835 100644 --- a/OptimizelySDK/Entity/Holdout.cs +++ b/OptimizelySDK/Entity/Holdout.cs @@ -1,5 +1,5 @@ -/* - * Copyright 2025, Optimizely +/* + * Copyright 2025-2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,5 +45,19 @@ public override string LayerId /* Holdouts don't have layer IDs, ignore any assignment */ } } + + /// + /// Optional array of rule IDs that this holdout targets (local holdout). + /// When null, the holdout applies to all rules across all flags (global holdout). + /// When set to an array (even empty), the holdout only applies to the specified rules. + /// Rule IDs in this array are experiment/delivery rule IDs from the datafile, NOT flag IDs. + /// + public string[] IncludedRules { get; set; } + + /// + /// Returns true if this is a global holdout (IncludedRules is null), + /// false if this is a local holdout (IncludedRules is a non-null array). + /// + public bool IsGlobal => IncludedRules == null; } } diff --git a/OptimizelySDK/ProjectConfig.cs b/OptimizelySDK/ProjectConfig.cs index 598de0fa..cb3afcc0 100644 --- a/OptimizelySDK/ProjectConfig.cs +++ b/OptimizelySDK/ProjectConfig.cs @@ -332,6 +332,21 @@ public interface ProjectConfig /// Holdout Entity corresponding to the holdout ID or null if ID is invalid Holdout GetHoldout(string holdoutId); + /// + /// Returns all global holdouts (holdouts where IncludedRules is null). + /// Global holdouts apply to all rules across all flags and are evaluated at flag level. + /// + /// Read-only list of global holdouts + IReadOnlyList GetGlobalHoldouts(); + + /// + /// Returns local holdouts that target a specific rule ID. + /// Local holdouts are evaluated per-rule, after forced decisions but before regular rule evaluation. + /// + /// The rule ID to look up holdouts for + /// Read-only list of local holdouts targeting the given rule, or empty list if none + IReadOnlyList GetHoldoutsForRule(string ruleId); + /// /// Returns the datafile corresponding to ProjectConfig /// diff --git a/OptimizelySDK/Utils/HoldoutConfig.cs b/OptimizelySDK/Utils/HoldoutConfig.cs index 9d720d14..5f1ea381 100644 --- a/OptimizelySDK/Utils/HoldoutConfig.cs +++ b/OptimizelySDK/Utils/HoldoutConfig.cs @@ -1,5 +1,5 @@ -/* - * Copyright 2025, Optimizely +/* + * Copyright 2025-2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,13 +21,26 @@ namespace OptimizelySDK.Utils { /// - /// Configuration manager for holdouts, providing holdout ID mapping. + /// Configuration manager for holdouts, providing holdout ID mapping, + /// global holdout access, and rule-level local holdout lookups. /// public class HoldoutConfig { private List _allHoldouts; private readonly Dictionary _holdoutIdMap; + /// + /// Global holdouts — holdouts where IncludedRules is null. + /// These are evaluated at flag level, before any per-rule logic. + /// + private readonly List _globalHoldouts; + + /// + /// Maps rule IDs to the local holdouts that target those rules. + /// A local holdout has IncludedRules set to a non-null array of rule IDs. + /// + private readonly Dictionary> _ruleHoldoutsMap; + /// /// Initializes a new instance of the HoldoutConfig class. /// @@ -36,6 +49,8 @@ public HoldoutConfig(Holdout[] allHoldouts = null) { _allHoldouts = allHoldouts?.ToList() ?? new List(); _holdoutIdMap = new Dictionary(); + _globalHoldouts = new List(); + _ruleHoldoutsMap = new Dictionary>(); UpdateHoldoutMapping(); } @@ -46,17 +61,40 @@ public HoldoutConfig(Holdout[] allHoldouts = null) public IDictionary HoldoutIdMap => _holdoutIdMap; /// - /// Updates internal mappings of holdouts including the id map. + /// Updates internal mappings of holdouts: ID map, global list, and rule-to-holdout map. /// private void UpdateHoldoutMapping() { // Clear existing mappings _holdoutIdMap.Clear(); + _globalHoldouts.Clear(); + _ruleHoldoutsMap.Clear(); foreach (var holdout in _allHoldouts) { // Build ID mapping _holdoutIdMap[holdout.Id] = holdout; + + // Classify as global or local based on IncludedRules + if (holdout.IsGlobal) + { + // IncludedRules == null → global holdout (applies to all rules across all flags) + _globalHoldouts.Add(holdout); + } + else + { + // IncludedRules != null → local holdout (applies only to the specified rules) + // An empty IncludedRules array means local but matches no rules (intentional). + foreach (var ruleId in holdout.IncludedRules) + { + if (!_ruleHoldoutsMap.ContainsKey(ruleId)) + { + _ruleHoldoutsMap[ruleId] = new List(); + } + + _ruleHoldoutsMap[ruleId].Add(holdout); + } + } } } @@ -77,6 +115,37 @@ public Holdout GetHoldout(string holdoutId) return holdout; } + /// + /// Returns all global holdouts (holdouts where IncludedRules is null). + /// These apply to all rules across all flags and are evaluated at flag level. + /// + /// List of global holdouts + public IReadOnlyList GetGlobalHoldouts() + { + return _globalHoldouts; + } + + /// + /// Returns local holdouts that target a specific rule ID. + /// These are evaluated per-rule, after forced decisions but before regular rule evaluation. + /// + /// The rule ID to look up holdouts for + /// List of holdouts targeting the specified rule, or an empty list if none + public IReadOnlyList GetHoldoutsForRule(string ruleId) + { + if (string.IsNullOrEmpty(ruleId)) + { + return new List(); + } + + if (_ruleHoldoutsMap.TryGetValue(ruleId, out var holdouts)) + { + return holdouts; + } + + return new List(); + } + /// /// Gets the total number of holdouts. ///