@@ -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