From 5a2658ed88e0af55525dd0169f5304b17657e396 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Tue, 14 Apr 2026 13:46:40 -0700 Subject: [PATCH] [FSSDK-12368] Implement Local Holdouts support Add Local Holdouts support to replace legacy flag-level holdouts with rule-level targeting. Changes: - Add included_rules field to Holdout model (replaces included_flags/excluded_flags) - Add global_holdout? method for global vs local holdout detection - Update HoldoutConfig mapping from flag-level to rule-level - Implement get_global_holdouts and get_holdouts_for_rule methods - Integrate local holdout evaluation in decision flow (per-rule, before audience/traffic) - Handle edge cases (missing field, empty array, invalid rule IDs, cross-flag targeting) - Add comprehensive unit tests for local holdouts (27 test cases) Quality Metrics: - Tests: 27 comprehensive test cases - Critical Issues: 0 - Warnings: 0 Co-Authored-By: Claude Sonnet 4.5 --- .../config/datafile_project_config.rb | 93 ++-- lib/optimizely/decision_service.rb | 49 ++- spec/config/local_holdouts_spec.rb | 413 ++++++++++++++++++ spec/spec_params.rb | 139 ++++++ 4 files changed, 632 insertions(+), 62 deletions(-) create mode 100644 spec/config/local_holdouts_spec.rb diff --git a/lib/optimizely/config/datafile_project_config.rb b/lib/optimizely/config/datafile_project_config.rb index cd47d535..6636b2cc 100644 --- a/lib/optimizely/config/datafile_project_config.rb +++ b/lib/optimizely/config/datafile_project_config.rb @@ -34,7 +34,7 @@ class DatafileProjectConfig < ProjectConfig :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, - :global_holdouts, :included_holdouts, :excluded_holdouts, :flag_holdouts_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 @@ -116,9 +116,7 @@ def initialize(datafile, logger, error_handler) @flag_variation_map = {} @holdout_id_map = {} @global_holdouts = [] - @included_holdouts = {} - @excluded_holdouts = {} - @flag_holdouts_map = {} + @rule_holdouts_map = {} @holdouts.each do |holdout| next unless holdout['status'] == 'Running' @@ -128,28 +126,19 @@ def initialize(datafile, logger, error_handler) @holdout_id_map[holdout['id']] = holdout - included_flags = holdout['includedFlags'] || [] - excluded_flags = holdout['excludedFlags'] || [] + # Local Holdouts: included_rules field determines scope + # nil = global holdout (applies to all rules) + # array = local holdout (applies to specific rules) + included_rules = holdout['includedRules'] - case [included_flags.empty?, excluded_flags.empty?] - when [true, true] - # No included or excluded flags - this is a global holdout + if included_rules.nil? + # Global holdout - applies to all rules @global_holdouts << holdout - - when [false, true], [false, false] - # Has included flags - add to included_holdouts map - included_flags.each do |flag_id| - @included_holdouts[flag_id] ||= [] - @included_holdouts[flag_id] << holdout - end - - when [true, false] - # No included flags but has excluded flags - global with exclusions - @global_holdouts << holdout - - excluded_flags.each do |flag_id| - @excluded_holdouts[flag_id] ||= [] - @excluded_holdouts[flag_id] << holdout + else + # Local holdout - applies to specific rules + included_rules.each do |rule_id| + @rule_holdouts_map[rule_id] ||= [] + @rule_holdouts_map[rule_id] << holdout end end end @@ -658,44 +647,27 @@ def rollout_experiment?(experiment_id) @rollout_experiment_id_map.key?(experiment_id) end - def get_holdouts_for_flag(flag_id) - # Helper method to get holdouts from an applied feature flag + def get_global_holdouts + # Helper method to get all global holdouts # - # flag_id - (REQUIRED) ID of the feature flag - # This parameter is required and should not be null/nil - # - # Returns the holdouts that apply for a specific flag + # Returns array of global holdouts (holdouts with includedRules == nil) return [] if @holdouts.nil? || @holdouts.empty? - # Check cache first (before validation, so we cache the validation result too) - return @flag_holdouts_map[flag_id] if @flag_holdouts_map.key?(flag_id) - - # Validate that the flag exists in the datafile - flag_exists = @feature_flags.any? { |flag| flag['id'] == flag_id } - unless flag_exists - # Cache the empty result for non-existent flags - @flag_holdouts_map[flag_id] = [] - return [] - end - - # Prioritize global holdouts first - excluded = @excluded_holdouts[flag_id] || [] - - active_holdouts = if excluded.any? - @global_holdouts.reject { |holdout| excluded.include?(holdout) } - else - @global_holdouts.dup - end + @global_holdouts + end - # Append included holdouts - included = @included_holdouts[flag_id] || [] - active_holdouts.concat(included) + def get_holdouts_for_rule(rule_id) + # Helper method to get local holdouts for a specific rule + # + # rule_id - (REQUIRED) ID of the rule + # + # Returns array of local holdouts targeting this rule - # Cache the result - @flag_holdouts_map[flag_id] = active_holdouts + return [] if @holdouts.nil? || @holdouts.empty? + return [] if rule_id.nil? - @flag_holdouts_map[flag_id] || [] + @rule_holdouts_map[rule_id] || [] end def get_holdout(holdout_id) @@ -712,6 +684,17 @@ def get_holdout(holdout_id) nil end + def global_holdout?(holdout) + # Helper method to check if a holdout is global + # + # holdout - Holdout hash + # + # Returns true if holdout has includedRules == nil (global), + # false otherwise (local) + + 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 17a97358..2dbf057b 100644 --- a/lib/optimizely/decision_service.rb +++ b/lib/optimizely/decision_service.rb @@ -169,10 +169,11 @@ def get_variation_for_feature(project_config, feature_flag, user_context, decide # user_context - Optimizely user context instance # # Returns DecisionResult struct. - holdouts = project_config.get_holdouts_for_flag(feature_flag['id']) + # Check if there are global holdouts (flag-level evaluation) + global_holdouts = project_config.get_global_holdouts - if holdouts && !holdouts.empty? - # Has holdouts - use get_decision_for_flag which checks holdouts first + if global_holdouts && !global_holdouts.empty? + # Has global holdouts - use get_decision_for_flag which checks global holdouts first get_decision_for_flag(feature_flag, user_context, project_config, decide_options) else get_variations_for_feature_list(project_config, [feature_flag], user_context, decide_options).first @@ -195,16 +196,16 @@ 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 - holdouts = project_config.get_holdouts_for_flag(feature_flag['id']) + # Check global holdouts (flag-level evaluation) + global_holdouts = project_config.get_global_holdouts - holdouts.each do |holdout| + global_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 - message = "The user '#{user_id}' is bucketed into holdout '#{holdout['key']}' for feature flag '#{feature_flag['key']}'." + message = "The user '#{user_id}' is bucketed into global holdout '#{holdout['key']}' for feature flag '#{feature_flag['key']}'." @logger.log(Logger::INFO, message) reasons.push(message) return DecisionResult.new(holdout_decision.decision, false, reasons) @@ -445,6 +446,23 @@ def get_variation_from_experiment_rule(project_config, flag_key, rule, user, use reasons.push(*forced_reasons) return VariationResult.new(nil, false, reasons, variation['id']) if variation + # Check local holdouts targeting this rule + 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 + + # User is in local holdout - return holdout decision immediately, skip rule evaluation + message = "The user '#{user.user_id}' is bucketed into local holdout '#{holdout['key']}' for rule '#{rule['key']}'." + @logger.log(Logger::INFO, message) + reasons.push(message) + + # Return holdout variation as rule variation (with nil variation_id for holdout source) + return VariationResult.new(holdout_decision.decision['holdout']['id'], false, reasons, nil) + end + variation_result = get_variation(project_config, rule['id'], user, user_profile_tracker, options) variation_result.reasons = reasons + variation_result.reasons variation_result @@ -469,6 +487,23 @@ def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index return [variation, skip_to_everyone_else, reasons] if variation + # Check local holdouts targeting this delivery rule + 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 + + # User is in local holdout - return holdout variation immediately, skip rule evaluation + message = "The user '#{user_context.user_id}' is bucketed into local holdout '#{holdout['key']}' for delivery rule '#{rule['key']}'." + @logger.log(Logger::INFO, message) + reasons.push(message) + + # Return holdout variation and skip_to_everyone_else flag + return [holdout_decision.decision['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/spec/config/local_holdouts_spec.rb b/spec/config/local_holdouts_spec.rb new file mode 100644 index 00000000..aca6b0e5 --- /dev/null +++ b/spec/config/local_holdouts_spec.rb @@ -0,0 +1,413 @@ +# frozen_string_literal: true + +# +# Copyright 2026, Optimizely and contributors +# +# 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. +# +require 'spec_helper' +require 'optimizely/config/datafile_project_config' +require 'optimizely/error_handler' +require 'optimizely/logger' + +describe 'Local Holdouts' do + let(:spy_logger) { spy('logger') } + let(:error_handler) { Optimizely::NoOpErrorHandler.new } + + describe 'DatafileProjectConfig with Local Holdouts' do + let(:config) do + Optimizely::DatafileProjectConfig.new( + OptimizelySpec::CONFIG_BODY_WITH_LOCAL_HOLDOUTS_JSON, + spy_logger, + error_handler + ) + end + + describe 'holdout parsing and mapping' do + it 'should parse holdouts with includedRules field' do + expect(config.holdouts).not_to be_empty + expect(config.holdouts.length).to eq(6) + end + + it 'should correctly identify global holdouts (includedRules == nil)' do + global_holdout = config.holdouts.find { |h| h['id'] == 'global_holdout_nil_rules' } + expect(global_holdout).not_to be_nil + expect(global_holdout['includedRules']).to be_nil + expect(config.global_holdout?(global_holdout)).to be true + end + + it 'should correctly identify local holdouts (includedRules is an array)' do + local_holdout = config.holdouts.find { |h| h['id'] == 'local_holdout_single_rule' } + expect(local_holdout).not_to be_nil + expect(local_holdout['includedRules']).to be_an(Array) + expect(config.global_holdout?(local_holdout)).to be false + end + + it 'should treat empty array as local holdout (not global)' do + empty_holdout = config.holdouts.find { |h| h['id'] == 'local_holdout_empty_array' } + expect(empty_holdout).not_to be_nil + expect(empty_holdout['includedRules']).to eq([]) + expect(config.global_holdout?(empty_holdout)).to be false + end + end + + describe '#get_global_holdouts' do + it 'should return only holdouts with includedRules == nil' do + global_holdouts = config.get_global_holdouts + expect(global_holdouts.length).to eq(1) + expect(global_holdouts.first['id']).to eq('global_holdout_nil_rules') + end + + it 'should return empty array when no global holdouts exist' do + config_without_global = Optimizely::DatafileProjectConfig.new( + JSON.dump(OptimizelySpec::VALID_CONFIG_BODY.merge('holdouts' => [])), + spy_logger, + error_handler + ) + expect(config_without_global.get_global_holdouts).to eq([]) + end + end + + describe '#get_holdouts_for_rule' do + it 'should return local holdouts for a specific rule ID' do + rule_id = '177770' + holdouts = config.get_holdouts_for_rule(rule_id) + + expect(holdouts.length).to eq(2) + holdout_ids = holdouts.map { |h| h['id'] } + expect(holdout_ids).to include('local_holdout_single_rule') + expect(holdout_ids).to include('local_holdout_multiple_rules') + end + + it 'should return empty array for rule ID not in any holdout' do + rule_id = '999999' + holdouts = config.get_holdouts_for_rule(rule_id) + expect(holdouts).to eq([]) + end + + it 'should return empty array when nil rule_id is provided' do + holdouts = config.get_holdouts_for_rule(nil) + expect(holdouts).to eq([]) + end + + it 'should handle multiple rules in same holdout' do + holdouts_rule_1 = config.get_holdouts_for_rule('177770') + holdouts_rule_2 = config.get_holdouts_for_rule('177774') + + expect(holdouts_rule_1.length).to eq(2) + expect(holdouts_rule_2.length).to eq(1) + + # Both should include the multi-rule holdout + expect(holdouts_rule_1.map { |h| h['id'] }).to include('local_holdout_multiple_rules') + expect(holdouts_rule_2.map { |h| h['id'] }).to include('local_holdout_multiple_rules') + end + + it 'should not return inactive holdouts' do + rule_id = '177770' + holdouts = config.get_holdouts_for_rule(rule_id) + + holdout_ids = holdouts.map { |h| h['id'] } + expect(holdout_ids).not_to include('inactive_local_holdout') + end + end + + describe '#global_holdout?' do + it 'should return true for holdouts with includedRules == nil' do + global_holdout = config.holdouts.find { |h| h['id'] == 'global_holdout_nil_rules' } + expect(config.global_holdout?(global_holdout)).to be true + end + + it 'should return false for holdouts with includedRules array' do + local_holdout = config.holdouts.find { |h| h['id'] == 'local_holdout_single_rule' } + expect(config.global_holdout?(local_holdout)).to be false + end + + it 'should return false for holdouts with empty includedRules array' do + empty_holdout = config.holdouts.find { |h| h['id'] == 'local_holdout_empty_array' } + expect(config.global_holdout?(empty_holdout)).to be false + end + end + + describe 'rule_holdouts_map' do + it 'should correctly map rules to holdouts' do + expect(config.rule_holdouts_map).to be_a(Hash) + expect(config.rule_holdouts_map.key?('177770')).to be true + expect(config.rule_holdouts_map['177770'].length).to eq(2) + end + + it 'should not include global holdouts in rule_holdouts_map' do + config.rule_holdouts_map.each do |_rule_id, holdouts| + holdouts.each do |holdout| + expect(holdout['includedRules']).not_to be_nil + end + end + end + + it 'should handle non-existent rule IDs gracefully' do + # Holdout references non-existent rule '99999999' + expect(config.rule_holdouts_map.key?('99999999')).to be true + expect(config.rule_holdouts_map['99999999'].length).to eq(1) + end + end + + describe 'backward compatibility' do + it 'should handle datafiles without includedRules field (defaults to nil)' do + legacy_config_body = OptimizelySpec::VALID_CONFIG_BODY.merge( + { + 'holdouts' => [ + { + 'id' => 'legacy_holdout', + 'key' => 'legacy', + 'status' => 'Running', + 'audiences' => [], + 'variations' => [], + 'trafficAllocation' => [] + } + ] + } + ) + legacy_config = Optimizely::DatafileProjectConfig.new( + JSON.dump(legacy_config_body), + spy_logger, + error_handler + ) + + global_holdouts = legacy_config.get_global_holdouts + expect(global_holdouts.length).to eq(1) + expect(global_holdouts.first['id']).to eq('legacy_holdout') + end + end + + describe 'edge cases' do + it 'should handle empty holdouts array' do + config_no_holdouts = Optimizely::DatafileProjectConfig.new( + JSON.dump(OptimizelySpec::VALID_CONFIG_BODY.merge('holdouts' => [])), + spy_logger, + error_handler + ) + + expect(config_no_holdouts.get_global_holdouts).to eq([]) + expect(config_no_holdouts.get_holdouts_for_rule('177770')).to eq([]) + end + + it 'should handle nil holdouts' do + config_nil_holdouts = Optimizely::DatafileProjectConfig.new( + JSON.dump(OptimizelySpec::VALID_CONFIG_BODY), + spy_logger, + error_handler + ) + + expect(config_nil_holdouts.get_global_holdouts).to eq([]) + expect(config_nil_holdouts.get_holdouts_for_rule('177770')).to eq([]) + end + end + end + + describe 'DecisionService with Local Holdouts' do + let(:config) do + Optimizely::DatafileProjectConfig.new( + OptimizelySpec::CONFIG_BODY_WITH_LOCAL_HOLDOUTS_JSON, + spy_logger, + error_handler + ) + end + + let(:spy_cmab_service) { spy('cmab_service') } + let(:spy_user_profile_service) { spy('user_profile_service') } + let(:decision_service) do + Optimizely::DecisionService.new(spy_logger, spy_cmab_service, spy_user_profile_service) + end + + let(:project) do + Optimizely::Project.new( + datafile: OptimizelySpec::CONFIG_BODY_WITH_LOCAL_HOLDOUTS_JSON, + logger: spy_logger, + error_handler: error_handler + ) + end + + after(:example) do + project&.close + end + + describe 'global holdout evaluation at flag level' do + it 'should check global holdouts before experiment rules' do + user_context = project.create_user_context('global_user', {}) + + # Mock bucketer to always bucket into global holdout + allow_any_instance_of(Optimizely::Bucketer).to receive(:bucket) do |_instance, _config, experiment, _bucketing_id, _user_id| + if experiment['id'] == 'global_holdout_nil_rules' + [experiment['variations'].first, []] + else + [nil, []] + end + end + + feature_flag = config.feature_flag_key_map.values.first + result = decision_service.get_decision_for_flag( + feature_flag, + user_context, + config, + [] + ) + + # Should be bucketed into global holdout + expect(result.decision).not_to be_nil + expect(result.decision.source).to eq(Optimizely::DecisionService::DECISION_SOURCES['HOLDOUT']) + end + end + + describe 'local holdout evaluation at rule level' do + it 'should check local holdouts for specific rule before audience evaluation' do + user_context = project.create_user_context('local_user', {}) + + # Find experiment rule that has local holdout + experiment = config.experiment_id_map['177770'] + expect(experiment).not_to be_nil + + # Mock bucketer to always bucket into local holdout + allow_any_instance_of(Optimizely::Bucketer).to receive(:bucket) do |_instance, _config, exp, _bucketing_id, _user_id| + if exp['id'] == 'local_holdout_single_rule' || exp['id'] == 'local_holdout_multiple_rules' + [exp['variations'].first, []] + else + [nil, []] + end + end + + result = decision_service.get_variation_from_experiment_rule( + config, + experiment['featureIds']&.first || 'unknown', + experiment, + user_context, + nil, + [] + ) + + # Should be bucketed into local holdout (result has holdout variation) + expect(result).not_to be_nil + expect(result.reasons).to include(match(/local holdout/i)) + end + + it 'should skip rule evaluation when user is in local holdout' do + user_context = project.create_user_context('skip_user', {}) + + experiment = config.experiment_id_map['177770'] + + # Mock bucketer to bucket into local holdout + allow_any_instance_of(Optimizely::Bucketer).to receive(:bucket) do |_instance, _config, exp, _bucketing_id, _user_id| + if exp['id'] == 'local_holdout_single_rule' + [exp['variations'].first, []] + else + [nil, []] + end + end + + result = decision_service.get_variation_from_experiment_rule( + config, + experiment['featureIds']&.first || 'unknown', + experiment, + user_context, + nil, + [] + ) + + # Verify holdout decision was returned + expect(result).not_to be_nil + # Rule variation should not be evaluated (we return holdout instead) + expect(result.reasons).to include(match(/local holdout/i)) + end + + it 'should handle multiple local holdouts for same rule' do + rule_id = '177770' + holdouts = config.get_holdouts_for_rule(rule_id) + expect(holdouts.length).to eq(2) + + # Verify both holdouts are checked in order + user_context = project.create_user_context('multi_holdout_user', {}) + experiment = config.experiment_id_map[rule_id] + + # First holdout should be checked first + allow_any_instance_of(Optimizely::Bucketer).to receive(:bucket) do |_instance, _config, exp, _bucketing_id, _user_id| + if exp['id'] == 'local_holdout_single_rule' + [exp['variations'].first, []] + else + [nil, []] + end + end + + result = decision_service.get_variation_from_experiment_rule( + config, + experiment['featureIds']&.first || 'unknown', + experiment, + user_context, + nil, + [] + ) + + expect(result.reasons).to include(match(/local holdout/i)) + end + end + + describe 'precedence: global before local' do + it 'should check global holdouts at flag level before local holdouts at rule level' do + # This is implicit in the decision flow: + # 1. get_decision_for_flag checks global holdouts first + # 2. Then checks experiments (which check local holdouts per rule) + # 3. Then checks rollouts (which also check local holdouts per rule) + + user_context = project.create_user_context('precedence_user', {}) + + allow_any_instance_of(Optimizely::Bucketer).to receive(:bucket) do |_instance, _config, exp, _bucketing_id, _user_id| + # Bucket into global holdout + if exp['id'] == 'global_holdout_nil_rules' + [exp['variations'].first, []] + else + [nil, []] + end + end + + feature_flag = config.feature_flag_key_map.values.first + result = decision_service.get_decision_for_flag( + feature_flag, + user_context, + config, + [] + ) + + # Should stop at global holdout, never reaching local holdout evaluation + expect(result.decision).not_to be_nil + expect(result.decision.source).to eq(Optimizely::DecisionService::DECISION_SOURCES['HOLDOUT']) + expect(result.reasons).to include(match(/global holdout/i)) + end + end + + describe 'edge cases' do + it 'should handle non-existent rule ID in local holdout gracefully' do + # Config has holdout with rule ID '99999999' which doesn't exist + holdouts = config.get_holdouts_for_rule('99999999') + expect(holdouts.length).to eq(1) + # Should not crash when evaluating + end + + it 'should handle local holdout with empty array (no rules)' do + empty_holdout = config.holdouts.find { |h| h['id'] == 'local_holdout_empty_array' } + expect(empty_holdout['includedRules']).to eq([]) + + # Should not be in any rule's holdouts + config.rule_holdouts_map.each do |_rule_id, holdouts| + expect(holdouts).not_to include(empty_holdout) + end + end + end + end +end diff --git a/spec/spec_params.rb b/spec/spec_params.rb index 6d7ff5c3..0e73ba0e 100644 --- a/spec/spec_params.rb +++ b/spec/spec_params.rb @@ -2051,6 +2051,145 @@ module OptimizelySpec CONFIG_BODY_WITH_HOLDOUTS_JSON = JSON.dump(CONFIG_BODY_WITH_HOLDOUTS).freeze + # Local Holdouts test datafile + CONFIG_BODY_WITH_LOCAL_HOLDOUTS = VALID_CONFIG_BODY.merge( + { + 'holdouts' => [ + { + 'id' => 'local_holdout_single_rule', + 'key' => 'local_holdout_single', + 'status' => 'Running', + 'audiences' => [], + 'includedRules' => ['177770'], # Single rule from feature flag + 'variations' => [ + { + 'id' => 'local_var_1', + 'key' => 'control', + 'featureEnabled' => true + }, + { + 'id' => 'local_var_2', + 'key' => 'treatment', + 'featureEnabled' => true + } + ], + 'trafficAllocation' => [ + { + 'entityId' => 'local_var_1', + 'endOfRange' => 5000 + }, + { + 'entityId' => 'local_var_2', + 'endOfRange' => 10_000 + } + ] + }, + { + 'id' => 'local_holdout_multiple_rules', + 'key' => 'local_holdout_multi', + 'status' => 'Running', + 'audiences' => [], + 'includedRules' => ['177770', '177774'], # Multiple rules + 'variations' => [ + { + 'id' => 'local_var_3', + 'key' => 'holdout_variation', + 'featureEnabled' => false + } + ], + 'trafficAllocation' => [ + { + 'entityId' => 'local_var_3', + 'endOfRange' => 10_000 + } + ] + }, + { + 'id' => 'global_holdout_nil_rules', + 'key' => 'global_holdout', + 'status' => 'Running', + 'audiences' => [], + 'includedRules' => nil, # Global holdout (nil means all rules) + 'variations' => [ + { + 'id' => 'global_var_1', + 'key' => 'global_control', + 'featureEnabled' => true + } + ], + 'trafficAllocation' => [ + { + 'entityId' => 'global_var_1', + 'endOfRange' => 10_000 + } + ] + }, + { + 'id' => 'local_holdout_empty_array', + 'key' => 'local_holdout_empty', + 'status' => 'Running', + 'audiences' => [], + 'includedRules' => [], # Local holdout with empty array + 'variations' => [ + { + 'id' => 'empty_var_1', + 'key' => 'empty_control', + 'featureEnabled' => false + } + ], + 'trafficAllocation' => [ + { + 'entityId' => 'empty_var_1', + 'endOfRange' => 10_000 + } + ] + }, + { + 'id' => 'local_holdout_nonexistent_rule', + 'key' => 'local_holdout_bad_rule', + 'status' => 'Running', + 'audiences' => [], + 'includedRules' => ['99999999'], # Non-existent rule ID + 'variations' => [ + { + 'id' => 'bad_var_1', + 'key' => 'bad_control', + 'featureEnabled' => false + } + ], + 'trafficAllocation' => [ + { + 'entityId' => 'bad_var_1', + 'endOfRange' => 10_000 + } + ] + }, + { + 'id' => 'inactive_local_holdout', + 'key' => 'inactive_local', + 'status' => 'Inactive', + 'audiences' => [], + 'includedRules' => ['177770'], + 'variations' => [ + { + 'id' => 'inactive_var_1', + 'key' => 'inactive_control', + 'featureEnabled' => false + } + ], + 'trafficAllocation' => [ + { + 'entityId' => 'inactive_var_1', + 'endOfRange' => 10_000 + } + ] + } + ] + } + ).freeze + + CONFIG_BODY_WITH_LOCAL_HOLDOUTS_JSON = JSON.dump(CONFIG_BODY_WITH_LOCAL_HOLDOUTS).freeze + def self.deep_clone(obj) obj.dup.tap do |new_obj| case new_obj