@@ -2084,4 +2084,187 @@ OptimizelyDecision callDecideWithIncludeReasons(String flagKey) {
20842084 return callDecideWithIncludeReasons (flagKey , Collections .emptyMap ());
20852085 }
20862086
2087+ private Optimizely createOptimizelyWithHoldouts () throws Exception {
2088+ String holdoutDatafile = com .google .common .io .Resources .toString (
2089+ com .google .common .io .Resources .getResource ("config/holdouts-project-config.json" ),
2090+ com .google .common .base .Charsets .UTF_8
2091+ );
2092+ return new Optimizely .Builder ().withDatafile (holdoutDatafile ).withEventProcessor (new ForwardingEventProcessor (eventHandler , null )).build ();
2093+ }
2094+
2095+ @ Test
2096+ public void decisionNotification_with_holdout () throws Exception {
2097+ // Use holdouts datafile
2098+ Optimizely optWithHoldout = createOptimizelyWithHoldouts ();
2099+ String flagKey = "boolean_feature" ;
2100+ String userId = "user123" ;
2101+ String ruleKey = "basic_holdout" ; // holdout rule key
2102+ String variationKey = "ho_off_key" ; // holdout (off) variation key
2103+ String experimentId = "10075323428" ; // holdout experiment id in holdouts-project-config.json
2104+ String variationId = "$opt_dummy_variation_id" ;// dummy variation id used for holdout impressions
2105+ String expectedReason = "User (" + userId + ") is in variation (" + variationKey + ") of holdout (" + ruleKey + ")." ;
2106+
2107+ Map <String , Object > attrs = new HashMap <>();
2108+ attrs .put ("$opt_bucketing_id" , "ppid160000" ); // deterministic bucketing into basic_holdout
2109+ attrs .put ("nationality" , "English" ); // non-reserved attribute should appear in impression & notification
2110+
2111+ OptimizelyUserContext user = optWithHoldout .createUserContext (userId , attrs );
2112+
2113+ // Register notification handler similar to decisionNotification test
2114+ isListenerCalled = false ;
2115+ optWithHoldout .addDecisionNotificationHandler (decisionNotification -> {
2116+ Assert .assertEquals (NotificationCenter .DecisionNotificationType .FLAG .toString (), decisionNotification .getType ());
2117+ Assert .assertEquals (userId , decisionNotification .getUserId ());
2118+
2119+ Assert .assertEquals (attrs , decisionNotification .getAttributes ());
2120+
2121+ Map <String , ?> info = decisionNotification .getDecisionInfo ();
2122+ Assert .assertEquals (flagKey , info .get (FLAG_KEY ));
2123+ Assert .assertEquals (variationKey , info .get (VARIATION_KEY ));
2124+ Assert .assertEquals (false , info .get (ENABLED ));
2125+ Assert .assertEquals (ruleKey , info .get (RULE_KEY ));
2126+ Assert .assertEquals (experimentId , info .get (EXPERIMENT_ID ));
2127+ Assert .assertEquals (variationId , info .get (VARIATION_ID ));
2128+ // Variables should be empty because feature is disabled by holdout
2129+ Assert .assertTrue (((Map <?, ?>) info .get (VARIABLES )).isEmpty ());
2130+ // Event should be dispatched (no DISABLE_DECISION_EVENT option)
2131+ Assert .assertEquals (true , info .get (DECISION_EVENT_DISPATCHED ));
2132+
2133+ @ SuppressWarnings ("unchecked" )
2134+ List <String > reasons = (List <String >) info .get (REASONS );
2135+ Assert .assertTrue ("Expected holdout reason present" , reasons .contains (expectedReason ));
2136+ isListenerCalled = true ;
2137+ });
2138+
2139+ // Execute decision with INCLUDE_REASONS so holdout reason is present
2140+ OptimizelyDecision decision = user .decide (flagKey , Collections .singletonList (OptimizelyDecideOption .INCLUDE_REASONS ));
2141+ assertTrue (isListenerCalled );
2142+
2143+ // Sanity checks on returned decision
2144+ assertEquals (variationKey , decision .getVariationKey ());
2145+ assertFalse (decision .getEnabled ());
2146+ assertTrue (decision .getReasons ().contains (expectedReason ));
2147+
2148+ // Impression expectation (nationality only)
2149+ DecisionMetadata metadata = new DecisionMetadata .Builder ()
2150+ .setFlagKey (flagKey )
2151+ .setRuleKey (ruleKey )
2152+ .setRuleType ("holdout" )
2153+ .setVariationKey (variationKey )
2154+ .setEnabled (false )
2155+ .build ();
2156+ eventHandler .expectImpression (experimentId , variationId , userId , Collections .singletonMap ("nationality" , "English" ), metadata );
2157+
2158+ // Log expectation (reuse existing pattern)
2159+ logbackVerifier .expectMessage (Level .INFO , expectedReason );
2160+ }
2161+ @ Test
2162+ public void decide_for_keys_with_holdout () throws Exception {
2163+ Optimizely optWithHoldout = createOptimizelyWithHoldouts ();
2164+ String userId = "user123" ;
2165+ Map <String , Object > attrs = new HashMap <>();
2166+ attrs .put ("$opt_bucketing_id" , "ppid160000" );
2167+ OptimizelyUserContext user = optWithHoldout .createUserContext (userId , attrs );
2168+
2169+ List <String > flagKeys = Arrays .asList (
2170+ "boolean_feature" , // previously validated basic_holdout membership
2171+ "double_single_variable_feature" , // also subject to global/basic holdout
2172+ "integer_single_variable_feature" // also subject to global/basic holdout
2173+ );
2174+
2175+ Map <String , OptimizelyDecision > decisions = user .decideForKeys (flagKeys , Collections .singletonList (OptimizelyDecideOption .INCLUDE_REASONS ));
2176+ assertEquals (3 , decisions .size ());
2177+
2178+ String holdoutExperimentId = "10075323428" ; // basic_holdout id
2179+ String variationId = "$opt_dummy_variation_id" ;
2180+ String variationKey = "ho_off_key" ;
2181+ String expectedReason = "User (" + userId + ") is in variation (" + variationKey + ") of holdout (basic_holdout)." ;
2182+
2183+ for (String flagKey : flagKeys ) {
2184+ OptimizelyDecision d = decisions .get (flagKey );
2185+ assertNotNull (d );
2186+ assertEquals (flagKey , d .getFlagKey ());
2187+ assertEquals (variationKey , d .getVariationKey ());
2188+ assertFalse (d .getEnabled ());
2189+ assertTrue ("Expected holdout reason for flag " + flagKey , d .getReasons ().contains (expectedReason ));
2190+ DecisionMetadata metadata = new DecisionMetadata .Builder ()
2191+ .setFlagKey (flagKey )
2192+ .setRuleKey ("basic_holdout" )
2193+ .setRuleType ("holdout" )
2194+ .setVariationKey (variationKey )
2195+ .setEnabled (false )
2196+ .build ();
2197+ // attributes map expected empty (reserved $opt_ attribute filtered out)
2198+ eventHandler .expectImpression (holdoutExperimentId , variationId , userId , Collections .emptyMap (), metadata );
2199+ }
2200+
2201+ // At least one log message confirming holdout membership
2202+ logbackVerifier .expectMessage (Level .INFO , expectedReason );
2203+ }
2204+
2205+ @ Test
2206+ public void decide_all_with_holdout () throws Exception {
2207+
2208+ Optimizely optWithHoldout = createOptimizelyWithHoldouts ();
2209+ String userId = "user123" ;
2210+ Map <String , Object > attrs = new HashMap <>();
2211+ // ppid120000 buckets user into holdout_included_flags
2212+ attrs .put ("$opt_bucketing_id" , "ppid120000" );
2213+ OptimizelyUserContext user = optWithHoldout .createUserContext (userId , attrs );
2214+
2215+ // All flag keys present in holdouts-project-config.json
2216+ List <String > allFlagKeys = Arrays .asList (
2217+ "boolean_feature" ,
2218+ "double_single_variable_feature" ,
2219+ "integer_single_variable_feature" ,
2220+ "boolean_single_variable_feature" ,
2221+ "string_single_variable_feature" ,
2222+ "multi_variate_feature" ,
2223+ "multi_variate_future_feature" ,
2224+ "mutex_group_feature"
2225+ );
2226+
2227+ // Flags INCLUDED in holdout_included_flags (only these should be holdout decisions)
2228+ List <String > includedInHoldout = Arrays .asList (
2229+ "boolean_feature" ,
2230+ "double_single_variable_feature" ,
2231+ "integer_single_variable_feature"
2232+ );
2233+
2234+ Map <String , OptimizelyDecision > decisions = user .decideAll (Arrays .asList (
2235+ OptimizelyDecideOption .INCLUDE_REASONS ,
2236+ OptimizelyDecideOption .DISABLE_DECISION_EVENT
2237+ ));
2238+ assertEquals (allFlagKeys .size (), decisions .size ());
2239+
2240+ String holdoutExperimentId = "1007543323427" ; // holdout_included_flags id
2241+ String variationId = "$opt_dummy_variation_id" ;
2242+ String variationKey = "ho_off_key" ;
2243+ String expectedReason = "User (" + userId + ") is in variation (" + variationKey + ") of holdout (holdout_included_flags)." ;
2244+
2245+ int holdoutCount = 0 ;
2246+ for (String flagKey : allFlagKeys ) {
2247+ OptimizelyDecision d = decisions .get (flagKey );
2248+ assertNotNull ("Missing decision for flag " + flagKey , d );
2249+ if (includedInHoldout .contains (flagKey )) {
2250+ // Should be holdout decision
2251+ assertEquals (variationKey , d .getVariationKey ());
2252+ assertFalse (d .getEnabled ());
2253+ assertTrue ("Expected holdout reason for flag " + flagKey , d .getReasons ().contains (expectedReason ));
2254+ DecisionMetadata metadata = new DecisionMetadata .Builder ()
2255+ .setFlagKey (flagKey )
2256+ .setRuleKey ("holdout_included_flags" )
2257+ .setRuleType ("holdout" )
2258+ .setVariationKey (variationKey )
2259+ .setEnabled (false )
2260+ .build ();
2261+ holdoutCount ++;
2262+ } else {
2263+ // Should NOT be a holdout decision
2264+ assertFalse ("Non-included flag should not have holdout reason: " + flagKey , d .getReasons ().contains (expectedReason ));
2265+ }
2266+ }
2267+ assertEquals ("Expected exactly the included flags to be in holdout" , includedInHoldout .size (), holdoutCount );
2268+ logbackVerifier .expectMessage (Level .INFO , expectedReason );
2269+ }
20872270}
0 commit comments