Skip to content

Commit 8b36afd

Browse files
feat: implement decision notification handling with holdouts
- Create Optimizely instance with specific configuration - Set up decision notification handler for optimalizely decision notifications - Test decision notification logic with holdout context - Validate decision notification data for various test scenarios
1 parent 3ea6d9e commit 8b36afd

File tree

1 file changed

+183
-0
lines changed

1 file changed

+183
-0
lines changed

core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)