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