@@ -1708,3 +1708,285 @@ def test_no_holdouts_at_all_falls_through_to_experiment(self):
17081708 self .assertIsNotNone (result )
17091709 decision = result ['decision' ]
17101710 self .assertNotEqual (decision .source , enums .DecisionSources .HOLDOUT )
1711+
1712+
1713+ # ---------------------------------------------------------------------------
1714+ # Exclude Targeted Deliveries Tests
1715+ # ---------------------------------------------------------------------------
1716+
1717+ def _holdout_with_etd (holdout_id , key , exclude_targeted_deliveries = False ,
1718+ included_rules = None , traffic = None , status = 'Running' ):
1719+ """Build a holdout dict with excludeTargetedDeliveries field."""
1720+ h = {
1721+ 'id' : holdout_id ,
1722+ 'key' : key ,
1723+ 'status' : status ,
1724+ 'audienceIds' : [],
1725+ 'variations' : _HOLDOUT_VARIATION ,
1726+ 'trafficAllocation' : traffic or _FULL_TRAFFIC ,
1727+ 'excludeTargetedDeliveries' : exclude_targeted_deliveries ,
1728+ }
1729+ if included_rules is not None :
1730+ h ['includedRules' ] = included_rules
1731+ return h
1732+
1733+
1734+ class ExcludeTargetedDeliveriesTest (base .BaseTest ):
1735+ """Tests for exclude_targeted_deliveries behavior on holdouts.
1736+
1737+ When a holdout has exclude_targeted_deliveries=True:
1738+ - TD (targeted delivery) experiments should bypass the holdout
1739+ - A/B and other experiment types should still be blocked by the holdout
1740+ - Delivery (rollout) rules should bypass local holdouts with this flag
1741+ """
1742+
1743+ def setUp (self ):
1744+ base .BaseTest .setUp (self )
1745+ self .error_handler = error_handler .NoOpErrorHandler ()
1746+ self .spy_logger = mock .MagicMock (spec = logger .SimpleLogger )
1747+ self .spy_logger .logger = self .spy_logger
1748+ self .spy_user_profile_service = mock .MagicMock ()
1749+ self .spy_cmab_service = mock .MagicMock ()
1750+
1751+ def tearDown (self ):
1752+ if hasattr (self , 'opt_obj' ):
1753+ self .opt_obj .close ()
1754+
1755+ def _make_opt_with_td (self , holdouts , local_holdouts = None , experiment_type = None ):
1756+ """Build Optimizely instance, optionally setting type on experiment '111127'."""
1757+ cfg = self .config_dict_with_features .copy ()
1758+
1759+ if experiment_type is not None :
1760+ experiments = []
1761+ for exp in cfg ['experiments' ]:
1762+ exp_copy = exp .copy ()
1763+ if exp_copy ['id' ] == '111127' :
1764+ exp_copy ['type' ] = experiment_type
1765+ experiments .append (exp_copy )
1766+ cfg ['experiments' ] = experiments
1767+
1768+ if local_holdouts is None :
1769+ globals_list : list = []
1770+ locals_list : list = []
1771+ for h in holdouts :
1772+ if h .get ('includedRules' ) is not None :
1773+ locals_list .append (h )
1774+ else :
1775+ globals_list .append (h )
1776+ cfg ['holdouts' ] = globals_list
1777+ if locals_list :
1778+ cfg ['localHoldouts' ] = locals_list
1779+ else :
1780+ cfg ['holdouts' ] = holdouts
1781+ cfg ['localHoldouts' ] = local_holdouts
1782+
1783+ self .opt_obj = optimizely_module .Optimizely (json .dumps (cfg ))
1784+ return self .opt_obj
1785+
1786+ def _decision_svc (self ):
1787+ return decision_service .DecisionService (
1788+ self .spy_logger ,
1789+ self .spy_user_profile_service ,
1790+ self .spy_cmab_service ,
1791+ )
1792+
1793+ # ------------------------------------------------------------------
1794+ # Test 1: Global holdout with exclude_targeted_deliveries=False (default)
1795+ # ------------------------------------------------------------------
1796+
1797+ def test_global_holdout_exclude_td_false_blocks_all_rules (self ):
1798+ """Global holdout with exclude_targeted_deliveries=False blocks all rules as before."""
1799+ opt = self ._make_opt_with_td (
1800+ [_holdout_with_etd ('gh1' , 'global_no_exclude' , exclude_targeted_deliveries = False )],
1801+ experiment_type = 'td' ,
1802+ )
1803+ config = opt .config_manager .get_config ()
1804+ feature_flag = config .get_feature_from_key ('test_feature_in_experiment' )
1805+
1806+ ds = self ._decision_svc ()
1807+
1808+ with mock .patch .object (ds , 'get_variation' , wraps = ds .get_variation ) as mock_get_var :
1809+ user_ctx = opt .create_user_context ('user_blocked' , {})
1810+ result = ds .get_decision_for_flag (feature_flag , user_ctx , config )
1811+
1812+ decision = result ['decision' ]
1813+ self .assertEqual (decision .source , enums .DecisionSources .HOLDOUT )
1814+ mock_get_var .assert_not_called ()
1815+
1816+ # ------------------------------------------------------------------
1817+ # Test 2: Global holdout with exclude_targeted_deliveries=True, TD experiment
1818+ # ------------------------------------------------------------------
1819+
1820+ def test_global_holdout_exclude_td_true_allows_td_experiment (self ):
1821+ """Global holdout with exclude_targeted_deliveries=True allows TD rules through."""
1822+ opt = self ._make_opt_with_td (
1823+ [_holdout_with_etd ('gh1' , 'global_exclude_td' , exclude_targeted_deliveries = True )],
1824+ experiment_type = 'td' ,
1825+ )
1826+ config = opt .config_manager .get_config ()
1827+ feature_flag = config .get_feature_from_key ('test_feature_in_experiment' )
1828+
1829+ ds = self ._decision_svc ()
1830+ user_ctx = opt .create_user_context ('testUserId' , {})
1831+ result = ds .get_decision_for_flag (feature_flag , user_ctx , config )
1832+
1833+ decision = result ['decision' ]
1834+ # TD experiment should be evaluated, not blocked by holdout
1835+ self .assertNotEqual (decision .source , enums .DecisionSources .HOLDOUT )
1836+
1837+ # ------------------------------------------------------------------
1838+ # Test 3: Global holdout with exclude_targeted_deliveries=True, A/B experiment
1839+ # ------------------------------------------------------------------
1840+
1841+ def test_global_holdout_exclude_td_true_blocks_ab_experiment (self ):
1842+ """Global holdout with exclude_targeted_deliveries=True still blocks A/B rules."""
1843+ opt = self ._make_opt_with_td (
1844+ [_holdout_with_etd ('gh1' , 'global_exclude_td' , exclude_targeted_deliveries = True )],
1845+ experiment_type = 'ab' ,
1846+ )
1847+ config = opt .config_manager .get_config ()
1848+ feature_flag = config .get_feature_from_key ('test_feature_in_experiment' )
1849+
1850+ ds = self ._decision_svc ()
1851+
1852+ with mock .patch .object (ds , 'get_variation' , wraps = ds .get_variation ) as mock_get_var :
1853+ user_ctx = opt .create_user_context ('user_blocked_ab' , {})
1854+ result = ds .get_decision_for_flag (feature_flag , user_ctx , config )
1855+
1856+ decision = result ['decision' ]
1857+ # A/B experiment should be skipped; user stays in holdout
1858+ self .assertEqual (decision .source , enums .DecisionSources .HOLDOUT )
1859+ mock_get_var .assert_not_called ()
1860+
1861+ # ------------------------------------------------------------------
1862+ # Test 4: Global holdout with exclude_targeted_deliveries=True, no TD matches
1863+ # ------------------------------------------------------------------
1864+
1865+ def test_global_holdout_exclude_td_true_no_td_falls_back_to_holdout (self ):
1866+ """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+ opt = self ._make_opt_with_td (
1870+ [_holdout_with_etd ('gh1' , 'global_exclude_td' , exclude_targeted_deliveries = True )],
1871+ )
1872+ config = opt .config_manager .get_config ()
1873+ feature_flag = config .get_feature_from_key ('test_feature_in_experiment' )
1874+
1875+ ds = self ._decision_svc ()
1876+ user_ctx = opt .create_user_context ('user_no_td' , {})
1877+ result = ds .get_decision_for_flag (feature_flag , user_ctx , config )
1878+
1879+ decision = result ['decision' ]
1880+ # No TD experiment available, so holdout decision should be returned
1881+ self .assertEqual (decision .source , enums .DecisionSources .HOLDOUT )
1882+
1883+ # ------------------------------------------------------------------
1884+ # Test 5: Local holdout on delivery rule with exclude_targeted_deliveries=True
1885+ # ------------------------------------------------------------------
1886+
1887+ def test_local_holdout_delivery_rule_exclude_td_true_skips_holdout (self ):
1888+ """Local holdout on delivery rule with exclude_targeted_deliveries=True
1889+ skips the holdout and evaluates the delivery rule."""
1890+ delivery_rule_id = '211147'
1891+ opt = self ._make_opt_with_td (
1892+ [_holdout_with_etd (
1893+ 'lh1' , 'local_delivery_exclude' ,
1894+ exclude_targeted_deliveries = True ,
1895+ included_rules = [delivery_rule_id ],
1896+ )],
1897+ )
1898+ config = opt .config_manager .get_config ()
1899+ feature_flag = config .get_feature_from_key ('test_feature_in_rollout' )
1900+
1901+ ds = self ._decision_svc ()
1902+ user_ctx = opt .create_user_context ('user_delivery_exclude' , {})
1903+ result = ds .get_decision_for_flag (feature_flag , user_ctx , config )
1904+
1905+ decision = result ['decision' ]
1906+ # Delivery rules are targeted deliveries, so holdout should be skipped
1907+ self .assertNotEqual (decision .source , enums .DecisionSources .HOLDOUT )
1908+
1909+ # ------------------------------------------------------------------
1910+ # Test 6: Local holdout on experiment rule (TD type) with exclude_targeted_deliveries=True
1911+ # ------------------------------------------------------------------
1912+
1913+ def test_local_holdout_td_experiment_exclude_td_true_skips_holdout (self ):
1914+ """Local holdout on TD experiment with exclude_targeted_deliveries=True
1915+ skips the holdout."""
1916+ experiment_rule_id = '111127'
1917+ opt = self ._make_opt_with_td (
1918+ [_holdout_with_etd (
1919+ 'lh1' , 'local_td_exclude' ,
1920+ exclude_targeted_deliveries = True ,
1921+ included_rules = [experiment_rule_id ],
1922+ )],
1923+ experiment_type = 'td' ,
1924+ )
1925+ config = opt .config_manager .get_config ()
1926+ feature_flag = config .get_feature_from_key ('test_feature_in_experiment' )
1927+
1928+ ds = self ._decision_svc ()
1929+ user_ctx = opt .create_user_context ('testUserId' , {})
1930+ result = ds .get_decision_for_flag (feature_flag , user_ctx , config )
1931+
1932+ decision = result ['decision' ]
1933+ # TD experiment should bypass the local holdout
1934+ self .assertNotEqual (decision .source , enums .DecisionSources .HOLDOUT )
1935+
1936+ # ------------------------------------------------------------------
1937+ # Test 7: Local holdout on experiment rule (A/B type) with exclude_targeted_deliveries=True
1938+ # ------------------------------------------------------------------
1939+
1940+ def test_local_holdout_ab_experiment_exclude_td_true_still_applies (self ):
1941+ """Local holdout on A/B experiment with exclude_targeted_deliveries=True
1942+ still applies the holdout (A/B is not a targeted delivery)."""
1943+ experiment_rule_id = '111127'
1944+ opt = self ._make_opt_with_td (
1945+ [_holdout_with_etd (
1946+ 'lh1' , 'local_ab_exclude' ,
1947+ exclude_targeted_deliveries = True ,
1948+ included_rules = [experiment_rule_id ],
1949+ )],
1950+ experiment_type = 'ab' ,
1951+ )
1952+ config = opt .config_manager .get_config ()
1953+ feature_flag = config .get_feature_from_key ('test_feature_in_experiment' )
1954+
1955+ ds = self ._decision_svc ()
1956+
1957+ with mock .patch .object (ds , 'get_variation' , wraps = ds .get_variation ) as mock_get_var :
1958+ user_ctx = opt .create_user_context ('user_ab_holdout' , {})
1959+ result = ds .get_decision_for_flag (feature_flag , user_ctx , config )
1960+
1961+ decision = result ['decision' ]
1962+ # A/B experiment should still be blocked by local holdout
1963+ self .assertEqual (decision .source , enums .DecisionSources .HOLDOUT )
1964+ mock_get_var .assert_not_called ()
1965+
1966+ # ------------------------------------------------------------------
1967+ # Test 8: Missing field (backward compatibility)
1968+ # ------------------------------------------------------------------
1969+
1970+ def test_missing_exclude_td_field_defaults_to_false (self ):
1971+ """When excludeTargetedDeliveries is not in the datafile, defaults to False
1972+ and holdout works as before (blocks all rules)."""
1973+ # Use _holdout helper which does NOT set excludeTargetedDeliveries
1974+ opt = self ._make_opt_with_td (
1975+ [_holdout ('gh1' , 'legacy_holdout' , traffic = _FULL_TRAFFIC )],
1976+ experiment_type = 'td' ,
1977+ )
1978+ config = opt .config_manager .get_config ()
1979+ feature_flag = config .get_feature_from_key ('test_feature_in_experiment' )
1980+
1981+ ds = self ._decision_svc ()
1982+ user_ctx = opt .create_user_context ('user_legacy' , {})
1983+ result = ds .get_decision_for_flag (feature_flag , user_ctx , config )
1984+
1985+ decision = result ['decision' ]
1986+ # Without the field, holdout should block all rules including TD
1987+ self .assertEqual (decision .source , enums .DecisionSources .HOLDOUT )
1988+
1989+ # Verify entity defaults
1990+ holdout = config .holdouts [0 ] if config .holdouts else None
1991+ self .assertIsNotNone (holdout )
1992+ self .assertFalse (holdout .exclude_targeted_deliveries )
0 commit comments