@@ -2879,4 +2879,225 @@ describe('DecisionService', () => {
28792879 expect ( variation ) . toBe ( null ) ;
28802880 } ) ;
28812881 } ) ;
2882+
2883+ describe ( 'local holdouts' , ( ) => {
2884+ // Helper: build a datafile that has a local holdout targeting a specific experiment or delivery rule.
2885+ const makeLocalHoldoutDatafile = ( targetRuleId : string , ruleIds : string [ ] = [ targetRuleId ] ) => {
2886+ const datafile = getDecisionTestDatafile ( ) ;
2887+ ( datafile as any ) . holdouts = [
2888+ {
2889+ id : 'local_holdout_id' ,
2890+ key : 'local_holdout' ,
2891+ status : 'Running' ,
2892+ includedFlags : [ ] ,
2893+ excludedFlags : [ ] ,
2894+ includedRules : ruleIds ,
2895+ audienceIds : [ ] ,
2896+ audienceConditions : [ ] ,
2897+ variations : [
2898+ {
2899+ id : 'local_holdout_variation_id' ,
2900+ key : 'local_holdout_variation' ,
2901+ variables : [ ]
2902+ }
2903+ ] ,
2904+ trafficAllocation : [
2905+ { entityId : 'local_holdout_variation_id' , endOfRange : 10000 }
2906+ ]
2907+ }
2908+ ] ;
2909+ return datafile ;
2910+ } ;
2911+
2912+ beforeEach ( ( ) => {
2913+ mockBucket . mockReset ( ) ;
2914+ } ) ;
2915+
2916+ it ( 'global holdout branch: global holdout is evaluated before per-rule logic' , async ( ) => {
2917+ const datafile = getDecisionTestDatafile ( ) ;
2918+ ( datafile as any ) . holdouts = [
2919+ {
2920+ id : 'global_holdout_id' ,
2921+ key : 'global_holdout' ,
2922+ status : 'Running' ,
2923+ includedFlags : [ ] ,
2924+ excludedFlags : [ ] ,
2925+ // No includedRules → global holdout
2926+ audienceIds : [ ] ,
2927+ audienceConditions : [ ] ,
2928+ variations : [
2929+ { id : 'global_holdout_var_id' , key : 'global_holdout_var' , variables : [ ] }
2930+ ] ,
2931+ trafficAllocation : [ { entityId : 'global_holdout_var_id' , endOfRange : 10000 } ]
2932+ }
2933+ ] ;
2934+ const config = createProjectConfig ( datafile ) ;
2935+ const { decisionService } = getDecisionService ( ) ;
2936+
2937+ // bucket returns the global holdout variation for the holdout, nothing for experiments
2938+ mockBucket . mockImplementation ( ( params : BucketerParams ) => {
2939+ if ( params . experimentId === 'global_holdout_id' ) {
2940+ return { result : 'global_holdout_var_id' , reasons : [ ] } ;
2941+ }
2942+ return { result : null , reasons : [ ] } ;
2943+ } ) ;
2944+
2945+ const user = new OptimizelyUserContext ( { optimizely : { } as any , userId : 'user1' } ) ;
2946+ const feature = config . featureKeyMap [ 'flag_1' ] ;
2947+ const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
2948+
2949+ // Decision should be from the global holdout, not from any experiment
2950+ expect ( value [ 0 ] . result . decisionSource ) . toBe ( DECISION_SOURCES . HOLDOUT ) ;
2951+ expect ( value [ 0 ] . result . experiment ?. id ) . toBe ( 'global_holdout_id' ) ;
2952+ } ) ;
2953+
2954+ it ( 'local holdout hit branch: user bucketed into local holdout for experiment rule returns holdout variation; audience and traffic not evaluated for that rule' , async ( ) => {
2955+ // exp_1 has id '2001'
2956+ const config = createProjectConfig ( makeLocalHoldoutDatafile ( '2001' ) ) ;
2957+ const { decisionService } = getDecisionService ( ) ;
2958+
2959+ // bucket returns holdout variation when evaluating the local holdout
2960+ mockBucket . mockImplementation ( ( params : BucketerParams ) => {
2961+ if ( params . experimentId === 'local_holdout_id' ) {
2962+ return { result : 'local_holdout_variation_id' , reasons : [ ] } ;
2963+ }
2964+ return { result : null , reasons : [ ] } ;
2965+ } ) ;
2966+
2967+ const user = new OptimizelyUserContext ( { optimizely : { } as any , userId : 'user1' } ) ;
2968+ const feature = config . featureKeyMap [ 'flag_1' ] ;
2969+ const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
2970+
2971+ // Should return holdout decision for the local holdout
2972+ expect ( value [ 0 ] . result . decisionSource ) . toBe ( DECISION_SOURCES . HOLDOUT ) ;
2973+ expect ( value [ 0 ] . result . experiment ?. id ) . toBe ( 'local_holdout_id' ) ;
2974+ expect ( value [ 0 ] . result . variation ?. id ) . toBe ( 'local_holdout_variation_id' ) ;
2975+ } ) ;
2976+
2977+ it ( 'local holdout miss branch: user not bucketed into local holdout falls through to regular rule evaluation' , async ( ) => {
2978+ // exp_1 has id '2001' and audience 4001 (age <= 22)
2979+ const config = createProjectConfig ( makeLocalHoldoutDatafile ( '2001' ) ) ;
2980+ const { decisionService } = getDecisionService ( ) ;
2981+
2982+ // bucket returns null for the local holdout, then succeeds for the experiment
2983+ mockBucket . mockImplementation ( ( params : BucketerParams ) => {
2984+ if ( params . experimentId === 'local_holdout_id' ) {
2985+ return { result : null , reasons : [ ] } ;
2986+ }
2987+ if ( params . experimentId === '2001' ) {
2988+ return { result : '5001' , reasons : [ ] } ; // variation_1 in exp_1
2989+ }
2990+ return { result : null , reasons : [ ] } ;
2991+ } ) ;
2992+
2993+ const user = new OptimizelyUserContext ( {
2994+ optimizely : { } as any ,
2995+ userId : 'user1' ,
2996+ attributes : { age : 15 } , // satisfies 4001 audience (age <= 22)
2997+ } ) ;
2998+ const feature = config . featureKeyMap [ 'flag_1' ] ;
2999+ const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
3000+
3001+ // Should fall through to experiment evaluation (not holdout)
3002+ expect ( value [ 0 ] . result . decisionSource ) . toBe ( DECISION_SOURCES . FEATURE_TEST ) ;
3003+ expect ( value [ 0 ] . result . variation ?. id ) . toBe ( '5001' ) ;
3004+ } ) ;
3005+
3006+ it ( 'rule specificity: local holdout targeting experiment rule X does not affect experiment rule Y' , async ( ) => {
3007+ // exp_1 = '2001', exp_2 = '2002'. Local holdout targets only '2002' (exp_2).
3008+ // Audience for exp_1: 4001 (age <= 22). User satisfies exp_1 audience but not exp_2.
3009+ const config = createProjectConfig ( makeLocalHoldoutDatafile ( '2002' ) ) ;
3010+ const { decisionService } = getDecisionService ( ) ;
3011+
3012+ // bucket returns holdout variation only for the local holdout when evaluating for '2002',
3013+ // and returns experiment variation for '2001'
3014+ mockBucket . mockImplementation ( ( params : BucketerParams ) => {
3015+ if ( params . experimentId === 'local_holdout_id' ) {
3016+ return { result : 'local_holdout_variation_id' , reasons : [ ] } ;
3017+ }
3018+ if ( params . experimentId === '2001' ) {
3019+ return { result : '5001' , reasons : [ ] } ;
3020+ }
3021+ return { result : null , reasons : [ ] } ;
3022+ } ) ;
3023+
3024+ // User satisfies exp_1 audience (age <= 22)
3025+ const user = new OptimizelyUserContext ( {
3026+ optimizely : { } as any ,
3027+ userId : 'user1' ,
3028+ attributes : { age : 15 } ,
3029+ } ) ;
3030+ const feature = config . featureKeyMap [ 'flag_1' ] ;
3031+ const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
3032+
3033+ // exp_1 is evaluated first; local holdout targets '2002' not '2001', so exp_1 is evaluated normally
3034+ expect ( value [ 0 ] . result . decisionSource ) . toBe ( DECISION_SOURCES . FEATURE_TEST ) ;
3035+ expect ( value [ 0 ] . result . experiment ?. id ) . toBe ( '2001' ) ;
3036+ } ) ;
3037+
3038+ it ( 'local holdout applies to delivery rules (rollouts) as well as experiment rules' , async ( ) => {
3039+ // delivery_1 has id '3001'
3040+ const config = createProjectConfig ( makeLocalHoldoutDatafile ( '3001' ) ) ;
3041+ const { decisionService } = getDecisionService ( ) ;
3042+
3043+ // bucket returns null for all experiments and the local holdout variation for delivery rule
3044+ mockBucket . mockImplementation ( ( params : BucketerParams ) => {
3045+ if ( params . experimentId === 'local_holdout_id' ) {
3046+ return { result : 'local_holdout_variation_id' , reasons : [ ] } ;
3047+ }
3048+ return { result : null , reasons : [ ] } ;
3049+ } ) ;
3050+
3051+ // No audience attributes → experiments won't match, falls through to rollout
3052+ const user = new OptimizelyUserContext ( {
3053+ optimizely : { } as any ,
3054+ userId : 'user1' ,
3055+ attributes : { age : 15 } , // satisfies 4001 used by delivery_1
3056+ } ) ;
3057+ const feature = config . featureKeyMap [ 'flag_1' ] ;
3058+ const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
3059+
3060+ // Should be a holdout decision from the local holdout targeting the delivery rule
3061+ expect ( value [ 0 ] . result . decisionSource ) . toBe ( DECISION_SOURCES . HOLDOUT ) ;
3062+ expect ( value [ 0 ] . result . experiment ?. id ) . toBe ( 'local_holdout_id' ) ;
3063+ } ) ;
3064+
3065+ it ( 'forced decision beats a 100% traffic local holdout: forced decision takes precedence over local holdout' , async ( ) => {
3066+ // exp_1 has id '2001', key 'exp_1', variation key 'variation_1' (id '5001')
3067+ // Local holdout targets '2001' with 100% traffic allocation.
3068+ // User also has a forced decision set for exp_1.
3069+ // Expected: forced decision wins; decisionSource is FEATURE_TEST, not HOLDOUT.
3070+ const config = createProjectConfig ( makeLocalHoldoutDatafile ( '2001' ) ) ;
3071+ const { decisionService } = getDecisionService ( ) ;
3072+
3073+ // bucket should NOT be called for local_holdout_id because forced decision short-circuits first
3074+ mockBucket . mockImplementation ( ( params : BucketerParams ) => {
3075+ if ( params . experimentId === 'local_holdout_id' ) {
3076+ // returning holdout variation here to prove the test fails if ordering is wrong
3077+ return { result : 'local_holdout_variation_id' , reasons : [ ] } ;
3078+ }
3079+ return { result : null , reasons : [ ] } ;
3080+ } ) ;
3081+
3082+ const user = new OptimizelyUserContext ( {
3083+ optimizely : { } as any ,
3084+ userId : 'user1' ,
3085+ attributes : { age : 15 } ,
3086+ } ) ;
3087+
3088+ // Set forced decision for exp_1 → variation_1
3089+ user . setForcedDecision (
3090+ { flagKey : 'flag_1' , ruleKey : 'exp_1' } ,
3091+ { variationKey : 'variation_1' }
3092+ ) ;
3093+
3094+ const feature = config . featureKeyMap [ 'flag_1' ] ;
3095+ const value = await decisionService . resolveVariationsForFeatureList ( 'async' , config , [ feature ] , user , { } ) . get ( ) ;
3096+
3097+ // Forced decision must win — source must be FEATURE_TEST, not HOLDOUT
3098+ expect ( value [ 0 ] . result . decisionSource ) . toBe ( DECISION_SOURCES . FEATURE_TEST ) ;
3099+ expect ( value [ 0 ] . result . variation ?. key ) . toBe ( 'variation_1' ) ;
3100+ } ) ;
3101+ } ) ;
28823102} ) ;
3103+
0 commit comments