@@ -2084,4 +2084,187 @@ OptimizelyDecision callDecideWithIncludeReasons(String flagKey) {
2084
2084
return callDecideWithIncludeReasons (flagKey , Collections .emptyMap ());
2085
2085
}
2086
2086
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
+ }
2087
2270
}
0 commit comments