@@ -1839,7 +1839,9 @@ def test_global_holdout_exclude_td_true_allows_td_experiment(self):
18391839 # ------------------------------------------------------------------
18401840
18411841 def test_global_holdout_exclude_td_true_blocks_ab_experiment (self ):
1842- """Global holdout with exclude_targeted_deliveries=True still blocks A/B rules."""
1842+ """Global holdout with exclude_targeted_deliveries=True still blocks A/B rules.
1843+ The A/B experiment is skipped and a non-holdout decision is returned,
1844+ with the holdout decision attached separately."""
18431845 opt = self ._make_opt_with_td (
18441846 [_holdout_with_etd ('gh1' , 'global_exclude_td' , exclude_targeted_deliveries = True )],
18451847 experiment_type = 'ab' ,
@@ -1854,18 +1856,17 @@ def test_global_holdout_exclude_td_true_blocks_ab_experiment(self):
18541856 result = ds .get_decision_for_flag (feature_flag , user_ctx , config )
18551857
18561858 decision = result ['decision' ]
1857- # A/B experiment should be skipped; user stays in holdout
1858- self .assertEqual (decision .source , enums .DecisionSources .HOLDOUT )
1859+ self .assertNotEqual (decision .source , enums .DecisionSources .HOLDOUT )
18591860 mock_get_var .assert_not_called ()
1861+ self .assertIsNotNone (result .get ('holdout_decision' ))
18601862
18611863 # ------------------------------------------------------------------
18621864 # Test 4: Global holdout with exclude_targeted_deliveries=True, no TD matches
18631865 # ------------------------------------------------------------------
18641866
1865- def test_global_holdout_exclude_td_true_no_td_falls_back_to_holdout (self ):
1867+ def test_global_holdout_exclude_td_true_no_td_returns_non_holdout_decision (self ):
18661868 """When exclude_targeted_deliveries=True but no TD experiments match,
1867- falls back to holdout decision."""
1868- # No type set on experiment (defaults to None, not 'td')
1869+ returns a non-holdout decision with the bypassed holdout attached."""
18691870 opt = self ._make_opt_with_td (
18701871 [_holdout_with_etd ('gh1' , 'global_exclude_td' , exclude_targeted_deliveries = True )],
18711872 )
@@ -1877,16 +1878,16 @@ def test_global_holdout_exclude_td_true_no_td_falls_back_to_holdout(self):
18771878 result = ds .get_decision_for_flag (feature_flag , user_ctx , config )
18781879
18791880 decision = result ['decision' ]
1880- # No TD experiment available, so holdout decision should be returned
1881- self .assertEqual ( decision . source , enums . DecisionSources . HOLDOUT )
1881+ self . assertNotEqual ( decision . source , enums . DecisionSources . HOLDOUT )
1882+ self .assertIsNotNone ( result . get ( 'holdout_decision' ) )
18821883
18831884 # ------------------------------------------------------------------
18841885 # Test 5: Local holdout on delivery rule with exclude_targeted_deliveries=True
18851886 # ------------------------------------------------------------------
18861887
1887- def test_local_holdout_delivery_rule_exclude_td_true_skips_holdout (self ):
1888+ def test_local_holdout_delivery_rule_exclude_td_true_still_applies (self ):
18881889 """Local holdout on delivery rule with exclude_targeted_deliveries=True
1889- skips the holdout and evaluates the delivery rule ."""
1890+ still applies because local holdouts ignore that flag ."""
18901891 delivery_rule_id = '211147'
18911892 opt = self ._make_opt_with_td (
18921893 [_holdout_with_etd (
@@ -1903,16 +1904,15 @@ def test_local_holdout_delivery_rule_exclude_td_true_skips_holdout(self):
19031904 result = ds .get_decision_for_flag (feature_flag , user_ctx , config )
19041905
19051906 decision = result ['decision' ]
1906- # Delivery rules are targeted deliveries, so holdout should be skipped
1907- self .assertNotEqual (decision .source , enums .DecisionSources .HOLDOUT )
1907+ self .assertEqual (decision .source , enums .DecisionSources .HOLDOUT )
19081908
19091909 # ------------------------------------------------------------------
19101910 # Test 6: Local holdout on experiment rule (TD type) with exclude_targeted_deliveries=True
19111911 # ------------------------------------------------------------------
19121912
1913- def test_local_holdout_td_experiment_exclude_td_true_skips_holdout (self ):
1913+ def test_local_holdout_td_experiment_exclude_td_true_still_applies (self ):
19141914 """Local holdout on TD experiment with exclude_targeted_deliveries=True
1915- skips the holdout ."""
1915+ still applies because local holdouts ignore that flag ."""
19161916 experiment_rule_id = '111127'
19171917 opt = self ._make_opt_with_td (
19181918 [_holdout_with_etd (
@@ -1930,8 +1930,7 @@ def test_local_holdout_td_experiment_exclude_td_true_skips_holdout(self):
19301930 result = ds .get_decision_for_flag (feature_flag , user_ctx , config )
19311931
19321932 decision = result ['decision' ]
1933- # TD experiment should bypass the local holdout
1934- self .assertNotEqual (decision .source , enums .DecisionSources .HOLDOUT )
1933+ self .assertEqual (decision .source , enums .DecisionSources .HOLDOUT )
19351934
19361935 # ------------------------------------------------------------------
19371936 # Test 7: Local holdout on experiment rule (A/B type) with exclude_targeted_deliveries=True
@@ -1990,3 +1989,70 @@ def test_missing_exclude_td_field_defaults_to_false(self):
19901989 holdout = config .holdouts [0 ] if config .holdouts else None
19911990 self .assertIsNotNone (holdout )
19921991 self .assertFalse (holdout .exclude_targeted_deliveries )
1992+
1993+ # ------------------------------------------------------------------
1994+ # Test 9: TD match returns holdout_decision in result
1995+ # ------------------------------------------------------------------
1996+
1997+ def test_global_holdout_exclude_td_true_td_match_has_holdout_decision (self ):
1998+ """When exclude_targeted_deliveries=True and a TD experiment matches,
1999+ the result contains holdout_decision with the bypassed holdout."""
2000+ opt = self ._make_opt_with_td (
2001+ [_holdout_with_etd ('gh1' , 'global_exclude_td' , exclude_targeted_deliveries = True )],
2002+ experiment_type = 'td' ,
2003+ )
2004+ config = opt .config_manager .get_config ()
2005+ feature_flag = config .get_feature_from_key ('test_feature_in_experiment' )
2006+
2007+ ds = self ._decision_svc ()
2008+ user_ctx = opt .create_user_context ('testUserId' , {})
2009+ result = ds .get_decision_for_flag (feature_flag , user_ctx , config )
2010+
2011+ decision = result ['decision' ]
2012+ self .assertNotEqual (decision .source , enums .DecisionSources .HOLDOUT )
2013+ self .assertIsNotNone (result .get ('holdout_decision' ))
2014+ self .assertEqual (result ['holdout_decision' ].source , enums .DecisionSources .HOLDOUT )
2015+
2016+ # ------------------------------------------------------------------
2017+ # Test 10: No TD match returns non-holdout with holdout_decision attached
2018+ # ------------------------------------------------------------------
2019+
2020+ def test_global_holdout_exclude_td_true_no_td_has_holdout_decision (self ):
2021+ """When exclude_targeted_deliveries=True and no TD matches,
2022+ result is non-holdout but holdout_decision key is present."""
2023+ opt = self ._make_opt_with_td (
2024+ [_holdout_with_etd ('gh1' , 'global_exclude_td' , exclude_targeted_deliveries = True )],
2025+ )
2026+ config = opt .config_manager .get_config ()
2027+ feature_flag = config .get_feature_from_key ('test_feature_in_experiment' )
2028+
2029+ ds = self ._decision_svc ()
2030+ user_ctx = opt .create_user_context ('user_no_td' , {})
2031+ result = ds .get_decision_for_flag (feature_flag , user_ctx , config )
2032+
2033+ decision = result ['decision' ]
2034+ self .assertNotEqual (decision .source , enums .DecisionSources .HOLDOUT )
2035+ holdout_dec = result .get ('holdout_decision' )
2036+ self .assertIsNotNone (holdout_dec )
2037+ self .assertEqual (holdout_dec .source , enums .DecisionSources .HOLDOUT )
2038+
2039+ # ------------------------------------------------------------------
2040+ # Test 11: Holdout impression event dispatched for bypassed holdout
2041+ # ------------------------------------------------------------------
2042+
2043+ def test_holdout_impression_sent_when_td_evaluated (self ):
2044+ """When exclude_targeted_deliveries=True and TD matches,
2045+ two impression events are sent: one for holdout, one for TD."""
2046+ opt = self ._make_opt_with_td (
2047+ [_holdout_with_etd ('gh1' , 'global_exclude_td' , exclude_targeted_deliveries = True )],
2048+ experiment_type = 'td' ,
2049+ )
2050+ with mock .patch .object (opt , '_send_impression_event' ) as mock_send :
2051+ user_ctx = opt .create_user_context ('testUserId' , {})
2052+ user_ctx .decide ('test_feature_in_experiment' )
2053+
2054+ self .assertEqual (mock_send .call_count , 2 )
2055+ call_rule_types = [call .args [5 ] if len (call .args ) > 5 else call .kwargs .get ('rule_type' )
2056+ for call in mock_send .call_args_list ]
2057+ self .assertIn (str (enums .DecisionSources .HOLDOUT ), call_rule_types )
2058+ self .assertIn (str (enums .DecisionSources .FEATURE_TEST ), call_rule_types )
0 commit comments