diff --git a/lib/optimizely/config/datafile_project_config.rb b/lib/optimizely/config/datafile_project_config.rb index cf3a3629..4e384175 100644 --- a/lib/optimizely/config/datafile_project_config.rb +++ b/lib/optimizely/config/datafile_project_config.rb @@ -33,7 +33,8 @@ class DatafileProjectConfig < ProjectConfig :group_id_map, :rollout_id_map, :rollout_experiment_id_map, :variation_id_map, :variation_id_to_variable_usage_map, :variation_key_map, :variation_id_map_by_experiment_id, :variation_key_map_by_experiment_id, :flag_variation_map, :integration_key_map, :integrations, - :public_key_for_odp, :host_for_odp, :all_segments, :region, :holdouts, :holdout_id_map + :public_key_for_odp, :host_for_odp, :all_segments, :region, :holdouts, :holdout_id_map, + :global_holdouts, :rule_holdouts_map # Boolean - denotes if Optimizely should remove the last block of visitors' IP address before storing event data attr_reader :anonymize_ip @@ -114,6 +115,8 @@ def initialize(datafile, logger, error_handler) @variation_id_to_experiment_map = {} @flag_variation_map = {} @holdout_id_map = {} + @global_holdouts = [] + @rule_holdouts_map = {} @holdouts.each do |holdout| next unless holdout['status'] == 'Running' @@ -122,6 +125,19 @@ def initialize(datafile, logger, error_handler) holdout['layerId'] ||= '' @holdout_id_map[holdout['id']] = holdout + + # Build global vs local holdout mappings + # A holdout is global when includedRules is nil/absent (applies to all rules) + # A holdout is local when includedRules is a non-nil array (applies only to specified rules) + if holdout_global?(holdout) + @global_holdouts << holdout + else + included_rules = holdout['includedRules'] || [] + included_rules.each do |rule_id| + @rule_holdouts_map[rule_id] ||= [] + @rule_holdouts_map[rule_id] << holdout + end + end end @experiment_id_map.each_value do |exp| @@ -642,6 +658,27 @@ def get_holdout(holdout_id) nil end + def get_holdouts_for_rule(rule_id) + # Returns running local holdouts that target a specific rule ID. + # Local holdouts apply only to the rules listed in their includedRules array. + # + # rule_id - String ID of the experiment/delivery rule + # + # Returns Array of holdout hashes targeting the rule (empty array if none) + @rule_holdouts_map[rule_id] || [] + end + + def holdout_global?(holdout) + # Determines whether a holdout is global (applies to all rules) or local (applies to specific rules). + # A holdout is global when includedRules is nil or absent from the datafile. + # A holdout with an empty array [] is a local holdout with no matching rules (NOT global). + # + # holdout - Holdout hash from the datafile + # + # Returns true if the holdout is global, false if local + !holdout.key?('includedRules') || holdout['includedRules'].nil? + end + private def get_everyone_else_variation(feature_flag) diff --git a/lib/optimizely/decision_service.rb b/lib/optimizely/decision_service.rb index 38d10ab8..4a148a09 100644 --- a/lib/optimizely/decision_service.rb +++ b/lib/optimizely/decision_service.rb @@ -169,7 +169,7 @@ def get_variation_for_feature(project_config, feature_flag, user_context, decide # user_context - Optimizely user context instance # # Returns DecisionResult struct. - # Get running holdouts from the holdout_id_map (all holdouts are global now) + # Check for any running holdouts (global or local) running_holdouts = project_config.holdout_id_map.values if running_holdouts && !running_holdouts.empty? @@ -196,8 +196,8 @@ def get_decision_for_flag(feature_flag, user_context, project_config, decide_opt reasons = decide_reasons ? decide_reasons.dup : [] user_id = user_context.user_id - # Check holdouts (all holdouts are global now - apply to all flags) - holdouts = project_config.holdout_id_map.values + # Check global holdouts first (flag level) — these apply to all rules across all flags + holdouts = project_config.global_holdouts holdouts.each do |holdout| holdout_decision = get_variation_for_holdout(holdout, user_context, project_config) @@ -441,11 +441,25 @@ def get_variation_from_experiment_rule(project_config, flag_key, rule, user, use # Returns variation_id and reasons reasons = [] + # Step 1: Forced decision check — existing logic, evaluated per rule context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key']) variation, forced_reasons = validated_forced_decision(project_config, context, user) reasons.push(*forced_reasons) return VariationResult.new(nil, false, reasons, variation['id']) if variation + # Step 2: Local holdout check — check holdouts targeting this specific rule (FSSDK-12369) + # Local holdouts are evaluated per-rule, after forced decisions, before audience/traffic checks. + local_holdouts = project_config.get_holdouts_for_rule(rule['id']) + local_holdouts.each do |holdout| + holdout_decision = get_variation_for_holdout(holdout, user, project_config) + reasons.push(*holdout_decision.reasons) + next unless holdout_decision.decision + + holdout_variation = holdout_decision.decision.variation + return VariationResult.new(nil, false, reasons, holdout_variation['id']) + end + + # Step 3: Regular rule evaluation — existing logic variation_result = get_variation(project_config, rule['id'], user, user_profile_tracker, options) variation_result.reasons = reasons + variation_result.reasons variation_result @@ -464,12 +478,26 @@ def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index reasons = [] skip_to_everyone_else = false rule = rules[rule_index] + + # Step 1: Forced decision check — existing logic, evaluated per rule context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key']) variation, forced_reasons = validated_forced_decision(project_config, context, user_context) reasons.push(*forced_reasons) return [variation, skip_to_everyone_else, reasons] if variation + # Step 2: Local holdout check — check holdouts targeting this specific delivery rule (FSSDK-12369) + # Local holdouts are evaluated per-rule, after forced decisions, before audience/traffic checks. + local_holdouts = project_config.get_holdouts_for_rule(rule['id']) + local_holdouts.each do |holdout| + holdout_decision = get_variation_for_holdout(holdout, user_context, project_config) + reasons.push(*holdout_decision.reasons) + next unless holdout_decision.decision + + holdout_variation = holdout_decision.decision.variation + return [holdout_variation, skip_to_everyone_else, reasons] + end + user_id = user_context.user_id attributes = user_context.user_attributes bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes) diff --git a/lib/optimizely/helpers/constants.rb b/lib/optimizely/helpers/constants.rb index 2cfe817b..4a5e40a4 100644 --- a/lib/optimizely/helpers/constants.rb +++ b/lib/optimizely/helpers/constants.rb @@ -346,6 +346,9 @@ module Constants }, 'status' => { 'type' => 'string' + }, + 'includedRules' => { + 'type' => %w[array null] } } } diff --git a/spec/config/datafile_project_config_spec.rb b/spec/config/datafile_project_config_spec.rb index bb1a917b..7b3f5999 100644 --- a/spec/config/datafile_project_config_spec.rb +++ b/spec/config/datafile_project_config_spec.rb @@ -1935,4 +1935,163 @@ def build_datafile(experiments: [], rollouts: [], feature_flags: []) expect(experiment['trafficAllocation'].length).to eq(1) end end + + # Level 1 — Local Holdouts config/parsing tests (FSSDK-12369) + describe 'local holdouts data model and config parsing' do + let(:config_with_local_holdouts) do + Optimizely::DatafileProjectConfig.new( + OptimizelySpec::CONFIG_BODY_WITH_HOLDOUTS_JSON, + logger, + error_handler + ) + end + + describe '#holdout_global?' do + it 'returns true for holdout with no includedRules key (old datafile format)' do + global_holdout = config_with_local_holdouts.get_holdout('holdout_1') + expect(global_holdout).not_to be_nil + expect(global_holdout.key?('includedRules')).to be false + expect(config_with_local_holdouts.holdout_global?(global_holdout)).to be true + end + + it 'returns true for holdout with explicit nil includedRules' do + holdout = {'id' => 'test', 'key' => 'test', 'includedRules' => nil} + expect(config_with_local_holdouts.holdout_global?(holdout)).to be true + end + + it 'returns false for holdout with non-nil includedRules array (local holdout)' do + local_holdout = config_with_local_holdouts.get_holdout('holdout_local_1') + expect(local_holdout).not_to be_nil + expect(config_with_local_holdouts.holdout_global?(local_holdout)).to be false + end + + it 'returns false for holdout with empty includedRules array (local holdout with no matching rules)' do + local_holdout_empty = config_with_local_holdouts.get_holdout('holdout_local_empty_rules') + expect(local_holdout_empty).not_to be_nil + expect(local_holdout_empty['includedRules']).to eq([]) + # Empty array is local holdout (not global) — DIFFERENT from nil + expect(config_with_local_holdouts.holdout_global?(local_holdout_empty)).to be false + end + end + + describe '#get_global_holdouts' do + it 'returns only holdouts without includedRules (global holdouts)' do + global_holdouts = config_with_local_holdouts.get_global_holdouts + + expect(global_holdouts).not_to be_nil + expect(global_holdouts).to be_an(Array) + + # All returned holdouts must be global + global_holdouts.each do |holdout| + expect(config_with_local_holdouts.holdout_global?(holdout)).to be true + end + end + + it 'does not include local holdouts (those with includedRules array)' do + global_holdouts = config_with_local_holdouts.get_global_holdouts + local_holdout = config_with_local_holdouts.get_holdout('holdout_local_1') + + expect(global_holdouts).not_to include(local_holdout) + end + + it 'does not include holdouts with empty includedRules array' do + global_holdouts = config_with_local_holdouts.get_global_holdouts + empty_local_holdout = config_with_local_holdouts.get_holdout('holdout_local_empty_rules') + + # Empty [] is local, not global — must not appear in global_holdouts + expect(global_holdouts).not_to include(empty_local_holdout) + end + + it 'returns empty array when no global holdouts are present' do + config_no_global = Optimizely::DatafileProjectConfig.new( + JSON.dump( + OptimizelySpec::VALID_CONFIG_BODY.merge( + 'holdouts' => [ + { + 'id' => 'only_local', + 'key' => 'only_local_holdout', + 'status' => 'Running', + 'audiences' => [], + 'includedRules' => ['some_rule_id'], + 'variations' => [{'id' => 'v1', 'key' => 'holdout', 'featureEnabled' => false}], + 'trafficAllocation' => [{'entityId' => 'v1', 'endOfRange' => 10_000}] + } + ] + ) + ), + logger, + error_handler + ) + expect(config_no_global.get_global_holdouts).to eq([]) + end + end + + describe '#get_holdouts_for_rule' do + it 'returns local holdouts targeting the specified rule ID' do + rule_holdouts = config_with_local_holdouts.get_holdouts_for_rule('122227') + local_holdout = config_with_local_holdouts.get_holdout('holdout_local_1') + + expect(rule_holdouts).to include(local_holdout) + end + + it 'returns empty array for a rule ID with no targeting holdouts' do + rule_holdouts = config_with_local_holdouts.get_holdouts_for_rule('unknown_rule_id_xyz') + expect(rule_holdouts).to eq([]) + end + + it 'does not return global holdouts (those without includedRules)' do + rule_holdouts = config_with_local_holdouts.get_holdouts_for_rule('122227') + global_holdout = config_with_local_holdouts.get_holdout('holdout_1') + + # Global holdout must not appear in rule-specific list + expect(rule_holdouts).not_to include(global_holdout) + end + + it 'does not return holdouts targeting other rules' do + # holdout_local_2 targets rule 122238, not 122227 + holdouts_for_rule_a = config_with_local_holdouts.get_holdouts_for_rule('122227') + local_holdout_2 = config_with_local_holdouts.get_holdout('holdout_local_2') + + expect(holdouts_for_rule_a).not_to include(local_holdout_2) + end + + it 'returns empty array for holdout with empty includedRules — does not match any rule' do + # holdout_local_empty_rules has includedRules: [] — should not appear for any rule + rule_holdouts = config_with_local_holdouts.get_holdouts_for_rule('122227') + empty_rules_holdout = config_with_local_holdouts.get_holdout('holdout_local_empty_rules') + + expect(rule_holdouts).not_to include(empty_rules_holdout) + end + end + + describe 'backward compatibility — old datafiles without includedRules' do + it 'parses holdouts without includedRules field as global (backward compatible)' do + # Use the config with only global holdouts (no includedRules key) + config_global_only = Optimizely::DatafileProjectConfig.new( + OptimizelySpec::CONFIG_BODY_WITH_GLOBAL_HOLDOUTS_ONLY_JSON, + logger, + error_handler + ) + + holdout = config_global_only.get_holdout('global_holdout_only_1') + expect(holdout).not_to be_nil + expect(holdout.key?('includedRules')).to be false + + # Must be classified as global + expect(config_global_only.holdout_global?(holdout)).to be true + expect(config_global_only.get_global_holdouts).to include(holdout) + expect(config_global_only.get_holdouts_for_rule('any_rule')).to eq([]) + end + + it 'does not raise errors when processing old datafiles' do + expect do + Optimizely::DatafileProjectConfig.new( + OptimizelySpec::CONFIG_BODY_WITH_GLOBAL_HOLDOUTS_ONLY_JSON, + logger, + error_handler + ) + end.not_to raise_error + end + end + end end diff --git a/spec/decision_service_holdout_spec.rb b/spec/decision_service_holdout_spec.rb index b2fc4571..0c25a233 100644 --- a/spec/decision_service_holdout_spec.rb +++ b/spec/decision_service_holdout_spec.rb @@ -748,4 +748,289 @@ end end end + + # Level 2 — Decision service tests for local holdouts (FSSDK-12369) + describe 'Local Holdout Decision Service Tests' do + let(:config_with_local_holdouts) do + Optimizely::DatafileProjectConfig.new( + OptimizelySpec::CONFIG_BODY_WITH_HOLDOUTS_JSON, + spy_logger, + error_handler + ) + end + + let(:project_with_local_holdouts) do + Optimizely::Project.new( + datafile: OptimizelySpec::CONFIG_BODY_WITH_HOLDOUTS_JSON, + logger: spy_logger, + error_handler: error_handler + ) + end + + let(:decision_service_local) do + Optimizely::DecisionService.new(spy_logger, spy_cmab_service) + end + + after(:example) do + project_with_local_holdouts&.close + end + + describe 'global holdout branch — evaluated at flag level before any rules' do + it 'returns holdout decision before per-rule evaluation when user hits global holdout' do + # global_holdout (holdout_1) has no includedRules => global + global_holdout = config_with_local_holdouts.get_holdout('holdout_1') + expect(global_holdout).not_to be_nil + expect(config_with_local_holdouts.holdout_global?(global_holdout)).to be true + + # Verify it appears in global_holdouts list + expect(config_with_local_holdouts.get_global_holdouts).to include(global_holdout) + + # Mock the holdout to return a decision to simulate user being in global holdout + allow(decision_service_local).to receive(:get_variation_for_holdout) + .with(global_holdout, anything, anything) + .and_return( + Optimizely::DecisionService::DecisionResult.new( + Optimizely::DecisionService::Decision.new( + global_holdout, + global_holdout['variations'].first, + Optimizely::DecisionService::DECISION_SOURCES['HOLDOUT'], + nil + ), + false, + ['User is in global holdout'] + ) + ) + + # Mock get_variation_for_feature_experiment to track if it is called + allow(decision_service_local).to receive(:get_variation_for_feature_experiment).and_call_original + + feature_flag = config_with_local_holdouts.feature_flag_key_map['boolean_feature'] + user_ctx = project_with_local_holdouts.create_user_context('test_user', {}) + + result = decision_service_local.get_decision_for_flag( + feature_flag, + user_ctx, + config_with_local_holdouts + ) + + # Should return holdout decision from global holdout (flag level) + expect(result).not_to be_nil + expect(result.decision).not_to be_nil + expect(result.decision.source).to eq(Optimizely::DecisionService::DECISION_SOURCES['HOLDOUT']) + + # Experiment evaluation must NOT be called since global holdout fired + expect(decision_service_local).not_to have_received(:get_variation_for_feature_experiment) + end + end + + describe 'local holdout hit branch — user bucketed into local holdout for a specific rule' do + it 'returns holdout variation for the rule; audience and traffic allocation are not evaluated' do + # Local holdout targeting experiment rule 122227 + local_holdout = config_with_local_holdouts.get_holdout('holdout_local_1') + expect(local_holdout).not_to be_nil + expect(local_holdout['includedRules']).to eq(['122227']) + expect(config_with_local_holdouts.holdout_global?(local_holdout)).to be false + + # Verify it is in the rule holdouts map for rule 122227 + rule_holdouts = config_with_local_holdouts.get_holdouts_for_rule('122227') + expect(rule_holdouts).to include(local_holdout) + + # Verify it is NOT in global holdouts + expect(config_with_local_holdouts.get_global_holdouts).not_to include(local_holdout) + + # Set up global holdouts to miss (no global holdout decision) + config_with_local_holdouts.get_global_holdouts.each do |gh| + allow(decision_service_local).to receive(:get_variation_for_holdout) + .with(gh, anything, anything) + .and_return(Optimizely::DecisionService::DecisionResult.new(nil, false, [])) + end + + # Local holdout for rule 122227 fires + allow(decision_service_local).to receive(:get_variation_for_holdout) + .with(local_holdout, anything, anything) + .and_return( + Optimizely::DecisionService::DecisionResult.new( + Optimizely::DecisionService::Decision.new( + local_holdout, + local_holdout['variations'].first, + Optimizely::DecisionService::DECISION_SOURCES['HOLDOUT'], + nil + ), + false, + ['User is in local holdout for rule 122227'] + ) + ) + + # Spy on get_variation to verify it is NOT called (audience/traffic skipped) + allow(decision_service_local).to receive(:get_variation).and_call_original + + user_ctx = project_with_local_holdouts.create_user_context('test_user', {}) + user_profile_tracker = Optimizely::UserProfileTracker.new('test_user', nil, spy_logger) + + result = decision_service_local.get_variation_from_experiment_rule( + config_with_local_holdouts, + 'boolean_feature', + config_with_local_holdouts.experiment_id_map['122227'], + user_ctx, + user_profile_tracker + ) + + # get_variation must NOT be called — local holdout takes precedence + expect(decision_service_local).not_to have_received(:get_variation) + + # Variation ID must be from the local holdout + expect(result.variation_id).to eq(local_holdout['variations'].first['id']) + end + end + + describe 'local holdout miss branch — user not in local holdout falls through to rule evaluation' do + it 'proceeds to regular rule evaluation when local holdout does not match' do + local_holdout = config_with_local_holdouts.get_holdout('holdout_local_1') + expect(local_holdout).not_to be_nil + + # All global holdouts miss + config_with_local_holdouts.get_global_holdouts.each do |gh| + allow(decision_service_local).to receive(:get_variation_for_holdout) + .with(gh, anything, anything) + .and_return(Optimizely::DecisionService::DecisionResult.new(nil, false, [])) + end + + # Local holdout also misses (user not bucketed) + allow(decision_service_local).to receive(:get_variation_for_holdout) + .with(local_holdout, anything, anything) + .and_return(Optimizely::DecisionService::DecisionResult.new(nil, false, ['User not in local holdout'])) + + # Track that get_variation (regular rule evaluation) is called + allow(decision_service_local).to receive(:get_variation).and_call_original + + user_ctx = project_with_local_holdouts.create_user_context('test_user', {}) + user_profile_tracker = Optimizely::UserProfileTracker.new('test_user', nil, spy_logger) + + decision_service_local.get_variation_from_experiment_rule( + config_with_local_holdouts, + 'boolean_feature', + config_with_local_holdouts.experiment_id_map['122227'], + user_ctx, + user_profile_tracker + ) + + # Regular rule evaluation must be called since local holdout missed + expect(decision_service_local).to have_received(:get_variation) + end + end + + describe 'rule specificity — local holdout for rule X does not affect rule Y' do + it 'local holdout targeting rule 122227 is not returned for rule 122238' do + # holdout_local_1 targets rule 122227 only + holdouts_for_rule_a = config_with_local_holdouts.get_holdouts_for_rule('122227') + holdouts_for_rule_b = config_with_local_holdouts.get_holdouts_for_rule('122238') + + local_holdout_1 = config_with_local_holdouts.get_holdout('holdout_local_1') + local_holdout_2 = config_with_local_holdouts.get_holdout('holdout_local_2') + + # holdout_local_1 should be in rule 122227's list + expect(holdouts_for_rule_a).to include(local_holdout_1) + # holdout_local_1 should NOT be in rule 122238's list + expect(holdouts_for_rule_a).not_to include(local_holdout_2) + + # holdout_local_2 should be in rule 122238's list + expect(holdouts_for_rule_b).to include(local_holdout_2) + # holdout_local_2 should NOT be in rule 122227's list + expect(holdouts_for_rule_b).not_to include(local_holdout_1) + + # Unknown rule should return empty array + expect(config_with_local_holdouts.get_holdouts_for_rule('unknown_rule_id')).to eq([]) + end + end + + describe 'experiment vs delivery rules — local holdout check applies to both rule types' do + it 'applies local holdout check in get_variation_from_experiment_rule' do + local_holdout = config_with_local_holdouts.get_holdout('holdout_local_1') + expect(local_holdout).not_to be_nil + + # Verify get_holdouts_for_rule is called during experiment rule evaluation + allow(config_with_local_holdouts).to receive(:get_holdouts_for_rule).and_call_original + + user_ctx = project_with_local_holdouts.create_user_context('test_user', {}) + user_profile_tracker = Optimizely::UserProfileTracker.new('test_user', nil, spy_logger) + + decision_service_local.get_variation_from_experiment_rule( + config_with_local_holdouts, + 'boolean_feature', + config_with_local_holdouts.experiment_id_map['122227'], + user_ctx, + user_profile_tracker + ) + + expect(config_with_local_holdouts).to have_received(:get_holdouts_for_rule).with('122227') + end + + it 'applies local holdout check in get_variation_from_delivery_rule' do + # boolean_single_variable_feature has a rollout with delivery rules + feature_flag = config_with_local_holdouts.feature_flag_key_map['boolean_single_variable_feature'] + rollout_id = feature_flag['rolloutId'] + + unless rollout_id.nil? || rollout_id.empty? + rollout = config_with_local_holdouts.rollout_id_map[rollout_id] + + unless rollout.nil? || rollout['experiments'].empty? + rollout_rules = rollout['experiments'] + first_rule = rollout_rules[0] + + allow(config_with_local_holdouts).to receive(:get_holdouts_for_rule).and_call_original + + user_ctx = project_with_local_holdouts.create_user_context('test_user', {}) + + decision_service_local.get_variation_from_delivery_rule( + config_with_local_holdouts, + 'boolean_single_variable_feature', + rollout_rules, + 0, + user_ctx + ) + + expect(config_with_local_holdouts).to have_received(:get_holdouts_for_rule).with(first_rule['id']) + end + end + end + end + + # Mandatory enforcement test (cross-SDK): forced decision must beat a 100% traffic local holdout. + # Ordering: Forced Decision → Local Holdout → Regular Rule (non-negotiable). + describe 'forced decision beats 100% traffic local holdout' do + it 'returns forced decision variation even when 100% local holdout targets the same rule' do + # holdout_local_1 targets experiment 122227 (test_experiment_with_audience) with 100% traffic. + # User also has a forced decision set for boolean_feature / test_experiment_with_audience. + # Expected: forced decision wins; variation_id is from forced decision, not the holdout. + local_holdout = config_with_local_holdouts.get_holdout('holdout_local_1') + expect(local_holdout).not_to be_nil + expect(local_holdout['includedRules']).to eq(['122227']) + expect(local_holdout['trafficAllocation'].first['endOfRange']).to eq(10_000) # 100% traffic + + # Set forced decision for boolean_feature / test_experiment_with_audience → control_with_audience + user_ctx = project_with_local_holdouts.create_user_context('test_user', {}) + context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new( + 'boolean_feature', + 'test_experiment_with_audience' + ) + forced = Optimizely::OptimizelyUserContext::OptimizelyForcedDecision.new('control_with_audience') + user_ctx.set_forced_decision(context, forced) + + user_profile_tracker = Optimizely::UserProfileTracker.new('test_user', nil, spy_logger) + + result = decision_service_local.get_variation_from_experiment_rule( + config_with_local_holdouts, + 'boolean_feature', + config_with_local_holdouts.experiment_id_map['122227'], + user_ctx, + user_profile_tracker + ) + + # Forced decision must win — variation_id must be control_with_audience (122228), not the holdout variation + expect(result).not_to be_nil + expect(result.variation_id).to eq('122228') # control_with_audience — forced decision variation + expect(result.variation_id).not_to eq('local_var_1') # must NOT be holdout variation + end + end + end end diff --git a/spec/spec_params.rb b/spec/spec_params.rb index 789c36df..55982f32 100644 --- a/spec/spec_params.rb +++ b/spec/spec_params.rb @@ -1947,6 +1947,7 @@ module OptimizelySpec 'key' => 'global_holdout', 'status' => 'Running', 'audiences' => [], + # No includedRules field => global holdout (applies to all rules) 'variations' => [ { 'id' => 'var_1', @@ -1989,6 +1990,69 @@ module OptimizelySpec } ] }, + { + # Local holdout targeting experiment rule 122227 (used in boolean_feature) + 'id' => 'holdout_local_1', + 'key' => 'local_holdout_rule_122227', + 'status' => 'Running', + 'audiences' => [], + 'includedRules' => ['122227'], + 'variations' => [ + { + 'id' => 'local_var_1', + 'key' => 'holdout', + 'featureEnabled' => false + } + ], + 'trafficAllocation' => [ + { + 'entityId' => 'local_var_1', + 'endOfRange' => 10_000 + } + ] + }, + { + # Local holdout targeting a different rule (122238), not rule 122227 + 'id' => 'holdout_local_2', + 'key' => 'local_holdout_rule_122238', + 'status' => 'Running', + 'audiences' => [], + 'includedRules' => ['122238'], + 'variations' => [ + { + 'id' => 'local_var_2', + 'key' => 'holdout', + 'featureEnabled' => false + } + ], + 'trafficAllocation' => [ + { + 'entityId' => 'local_var_2', + 'endOfRange' => 10_000 + } + ] + }, + { + # Local holdout with empty includedRules array — local holdout with no matching rules (NOT global) + 'id' => 'holdout_local_empty_rules', + 'key' => 'local_holdout_empty_rules', + 'status' => 'Running', + 'audiences' => [], + 'includedRules' => [], + 'variations' => [ + { + 'id' => 'local_var_empty', + 'key' => 'holdout', + 'featureEnabled' => false + } + ], + 'trafficAllocation' => [ + { + 'entityId' => 'local_var_empty', + 'endOfRange' => 10_000 + } + ] + }, { 'id' => 'holdout_empty_1', 'key' => 'holdout_empty_1', @@ -2041,6 +2105,36 @@ module OptimizelySpec CONFIG_BODY_WITH_HOLDOUTS_JSON = JSON.dump(CONFIG_BODY_WITH_HOLDOUTS).freeze + # Config with only global holdouts (no includedRules) for backward compatibility tests + CONFIG_BODY_WITH_GLOBAL_HOLDOUTS_ONLY = VALID_CONFIG_BODY.merge( + { + 'holdouts' => [ + { + 'id' => 'global_holdout_only_1', + 'key' => 'global_only_holdout', + 'status' => 'Running', + 'audiences' => [], + # No includedRules key at all — old datafile format, defaults to global + 'variations' => [ + { + 'id' => 'gho_var_1', + 'key' => 'control', + 'featureEnabled' => true + } + ], + 'trafficAllocation' => [ + { + 'entityId' => 'gho_var_1', + 'endOfRange' => 10_000 + } + ] + } + ] + } + ).freeze + + CONFIG_BODY_WITH_GLOBAL_HOLDOUTS_ONLY_JSON = JSON.dump(CONFIG_BODY_WITH_GLOBAL_HOLDOUTS_ONLY).freeze + def self.deep_clone(obj) obj.dup.tap do |new_obj| case new_obj