From 04d379a2f7c650b424ffff04e172ca2c5c2b6e36 Mon Sep 17 00:00:00 2001 From: Jake Brown Date: Thu, 4 Nov 2021 09:44:09 -0400 Subject: [PATCH] feat(ForcedDecisions): add forced-decisions APIs to OptimizelyUserContext (#451) ## Summary Add a set of new APIs for forced-decisions to OptimizelyUserContext: - setForcedDecision - getForcedDecision - removeForcedDecision - removeAllForcedDecisions ## Test plan - unit tests for the new APIs - FSC tests with new test cases --- .../java/com/optimizely/ab/Optimizely.java | 66 ++- .../ab/OptimizelyDecisionContext.java | 49 ++ .../ab/OptimizelyForcedDecision.java | 31 ++ .../optimizely/ab/OptimizelyUserContext.java | 156 +++++- .../ab/bucketing/DecisionService.java | 280 +++++++--- .../ab/config/DatafileProjectConfig.java | 44 +- .../optimizely/ab/config/ProjectConfig.java | 2 + .../ab/event/internal/UserEventFactory.java | 6 +- .../ab/OptimizelyDecisionContextTest.java | 44 ++ .../ab/OptimizelyForcedDecisionTest.java | 30 + .../com/optimizely/ab/OptimizelyTest.java | 59 +- .../ab/OptimizelyUserContextTest.java | 520 ++++++++++++++++++ .../ab/bucketing/DecisionServiceTest.java | 234 ++++---- .../ab/config/DatafileProjectConfigTest.java | 23 +- 14 files changed, 1287 insertions(+), 257 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java create mode 100644 core-api/src/main/java/com/optimizely/ab/OptimizelyForcedDecision.java create mode 100644 core-api/src/test/java/com/optimizely/ab/OptimizelyDecisionContextTest.java create mode 100644 core-api/src/test/java/com/optimizely/ab/OptimizelyForcedDecisionTest.java diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 8a7034d0e..c53095692 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -376,7 +376,7 @@ public void track(@Nonnull String eventName, @Nonnull public Boolean isFeatureEnabled(@Nonnull String featureKey, @Nonnull String userId) { - return isFeatureEnabled(featureKey, userId, Collections.emptyMap()); + return isFeatureEnabled(featureKey, userId, Collections.emptyMap()); } /** @@ -424,7 +424,7 @@ private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, Map copiedAttributes = copyAttributes(attributes); FeatureDecision.DecisionSource decisionSource = FeatureDecision.DecisionSource.ROLLOUT; - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, copiedAttributes, projectConfig).getResult(); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContext(userId, copiedAttributes), projectConfig).getResult(); Boolean featureEnabled = false; SourceInfo sourceInfo = new RolloutSourceInfo(); if (featureDecision.decisionSource != null) { @@ -733,7 +733,7 @@ T getFeatureVariableValueForType(@Nonnull String featureKey, String variableValue = variable.getDefaultValue(); Map copiedAttributes = copyAttributes(attributes); - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, copiedAttributes, projectConfig).getResult(); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContext(userId, copiedAttributes), projectConfig).getResult(); Boolean featureEnabled = false; if (featureDecision.variation != null) { if (featureDecision.variation.getFeatureEnabled()) { @@ -824,6 +824,7 @@ Object convertStringToType(String variableValue, String type) { * @param userId The ID of the user. * @return An OptimizelyJSON instance for all variable values. * Null if the feature could not be found. + * */ @Nullable public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, @@ -839,6 +840,7 @@ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, * @param attributes The user's attributes. * @return An OptimizelyJSON instance for all variable values. * Null if the feature could not be found. + * */ @Nullable public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, @@ -866,7 +868,7 @@ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, } Map copiedAttributes = copyAttributes(attributes); - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, copiedAttributes, projectConfig).getResult(); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContext(userId, copiedAttributes), projectConfig, Collections.emptyList()).getResult(); Boolean featureEnabled = false; Variation variation = featureDecision.variation; @@ -922,9 +924,10 @@ public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, * @param attributes The user's attributes. * @return List of the feature keys that are enabled for the user if the userId is empty it will * return Empty List. + * */ public List getEnabledFeatures(@Nonnull String userId, @Nonnull Map attributes) { - List enabledFeaturesList = new ArrayList(); + List enabledFeaturesList = new ArrayList(); if (!validateUserId(userId)) { return enabledFeaturesList; } @@ -951,7 +954,7 @@ public List getEnabledFeatures(@Nonnull String userId, @Nonnull MapemptyMap()); + return getVariation(experiment, userId, Collections.emptyMap()); } @Nullable @@ -967,8 +970,7 @@ private Variation getVariation(@Nonnull ProjectConfig projectConfig, @Nonnull String userId, @Nonnull Map attributes) throws UnknownExperimentException { Map copiedAttributes = copyAttributes(attributes); - Variation variation = decisionService.getVariation(experiment, userId, copiedAttributes, projectConfig).getResult(); - + Variation variation = decisionService.getVariation(experiment, createUserContext(userId, copiedAttributes), projectConfig).getResult(); String notificationType = NotificationCenter.DecisionNotificationType.AB_TEST.toString(); if (projectConfig.getExperimentFeatureKeyMapping().get(experiment.getId()) != null) { @@ -1145,7 +1147,7 @@ public OptimizelyConfig getOptimizelyConfig() { * @return An OptimizelyUserContext associated with this OptimizelyClient. */ public OptimizelyUserContext createUserContext(@Nonnull String userId, - @Nonnull Map attributes) { + @Nonnull Map attributes) { if (userId == null) { logger.warn("The userId parameter must be nonnull."); return null; @@ -1179,14 +1181,24 @@ OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, DecisionReasons decisionReasons = DefaultDecisionReasons.newInstance(allOptions); Map copiedAttributes = new HashMap<>(attributes); - DecisionResponse decisionVariation = decisionService.getVariationForFeature( - flag, - userId, - copiedAttributes, - projectConfig, - allOptions); - FeatureDecision flagDecision = decisionVariation.getResult(); - decisionReasons.merge(decisionVariation.getReasons()); + FeatureDecision flagDecision; + + // Check Forced Decision + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flag.getKey(), null); + DecisionResponse forcedDecisionVariation = user.findValidatedForcedDecision(optimizelyDecisionContext); + decisionReasons.merge(forcedDecisionVariation.getReasons()); + if (forcedDecisionVariation.getResult() != null) { + flagDecision = new FeatureDecision(null, forcedDecisionVariation.getResult(), FeatureDecision.DecisionSource.FEATURE_TEST); + } else { + // Regular decision + DecisionResponse decisionVariation = decisionService.getVariationForFeature( + flag, + user, + projectConfig, + allOptions); + flagDecision = decisionVariation.getResult(); + decisionReasons.merge(decisionVariation.getReasons()); + } Boolean flagEnabled = false; if (flagDecision.variation != null) { @@ -1332,6 +1344,26 @@ private DecisionResponse> getDecisionVariableMap(@Nonnull Fe return new DecisionResponse(valuesMap, reasons); } + /** + * Gets a variation based on flagKey and variationKey + * + * @param flagKey The flag key for the variation + * @param variationKey The variation key for the variation + * @return Returns a variation based on flagKey and variationKey, otherwise null + */ + public Variation getFlagVariationByKey(String flagKey, String variationKey) { + Map> flagVariationsMap = getProjectConfig().getFlagVariationsMap(); + if (flagVariationsMap.containsKey(flagKey)) { + List variations = flagVariationsMap.get(flagKey); + for (Variation variation : variations) { + if (variation.getKey().equals(variationKey)) { + return variation; + } + } + } + return null; + } + /** * Helper method which makes separate copy of attributesMap variable and returns it * diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java new file mode 100644 index 000000000..4c4159301 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java @@ -0,0 +1,49 @@ +/** + * + * Copyright 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class OptimizelyDecisionContext { + public static final String OPTI_NULL_RULE_KEY = "$opt-null-rule-key"; + public static final String OPTI_KEY_DIVIDER = "-$opt$-"; + + private String flagKey; + private String ruleKey; + + public OptimizelyDecisionContext(@Nonnull String flagKey, @Nullable String ruleKey) { + this.flagKey = flagKey; + this.ruleKey = ruleKey; + } + + public String getFlagKey() { + return flagKey; + } + + public String getRuleKey() { + return ruleKey != null ? ruleKey : OPTI_NULL_RULE_KEY; + } + + public String getKey() { + StringBuilder keyBuilder = new StringBuilder(); + keyBuilder.append(flagKey); + keyBuilder.append(OPTI_KEY_DIVIDER); + keyBuilder.append(getRuleKey()); + return keyBuilder.toString(); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyForcedDecision.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyForcedDecision.java new file mode 100644 index 000000000..d73a86c83 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyForcedDecision.java @@ -0,0 +1,31 @@ +/** + * + * Copyright 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import javax.annotation.Nonnull; + +public class OptimizelyForcedDecision { + private String variationKey; + + public OptimizelyForcedDecision(@Nonnull String variationKey) { + this.variationKey = variationKey; + } + + public String getVariationKey() { + return variationKey; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java index f9cff6f44..0a785c550 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -16,19 +16,20 @@ */ package com.optimizely.ab; -import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; -import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.config.Variation; +import com.optimizely.ab.optimizelydecision.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; public class OptimizelyUserContext { + // OptimizelyForcedDecisionsKey mapped to variationKeys + Map forcedDecisionsMap; + @Nonnull private final String userId; @@ -42,7 +43,20 @@ public class OptimizelyUserContext { public OptimizelyUserContext(@Nonnull Optimizely optimizely, @Nonnull String userId, - @Nonnull Map attributes) { + @Nonnull Map attributes) { + this.optimizely = optimizely; + this.userId = userId; + if (attributes != null) { + this.attributes = Collections.synchronizedMap(new HashMap<>(attributes)); + } else { + this.attributes = Collections.synchronizedMap(new HashMap<>()); + } + } + + public OptimizelyUserContext(@Nonnull Optimizely optimizely, + @Nonnull String userId, + @Nonnull Map attributes, + @Nullable Map forcedDecisionsMap) { this.optimizely = optimizely; this.userId = userId; if (attributes != null) { @@ -50,6 +64,9 @@ public OptimizelyUserContext(@Nonnull Optimizely optimizely, } else { this.attributes = Collections.synchronizedMap(new HashMap<>()); } + if (forcedDecisionsMap != null) { + this.forcedDecisionsMap = new ConcurrentHashMap<>(forcedDecisionsMap); + } } public OptimizelyUserContext(@Nonnull Optimizely optimizely, @Nonnull String userId) { @@ -69,7 +86,7 @@ public Optimizely getOptimizely() { } public OptimizelyUserContext copy() { - return new OptimizelyUserContext(optimizely, userId, attributes); + return new OptimizelyUserContext(optimizely, userId, attributes, forcedDecisionsMap); } /** @@ -172,6 +189,129 @@ public void trackEvent(@Nonnull String eventName) throws UnknownEventTypeExcepti trackEvent(eventName, Collections.emptyMap()); } + /** + * Set a forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @param optimizelyForcedDecision The OptimizelyForcedDecision containing the variationKey + * @return Returns a boolean, Ture if successfully set, otherwise false + */ + public Boolean setForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext, + @Nonnull OptimizelyForcedDecision optimizelyForcedDecision) { + if (optimizely.getOptimizelyConfig() == null) { + logger.error("Optimizely SDK not ready."); + return false; + } + // Check if the forcedDecisionsMap has been initialized yet or not + if (forcedDecisionsMap == null ){ + // Thread-safe implementation of HashMap + forcedDecisionsMap = new ConcurrentHashMap<>(); + } + forcedDecisionsMap.put(optimizelyDecisionContext.getKey(), optimizelyForcedDecision); + return true; + } + + /** + * Get a forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @return Returns a variationKey for a given forced decision + */ + @Nullable + public OptimizelyForcedDecision getForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext) { + if (optimizely.getOptimizelyConfig() == null) { + logger.error("Optimizely SDK not ready."); + return null; + } + return findForcedDecision(optimizelyDecisionContext); + } + + /** + * Finds a forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @return Returns a variationKey relating to the found forced decision, otherwise null + */ + @Nullable + public OptimizelyForcedDecision findForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext) { + if (forcedDecisionsMap != null) { + return forcedDecisionsMap.get(optimizelyDecisionContext.getKey()); + } + return null; + } + + /** + * Remove a forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @return Returns a boolean, true if successfully removed, otherwise false + */ + public boolean removeForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext) { + if (optimizely.getOptimizelyConfig() == null) { + logger.error("Optimizely SDK not ready."); + return false; + } + + try { + if (forcedDecisionsMap != null) { + if (forcedDecisionsMap.remove(optimizelyDecisionContext.getKey()) != null) { + return true; + } + } + } catch (Exception e) { + logger.error("Unable to remove forced-decision - " + e); + } + + return false; + } + + /** + * Remove all forced decisions + * + * @return Returns a boolean, True if successfully, otherwise false + */ + public boolean removeAllForcedDecisions() { + if (optimizely.getProjectConfig() == null) { + logger.error("Optimizely SDK not ready."); + return false; + } + // Clear both maps for with and without ruleKey + if (forcedDecisionsMap != null) { + forcedDecisionsMap.clear(); + } + return true; + } + + /** + * Find a validated forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @return Returns a DecisionResponse structure of type Variation, otherwise null result with reasons + */ + public DecisionResponse findValidatedForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + OptimizelyForcedDecision optimizelyForcedDecision = findForcedDecision(optimizelyDecisionContext); + String variationKey = optimizelyForcedDecision != null ? optimizelyForcedDecision.getVariationKey() : null; + if (variationKey != null) { + Variation variation = optimizely.getFlagVariationByKey(optimizelyDecisionContext.getFlagKey(), variationKey); + String ruleKey = optimizelyDecisionContext.getRuleKey(); + String flagKey = optimizelyDecisionContext.getFlagKey(); + String info; + String target = ruleKey != OptimizelyDecisionContext.OPTI_NULL_RULE_KEY ? String.format("flag (%s), rule (%s)", flagKey, ruleKey) : String.format("flag (%s)", flagKey); + if (variation != null) { + info = String.format("Variation (%s) is mapped to %s and user (%s) in the forced decision map.", variationKey, target, userId); + logger.debug(info); + reasons.addInfo(info); + return new DecisionResponse(variation, reasons); + } else { + info = String.format("Invalid variation is mapped to %s and user (%s) in the forced decision map.", target, userId); + logger.debug(info); + reasons.addInfo(info); + } + } + return new DecisionResponse<>(null, reasons); + } + // Utils @Override diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index c6a267f5b..e386c360d 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -15,7 +15,9 @@ ***************************************************************************/ package com.optimizely.ab.bucketing; +import com.optimizely.ab.OptimizelyDecisionContext; import com.optimizely.ab.OptimizelyRuntimeException; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.*; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.internal.ControlAttribute; @@ -26,13 +28,9 @@ import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.EXPERIMENT; @@ -83,16 +81,14 @@ public DecisionService(@Nonnull Bucketer bucketer, * Get a {@link Variation} of an {@link Experiment} for a user to be allocated into. * * @param experiment The Experiment the user will be bucketed into. - * @param userId The userId of the user. - * @param filteredAttributes The user's attributes. This should be filtered to just attributes in the Datafile. + * @param user The current OptimizelyUserContext * @param projectConfig The current projectConfig * @param options An array of decision options * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons */ @Nonnull public DecisionResponse getVariation(@Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull Map filteredAttributes, + @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig, @Nonnull List options) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); @@ -104,13 +100,13 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, } // look for forced bucketing first. - DecisionResponse decisionVariation = getForcedVariation(experiment, userId); + DecisionResponse decisionVariation = getForcedVariation(experiment, user.getUserId()); reasons.merge(decisionVariation.getReasons()); Variation variation = decisionVariation.getResult(); // check for whitelisting if (variation == null) { - decisionVariation = getWhitelistedVariation(experiment, userId); + decisionVariation = getWhitelistedVariation(experiment, user.getUserId()); reasons.merge(decisionVariation.getReasons()); variation = decisionVariation.getResult(); } @@ -125,7 +121,7 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, if (userProfileService != null && !ignoreUPS) { try { - Map userProfileMap = userProfileService.lookup(userId); + Map userProfileMap = userProfileService.lookup(user.getUserId()); if (userProfileMap == null) { String message = reasons.addInfo("We were unable to get a user profile map from the UserProfileService."); logger.info(message); @@ -151,14 +147,14 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, return new DecisionResponse(variation, reasons); } } else { // if we could not find a user profile, make a new one - userProfile = new UserProfile(userId, new HashMap()); + userProfile = new UserProfile(user.getUserId(), new HashMap()); } } - DecisionResponse decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, filteredAttributes, EXPERIMENT, experiment.getKey()); + DecisionResponse decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, user.getAttributes(), EXPERIMENT, experiment.getKey()); reasons.merge(decisionMeetAudience.getReasons()); if (decisionMeetAudience.getResult()) { - String bucketingId = getBucketingId(userId, filteredAttributes); + String bucketingId = getBucketingId(user.getUserId(), user.getAttributes()); decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig); reasons.merge(decisionVariation.getReasons()); @@ -175,42 +171,85 @@ public DecisionResponse getVariation(@Nonnull Experiment experiment, return new DecisionResponse(variation, reasons); } - String message = reasons.addInfo("User \"%s\" does not meet conditions to be in experiment \"%s\".", userId, experiment.getKey()); + String message = reasons.addInfo("User \"%s\" does not meet conditions to be in experiment \"%s\".", user.getUserId(), experiment.getKey()); logger.info(message); return new DecisionResponse(null, reasons); } @Nonnull public DecisionResponse getVariation(@Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull Map filteredAttributes, + @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig) { - return getVariation(experiment, userId, filteredAttributes, projectConfig, Collections.emptyList()); + return getVariation(experiment, user, projectConfig, Collections.emptyList()); } /** * Get the variation the user is bucketed into for the FeatureFlag * * @param featureFlag The feature flag the user wants to access. - * @param userId User Identifier - * @param filteredAttributes A map of filtered attributes. + * @param user The current OptimizelyuserContext * @param projectConfig The current projectConfig * @param options An array of decision options * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons */ @Nonnull public DecisionResponse getVariationForFeature(@Nonnull FeatureFlag featureFlag, - @Nonnull String userId, - @Nonnull Map filteredAttributes, + @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig, @Nonnull List options) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + DecisionResponse decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options); + reasons.merge(decisionVariationResponse.getReasons()); + + FeatureDecision decision = decisionVariationResponse.getResult(); + if (decision != null) { + return new DecisionResponse(decision, reasons); + } + + DecisionResponse decisionFeatureResponse = getVariationForFeatureInRollout(featureFlag, user, projectConfig); + reasons.merge(decisionFeatureResponse.getReasons()); + decision = decisionFeatureResponse.getResult(); + + String message; + if (decision.variation == null) { + message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".", + user.getUserId(), featureFlag.getKey()); + } else { + message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", + user.getUserId(), featureFlag.getKey()); + } + logger.info(message); + + return new DecisionResponse(decision, reasons); + } + + @Nonnull + public DecisionResponse getVariationForFeature(@Nonnull FeatureFlag featureFlag, + @Nonnull OptimizelyUserContext user, + @Nonnull ProjectConfig projectConfig) { + return getVariationForFeature(featureFlag, user, projectConfig, Collections.emptyList()); + } + + /** + * + * @param projectConfig The ProjectConfig. + * @param featureFlag The feature flag the user wants to access. + * @param user The current OptimizelyUserContext. + * @param options An array of decision options + * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons + */ + @Nonnull + DecisionResponse getVariationFromExperiment(@Nonnull ProjectConfig projectConfig, + @Nonnull FeatureFlag featureFlag, + @Nonnull OptimizelyUserContext user, + @Nonnull List options) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); if (!featureFlag.getExperimentIds().isEmpty()) { for (String experimentId : featureFlag.getExperimentIds()) { Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId); - DecisionResponse decisionVariation = getVariation(experiment, userId, filteredAttributes, projectConfig, options); + DecisionResponse decisionVariation = getVariationFromExperimentRule(projectConfig, featureFlag.getKey(), experiment, user, options); reasons.merge(decisionVariation.getReasons()); Variation variation = decisionVariation.getResult(); @@ -225,28 +264,8 @@ public DecisionResponse getVariationForFeature(@Nonnull Feature logger.info(message); } - DecisionResponse decisionFeature = getVariationForFeatureInRollout(featureFlag, userId, filteredAttributes, projectConfig); - reasons.merge(decisionFeature.getReasons()); - FeatureDecision featureDecision = decisionFeature.getResult(); - - if (featureDecision.variation == null) { - String message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".", - userId, featureFlag.getKey()); - logger.info(message); - } else { - String message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", - userId, featureFlag.getKey()); - logger.info(message); - } - return new DecisionResponse(featureDecision, reasons); - } + return new DecisionResponse(null, reasons); - @Nonnull - public DecisionResponse getVariationForFeature(@Nonnull FeatureFlag featureFlag, - @Nonnull String userId, - @Nonnull Map filteredAttributes, - @Nonnull ProjectConfig projectConfig) { - return getVariationForFeature(featureFlag, userId, filteredAttributes, projectConfig,Collections.emptyList()); } /** @@ -255,15 +274,13 @@ public DecisionResponse getVariationForFeature(@Nonnull Feature * Fall back onto the everyone else rule if the user is ever excluded from a rule due to traffic allocation. * * @param featureFlag The feature flag the user wants to access. - * @param userId User Identifier - * @param filteredAttributes A map of filtered attributes. + * @param user The current OptimizelyUserContext * @param projectConfig The current projectConfig * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons */ @Nonnull DecisionResponse getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag, - @Nonnull String userId, - @Nonnull Map filteredAttributes, + @Nonnull OptimizelyUserContext user, @Nonnull ProjectConfig projectConfig) { DecisionReasons reasons = DefaultDecisionReasons.newInstance(); @@ -286,51 +303,33 @@ DecisionResponse getVariationForFeatureInRollout(@Nonnull Featu if (rolloutRulesLength == 0) { return new DecisionResponse(new FeatureDecision(null, null, null), reasons); } - String bucketingId = getBucketingId(userId, filteredAttributes); - - Variation variation; - DecisionResponse decisionMeetAudience; - DecisionResponse decisionVariation; - for (int i = 0; i < rolloutRulesLength - 1; i++) { - Experiment rolloutRule = rollout.getExperiments().get(i); - - decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, rolloutRule, filteredAttributes, RULE, Integer.toString(i + 1)); - reasons.merge(decisionMeetAudience.getReasons()); - if (decisionMeetAudience.getResult()) { - decisionVariation = bucketer.bucket(rolloutRule, bucketingId, projectConfig); - reasons.merge(decisionVariation.getReasons()); - variation = decisionVariation.getResult(); - if (variation == null) { - break; - } - return new DecisionResponse( - new FeatureDecision(rolloutRule, variation, FeatureDecision.DecisionSource.ROLLOUT), - reasons); - } else { - String message = reasons.addInfo("User \"%s\" does not meet conditions for targeting rule \"%d\".", userId, i + 1); - logger.debug(message); - } - } - // get last rule which is the fall back rule - Experiment finalRule = rollout.getExperiments().get(rolloutRulesLength - 1); + int index = 0; + while (index < rolloutRulesLength) { - decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, finalRule, filteredAttributes, RULE, "Everyone Else"); - reasons.merge(decisionMeetAudience.getReasons()); - if (decisionMeetAudience.getResult()) { - decisionVariation = bucketer.bucket(finalRule, bucketingId, projectConfig); - variation = decisionVariation.getResult(); - reasons.merge(decisionVariation.getReasons()); + DecisionResponse decisionVariationResponse = getVariationFromDeliveryRule( + projectConfig, + featureFlag.getKey(), + rollout.getExperiments(), + index, + user + ); + reasons.merge(decisionVariationResponse.getReasons()); + AbstractMap.SimpleEntry response = decisionVariationResponse.getResult(); + Variation variation = response.getKey(); + Boolean skipToEveryoneElse = response.getValue(); if (variation != null) { - String message = reasons.addInfo("User \"%s\" meets conditions for targeting rule \"Everyone Else\".", userId); - logger.debug(message); - return new DecisionResponse( - new FeatureDecision(finalRule, variation, FeatureDecision.DecisionSource.ROLLOUT), - reasons); + Experiment rule = rollout.getExperiments().get(index); + FeatureDecision featureDecision = new FeatureDecision(rule, variation, FeatureDecision.DecisionSource.ROLLOUT); + return new DecisionResponse(featureDecision, reasons); } + + // The last rule is special for "Everyone Else" + index = skipToEveryoneElse ? (rolloutRulesLength - 1) : (index + 1); } + return new DecisionResponse(new FeatureDecision(null, null, null), reasons); } @@ -509,7 +508,7 @@ public boolean setForcedVariation(@Nonnull Experiment experiment, ConcurrentHashMap experimentToVariation; if (!forcedVariationMapping.containsKey(userId)) { - forcedVariationMapping.putIfAbsent(userId, new ConcurrentHashMap()); + forcedVariationMapping.putIfAbsent(userId, new ConcurrentHashMap()); } experimentToVariation = forcedVariationMapping.get(userId); @@ -581,6 +580,34 @@ public DecisionResponse getForcedVariation(@Nonnull Experiment experi return new DecisionResponse(null, reasons); } + + public DecisionResponse getVariationFromExperimentRule(@Nonnull ProjectConfig projectConfig, + @Nonnull String flagKey, + @Nonnull Experiment rule, + @Nonnull OptimizelyUserContext user, + @Nonnull List options) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + String ruleKey = rule != null ? rule.getKey() : null; + // Check Forced-Decision + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + DecisionResponse forcedDecisionResponse = user.findValidatedForcedDecision(optimizelyDecisionContext); + + reasons.merge(forcedDecisionResponse.getReasons()); + + Variation variation = forcedDecisionResponse.getResult(); + if (variation != null) { + return new DecisionResponse(variation, reasons); + } + //regular decision + DecisionResponse decisionResponse = getVariation(rule, user, projectConfig, options); + reasons.merge(decisionResponse.getReasons()); + + variation = decisionResponse.getResult(); + + return new DecisionResponse(variation, reasons); + } + /** * Helper function to check that the provided userId is valid * @@ -591,4 +618,81 @@ private boolean validateUserId(String userId) { return (userId != null); } + /** + * + * @param projectConfig The Project config + * @param flagKey The flag key for the feature flag + * @param rules The experiments belonging to a rollout + * @param ruleIndex The index of the rule + * @param user The OptimizelyUserContext + * @return Returns a DecisionResponse Object containing a AbstractMap.SimpleEntry + * where the Variation is the result and the Boolean is the skipToEveryoneElse. + */ + DecisionResponse getVariationFromDeliveryRule(@Nonnull ProjectConfig projectConfig, + @Nonnull String flagKey, + @Nonnull List rules, + @Nonnull int ruleIndex, + @Nonnull OptimizelyUserContext user) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + Boolean skipToEveryoneElse = false; + AbstractMap.SimpleEntry variationToSkipToEveryoneElsePair; + // Check forced-decisions first + Experiment rule = rules.get(ruleIndex); + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, rule.getKey()); + DecisionResponse forcedDecisionResponse = user.findValidatedForcedDecision(optimizelyDecisionContext); + reasons.merge(forcedDecisionResponse.getReasons()); + + Variation variation = forcedDecisionResponse.getResult(); + if (variation != null) { + variationToSkipToEveryoneElsePair = new AbstractMap.SimpleEntry<>(variation, false); + return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons); + } + + // Handle a regular decision + String bucketingId = getBucketingId(user.getUserId(), user.getAttributes()); + Boolean everyoneElse = (ruleIndex == rules.size() - 1); + String loggingKey = everyoneElse ? "Everyone Else" : String.valueOf(ruleIndex + 1); + + Variation bucketedVariation = null; + + DecisionResponse audienceDecisionResponse = ExperimentUtils.doesUserMeetAudienceConditions( + projectConfig, + rule, + user.getAttributes(), + RULE, + String.valueOf(ruleIndex + 1) + ); + + reasons.merge(audienceDecisionResponse.getReasons()); + String message; + if (audienceDecisionResponse.getResult()) { + message = reasons.addInfo("User \"%s\" meets conditions for targeting rule \"%s\".", user.getUserId(), loggingKey); + reasons.addInfo(message); + logger.debug(message); + + DecisionResponse decisionResponse = bucketer.bucket(rule, bucketingId, projectConfig); + reasons.merge(decisionResponse.getReasons()); + bucketedVariation = decisionResponse.getResult(); + + if (bucketedVariation != null) { + message = reasons.addInfo("User \"%s\" bucketed for targeting rule \"%s\".", user.getUserId(), loggingKey); + logger.debug(message); + reasons.addInfo(message); + } else if (!everyoneElse) { + message = reasons.addInfo("User \"%s\" is not bucketed for targeting rule \"%s\".", user.getUserId(), loggingKey); + logger.debug(message); + reasons.addInfo(message); + // Skip the rest of rollout rules to the everyone-else rule if audience matches but not bucketed. + skipToEveryoneElse = true; + } + } else { + message = reasons.addInfo("User \"%s\" does not meet conditions for targeting rule \"%d\".", user.getUserId(), ruleIndex + 1); + reasons.addInfo(message); + logger.debug(message); + } + variationToSkipToEveryoneElsePair = new AbstractMap.SimpleEntry<>(bucketedVariation, skipToEveryoneElse); + return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons); + } + } diff --git a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java index 0757b6d4e..831563826 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java @@ -31,9 +31,7 @@ import javax.annotation.CheckForNull; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import javax.annotation.concurrent.Immutable; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; /** * DatafileProjectConfig is an implementation of ProjectConfig that is backed by a @@ -80,6 +78,9 @@ public class DatafileProjectConfig implements ProjectConfig { private final Map experimentKeyMapping; private final Map featureKeyMapping; + // Key to Entity mappings for Forced Decisions + private final Map> flagVariationsMap; + // id to entity mappings private final Map audienceIdMapping; private final Map experimentIdMapping; @@ -209,8 +210,42 @@ public DatafileProjectConfig(String accountId, // Generate experiment to featureFlag list mapping to identify if experiment is AB-Test experiment or Feature-Test Experiment. this.experimentFeatureKeyMapping = ProjectConfigUtils.generateExperimentFeatureMapping(this.featureFlags); + + flagVariationsMap = new HashMap<>(); + if (featureFlags != null) { + for (FeatureFlag flag : featureFlags) { + Map variationIdToVariationsMap = new HashMap<>(); + for (Experiment rule : getAllRulesForFlag(flag)) { + for (Variation variation : rule.getVariations()) { + if(!variationIdToVariationsMap.containsKey(variation.getId())) { + variationIdToVariationsMap.put(variation.getId(), variation); + } + } + } + // Grab all the variations from the flag experiments and rollouts and add to flagVariationsMap + flagVariationsMap.put(flag.getKey(), new ArrayList<>(variationIdToVariationsMap.values())); + } + } + } + + /** + * Helper method to grab all rules for a flag + * @param flag The flag to grab all the rules from + * @return Returns a list of Experiments as rules + */ + private List getAllRulesForFlag(FeatureFlag flag) { + List rules = new ArrayList<>(); + Rollout rollout = rolloutIdMapping.get(flag.getRolloutId()); + for (String experimentId : flag.getExperimentIds()) { + rules.add(experimentIdMapping.get(experimentId)); + } + if (rollout != null) { + rules.addAll(rollout.getExperiments()); + } + return rules; } + /** * Helper method to retrieve the {@link Experiment} for the given experiment key. * If {@link RaiseExceptionErrorHandler} is provided, either an experiment is returned, @@ -463,6 +498,11 @@ public Map> getExperimentFeatureKeyMapping() { return experimentFeatureKeyMapping; } + @Override + public Map> getFlagVariationsMap() { + return flagVariationsMap; + } + @Override public String toString() { return "ProjectConfig{" + diff --git a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java index a6222e8b2..9c3321708 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java @@ -103,6 +103,8 @@ Experiment getExperimentForKey(@Nonnull String experimentKey, Map> getExperimentFeatureKeyMapping(); + Map> getFlagVariationsMap(); + @Override String toString(); diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java index 20d771033..9c44f455b 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java @@ -54,9 +54,9 @@ public static ImpressionEvent createImpressionEvent(@Nonnull ProjectConfig proje if (variation != null) { variationKey = variation.getKey(); variationID = variation.getId(); - layerID = activatedExperiment.getLayerId(); - experimentId = activatedExperiment.getId(); - experimentKey = activatedExperiment.getKey(); + layerID = activatedExperiment != null ? activatedExperiment.getLayerId() : ""; + experimentId = activatedExperiment != null ? activatedExperiment.getId() : ""; + experimentKey = activatedExperiment != null ? activatedExperiment.getKey() : ""; } UserContext userContext = new UserContext.Builder() diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyDecisionContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyDecisionContextTest.java new file mode 100644 index 000000000..daaf59d61 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyDecisionContextTest.java @@ -0,0 +1,44 @@ +/** + * + * Copyright 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import org.junit.Test; +import static junit.framework.TestCase.assertEquals; + +public class OptimizelyDecisionContextTest { + + @Test + public void initializeOptimizelyDecisionContextWithFlagKeyAndRuleKey() { + String flagKey = "test-flag-key"; + String ruleKey = "1029384756"; + String expectedKey = flagKey + OptimizelyDecisionContext.OPTI_KEY_DIVIDER + ruleKey; + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + assertEquals(flagKey, optimizelyDecisionContext.getFlagKey()); + assertEquals(ruleKey, optimizelyDecisionContext.getRuleKey()); + assertEquals(expectedKey, optimizelyDecisionContext.getKey()); + } + + @Test + public void initializeOptimizelyDecisionContextWithFlagKey() { + String flagKey = "test-flag-key"; + String expectedKey = flagKey + OptimizelyDecisionContext.OPTI_KEY_DIVIDER + OptimizelyDecisionContext.OPTI_NULL_RULE_KEY; + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + assertEquals(flagKey, optimizelyDecisionContext.getFlagKey()); + assertEquals(OptimizelyDecisionContext.OPTI_NULL_RULE_KEY, optimizelyDecisionContext.getRuleKey()); + assertEquals(expectedKey, optimizelyDecisionContext.getKey()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyForcedDecisionTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyForcedDecisionTest.java new file mode 100644 index 000000000..90c0f9e50 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyForcedDecisionTest.java @@ -0,0 +1,30 @@ +/** + * + * Copyright 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import org.junit.Test; +import static junit.framework.TestCase.assertEquals; + +public class OptimizelyForcedDecisionTest { + + @Test + public void initializeOptimizelyForcedDecision() { + String variationKey = "test-variation-key"; + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + assertEquals(variationKey, optimizelyForcedDecision.getVariationKey()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index a0c0541ac..0a9c334f8 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -1716,8 +1716,7 @@ public void getEnabledFeaturesWithNoFeatureEnabled() throws Exception { FeatureDecision featureDecision = new FeatureDecision(null, null, FeatureDecision.DecisionSource.ROLLOUT); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); int notificationId = optimizely.addDecisionNotificationHandler( decisionNotification -> { }); @@ -1833,8 +1832,7 @@ public void isFeatureEnabledWithListenerUserInExperimentFeatureOff() throws Exce FeatureDecision featureDecision = new FeatureDecision(activatedExperiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); @@ -2897,8 +2895,7 @@ public void getFeatureVariableValueReturnsDefaultValueWhenFeatureEnabledIsFalse( FeatureDecision featureDecision = new FeatureDecision(multivariateExperiment, VARIATION_MULTIVARIATE_EXPERIMENT_GRED, FeatureDecision.DecisionSource.FEATURE_TEST); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE), + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), validProjectConfig ); @@ -3110,8 +3107,7 @@ public void isFeatureEnabledReturnsFalseWhenFeatureKeyIsNull() throws Exception verify(mockDecisionService, never()).getVariationForFeature( any(FeatureFlag.class), - any(String.class), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); } @@ -3132,8 +3128,7 @@ public void isFeatureEnabledReturnsFalseWhenUserIdIsNull() throws Exception { verify(mockDecisionService, never()).getVariationForFeature( any(FeatureFlag.class), - any(String.class), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); } @@ -3156,8 +3151,7 @@ public void isFeatureEnabledReturnsFalseWhenFeatureFlagKeyIsInvalid() throws Exc verify(mockDecisionService, never()).getVariation( any(Experiment.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); } @@ -3179,8 +3173,7 @@ public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation() FeatureDecision featureDecision = new FeatureDecision(null, null, null); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); @@ -3195,8 +3188,7 @@ public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation() verify(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.emptyMap()), + eq(optimizely.createUserContext(genericUserId, Collections.emptyMap())), eq(validProjectConfig) ); } @@ -3221,8 +3213,7 @@ public void isFeatureEnabledReturnsTrueButDoesNotSendWhenUserIsBucketedIntoVaria FeatureDecision featureDecision = new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.ROLLOUT); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.emptyMap()), + eq(optimizely.createUserContext(genericUserId, Collections.emptyMap())), eq(validProjectConfig) ); @@ -3242,8 +3233,7 @@ public void isFeatureEnabledReturnsTrueButDoesNotSendWhenUserIsBucketedIntoVaria verify(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.emptyMap()), + eq(optimizely.createUserContext(genericUserId, Collections.emptyMap())), eq(validProjectConfig) ); } @@ -3306,8 +3296,7 @@ public void isFeatureEnabledTrueWhenFeatureEnabledOfVariationIsTrue() throws Exc FeatureDecision featureDecision = new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.ROLLOUT); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.emptyMap()), + eq(optimizely.createUserContext(genericUserId, Collections.emptyMap())), eq(validProjectConfig) ); @@ -3336,8 +3325,7 @@ public void isFeatureEnabledFalseWhenFeatureEnabledOfVariationIsFalse() throws E FeatureDecision featureDecision = new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.ROLLOUT); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.emptyMap()), + eq(spyOptimizely.createUserContext(genericUserId, Collections.emptyMap())), eq(validProjectConfig) ); @@ -3366,8 +3354,7 @@ public void isFeatureEnabledReturnsFalseAndDispatchesWhenUserIsBucketedIntoAnExp FeatureDecision featureDecision = new FeatureDecision(activatedExperiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); @@ -3422,8 +3409,7 @@ public void isFeatureEnabledWithInvalidDatafile() throws Exception { // make sure we didn't even attempt to bucket the user verify(mockDecisionService, never()).getVariationForFeature( any(FeatureFlag.class), - anyString(), - anyMap(), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); } @@ -3512,13 +3498,11 @@ public void getEnabledFeatureWithMockDecisionService() throws Exception { FeatureDecision featureDecision = new FeatureDecision(null, null, FeatureDecision.DecisionSource.ROLLOUT); doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); - List featureFlags = optimizely.getEnabledFeatures(genericUserId, - Collections.emptyMap()); + List featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap()); assertTrue(featureFlags.isEmpty()); eventHandler.expectImpression(null, "", genericUserId); @@ -4671,4 +4655,15 @@ public void createUserContext_multiple() { assertTrue(user2.getAttributes().isEmpty()); } + @Test + public void getFlagVariationByKey() throws IOException { + String flagKey = "double_single_variable_feature"; + String variationKey = "pi_variation"; + Optimizely optimizely = Optimizely.builder().withDatafile(validConfigJsonV4()).build(); + Variation variation = optimizely.getFlagVariationByKey(flagKey, variationKey); + + assertNotNull(variation); + assertEquals(variationKey, variation.getKey()); + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java index 0ac8beedb..6197d878b 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -26,6 +26,7 @@ import com.optimizely.ab.event.internal.payload.DecisionMetadata; import com.optimizely.ab.notification.NotificationCenter; import com.optimizely.ab.optimizelydecision.DecisionMessage; +import com.optimizely.ab.optimizelydecision.DecisionResponse; import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import com.optimizely.ab.optimizelydecision.OptimizelyDecision; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; @@ -1190,6 +1191,525 @@ public void decideReasons_missingAttributeValue() { )); } + @Test + public void setForcedDecisionWithRuleKeyTest() { + String flagKey = "55555"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + String foundVariationKey = optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey(); + assertEquals(variationKey, foundVariationKey); + } + + @Test + public void setForcedDecisionsWithRuleKeyTest() { + String flagKey = "feature_2"; + String ruleKey = "exp_no_audience"; + String ruleKey2 = "88888"; + String variationKey = "33333"; + String variationKey2 = "variation_with_traffic"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + OptimizelyDecisionContext optimizelyDecisionContext2 = new OptimizelyDecisionContext(flagKey, ruleKey2); + OptimizelyForcedDecision optimizelyForcedDecision2 = new OptimizelyForcedDecision(variationKey2); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext2, optimizelyForcedDecision2); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + assertEquals(variationKey2, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext2).getVariationKey()); + + // Update first forcedDecision + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision2); + assertEquals(variationKey2, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s), rule (%s) and user (%s) in the forced decision map.", variationKey2, flagKey, ruleKey, userId) + )); + } + + @Test + public void setForcedDecisionWithoutRuleKeyTest() { + String flagKey = "55555"; + String variationKey = "33333"; + String updatedVariationKey = "55555"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + OptimizelyForcedDecision updatedOptimizelyForcedDecision = new OptimizelyForcedDecision(updatedVariationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + // Update forcedDecision + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, updatedOptimizelyForcedDecision); + assertEquals(updatedVariationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + } + + + @Test + public void setForcedDecisionWithoutRuleKeyTestSdkNotReady() { + String flagKey = "55555"; + String variationKey = "33333"; + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + assertFalse(optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision)); + } + + @Test + public void getForcedVariationWithRuleKey() { + String flagKey = "55555"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + } + + @Test + public void failedGetForcedDecisionWithRuleKey() { + String flagKey = "55555"; + String invalidFlagKey = "11"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyDecisionContext invalidOptimizelyDecisionContext = new OptimizelyDecisionContext(invalidFlagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertNull(optimizelyUserContext.getForcedDecision(invalidOptimizelyDecisionContext)); + } + + @Test + public void getForcedVariationWithoutRuleKey() { + String flagKey = "55555"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + } + + @Test + public void getForcedVariationWithoutRuleKeySdkNotReady() { + String flagKey = "55555"; + String variationKey = "33333"; + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertNull(optimizelyUserContext.getForcedDecision(optimizelyDecisionContext)); + } + + @Test + public void failedGetForcedDecisionWithoutRuleKey() { + String flagKey = "55555"; + String invalidFlagKey = "11"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyDecisionContext invalidOptimizelyDecisionContext = new OptimizelyDecisionContext(invalidFlagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertNull(optimizelyUserContext.getForcedDecision(invalidOptimizelyDecisionContext)); + } + + @Test + public void removeForcedDecisionWithRuleKey() { + String flagKey = "55555"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertTrue(optimizelyUserContext.removeForcedDecision(optimizelyDecisionContext)); + } + + @Test + public void removeForcedDecisionWithoutRuleKey() { + String flagKey = "55555"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertTrue(optimizelyUserContext.removeForcedDecision(optimizelyDecisionContext)); + } + + @Test + public void removeForcedDecisionWithNullRuleKeyAfterAddingWithRuleKey() { + String flagKey = "flag2"; + String ruleKey = "default-rollout-3045-20390585493"; + String variationKey = "variation2"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyDecisionContext optimizelyDecisionContextNonNull = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertFalse(optimizelyUserContext.removeForcedDecision(optimizelyDecisionContextNonNull)); + } + + @Test + public void removeForcedDecisionWithIncorrectFlagKey() { + String flagKey = "55555"; + String variationKey = "variation2"; + String incorrectFlagKey = "flag1"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyDecisionContext incorrectOptimizelyDecisionContext = new OptimizelyDecisionContext(incorrectFlagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertFalse(optimizelyUserContext.removeForcedDecision(incorrectOptimizelyDecisionContext)); + } + + @Test + public void removeForcedDecisionWithoutRuleKeySdkNotReady() { + String flagKey = "flag2"; + String variationKey = "33333"; + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertFalse(optimizelyUserContext.removeForcedDecision(optimizelyDecisionContext)); + } + + @Test + public void removeForcedDecisionWithIncorrectFlagKeyButSimilarRuleKey() { + String flagKey = "flag2"; + String incorrectFlagKey = "flag3"; + String ruleKey = "default-rollout-3045-20390585493"; + String variationKey = "variation2"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyDecisionContext similarOptimizelyDecisionContext = new OptimizelyDecisionContext(incorrectFlagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertFalse(optimizelyUserContext.removeForcedDecision(similarOptimizelyDecisionContext)); + } + + @Test + public void removeAllForcedDecisions() { + String flagKey = "55555"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertTrue(optimizelyUserContext.removeAllForcedDecisions()); + } + + @Test + public void removeAllForcedDecisionsSdkNotReady() { + String flagKey = "55555"; + String ruleKey = "77777"; + String variationKey = "33333"; + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertFalse(optimizelyUserContext.removeAllForcedDecisions()); + } + + @Test + public void findValidatedForcedDecisionWithRuleKey() { + String ruleKey = "77777"; + String flagKey = "feature_2"; + String variationKey = "variation_no_traffic"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + DecisionResponse response = optimizelyUserContext.findValidatedForcedDecision(optimizelyDecisionContext); + Variation variation = response.getResult(); + assertEquals(variationKey, variation.getKey()); + } + + @Test + public void findValidatedForcedDecisionWithoutRuleKey() { + String flagKey = "feature_2"; + String variationKey = "variation_no_traffic"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + DecisionResponse response = optimizelyUserContext.findValidatedForcedDecision(optimizelyDecisionContext); + Variation variation = response.getResult(); + assertEquals(variationKey, variation.getKey()); + } + + @Test + public void setForcedDecisionsAndCallDecide() { + String flagKey = "feature_2"; + String ruleKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertNotNull(decision); + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s), rule (%s) and user (%s) in the forced decision map.", variationKey, flagKey, ruleKey, userId) + )); + } + /******************************************[START DECIDE TESTS WITH FDs]******************************************/ + @Test + public void setForcedDecisionsAndCallDecideFlagToDecision() { + String flagKey = "feature_1"; + String variationKey = "a"; + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + isListenerCalled = false; + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(isListenerCalled); + + String variationId = "10389729780"; + String experimentId = ""; + + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey("") + .setRuleType("feature-test") + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + + assertNotNull(decision); + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s) and user (%s) in the forced decision map.", variationKey, flagKey, userId) + )); + } + @Test + public void setForcedDecisionsAndCallDecideExperimentRuleToDecision() { + String flagKey = "feature_1"; + String ruleKey = "exp_with_audience"; + String variationKey = "a"; + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + isListenerCalled = false; + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(isListenerCalled); + + String variationId = "10389729780"; + String experimentId = "10390977673"; + + + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap()); + + assertNotNull(decision); + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s), rule (%s) and user (%s) in the forced decision map.", variationKey, flagKey, ruleKey, userId) + )); + } + + @Test + public void setForcedDecisionsAndCallDecideDeliveryRuleToDecision() { + String flagKey = "feature_1"; + String ruleKey = "3332020515"; + String variationKey = "3324490633"; + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + isListenerCalled = false; + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(isListenerCalled); + + String variationId = "3324490633"; + String experimentId = "3332020515"; + + + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap()); + + assertNotNull(decision); + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s), rule (%s) and user (%s) in the forced decision map.", variationKey, flagKey, ruleKey, userId) + )); + } + /********************************************[END DECIDE TESTS WITH FDs]******************************************/ // utils Map createUserProfileMap(String experimentId, String variationId) { diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java index 59dc47b22..eddbf0178 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java @@ -16,6 +16,8 @@ package com.optimizely.ab.bucketing; import ch.qos.logback.classic.Level; +import com.optimizely.ab.Optimizely; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.*; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.internal.ControlAttribute; @@ -29,10 +31,7 @@ import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.*; import static com.optimizely.ab.config.ValidProjectConfigV4.*; @@ -62,6 +61,8 @@ public class DecisionServiceTest { private Variation whitelistedVariation; private DecisionService decisionService; + private Optimizely optimizely; + @Rule public LogbackVerifier logbackVerifier = new LogbackVerifier(); @@ -74,13 +75,14 @@ public void setUp() throws Exception { whitelistedVariation = whitelistedExperiment.getVariationKeyToVariationMap().get("vtag1"); Bucketer bucketer = new Bucketer(); decisionService = spy(new DecisionService(bucketer, mockErrorHandler, null)); + this.optimizely = Optimizely.builder().build(); } //========= getVariation tests =========/ /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to forced variation bucketing over audience evaluation. */ @Test @@ -89,19 +91,24 @@ public void getVariationWhitelistedPrecedesAudienceEval() throws Exception { Variation expectedVariation = experiment.getVariations().get(0); // user excluded without audiences and whitelisting - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation( + experiment, + optimizely.createUserContext( + genericUserId, + Collections.emptyMap()), + validProjectConfig).getResult()); logbackVerifier.expectMessage(Level.INFO, "User \"" + whitelistedUserId + "\" is forced in variation \"vtag1\"."); // no attributes provided for a experiment that has an audience - assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig).getResult(), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(whitelistedUserId, Collections.emptyMap()), validProjectConfig).getResult(), is(expectedVariation)); verify(decisionService).getWhitelistedVariation(eq(experiment), eq(whitelistedUserId)); verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class)); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to forced variation bucketing over whitelisting. */ @Test @@ -111,23 +118,23 @@ public void getForcedVariationBeforeWhitelisting() throws Exception { Variation expectedVariation = experiment.getVariations().get(1); // user excluded without audiences and whitelisting - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig).getResult()); // set the runtimeForcedVariation decisionService.setForcedVariation(experiment, whitelistedUserId, expectedVariation.getKey()); // no attributes provided for a experiment that has an audience - assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig).getResult(), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(whitelistedUserId, Collections.emptyMap()), validProjectConfig).getResult(), is(expectedVariation)); //verify(decisionService).getForcedVariation(experiment.getKey(), whitelistedUserId); verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class)); assertEquals(decisionService.getWhitelistedVariation(experiment, whitelistedUserId).getResult(), whitelistVariation); assertTrue(decisionService.setForcedVariation(experiment, whitelistedUserId, null)); assertNull(decisionService.getForcedVariation(experiment, whitelistedUserId).getResult()); - assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig).getResult(), is(whitelistVariation)); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(whitelistedUserId, Collections.emptyMap()), validProjectConfig).getResult(), is(whitelistVariation)); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to forced variation bucketing over audience evaluation. */ @Test @@ -136,12 +143,12 @@ public void getVariationForcedPrecedesAudienceEval() throws Exception { Variation expectedVariation = experiment.getVariations().get(1); // user excluded without audiences and whitelisting - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig).getResult()); // set the runtimeForcedVariation decisionService.setForcedVariation(experiment, genericUserId, expectedVariation.getKey()); // no attributes provided for a experiment that has an audience - assertThat(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult(), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig).getResult(), is(expectedVariation)); verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), eq(validProjectConfig)); assertEquals(decisionService.setForcedVariation(experiment, genericUserId, null), true); @@ -149,7 +156,7 @@ public void getVariationForcedPrecedesAudienceEval() throws Exception { } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to forced variation bucketing over user profile. */ @Test @@ -165,22 +172,22 @@ public void getVariationForcedBeforeUserProfile() throws Exception { DecisionService decisionService = spy(new DecisionService(new Bucketer(), mockErrorHandler, userProfileService)); // ensure that normal users still get excluded from the experiment when they fail audience evaluation - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig).getResult()); // ensure that a user with a saved user profile, sees the same variation regardless of audience evaluation assertEquals(variation, - decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), validProjectConfig).getResult()); + decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), validProjectConfig).getResult()); Variation forcedVariation = experiment.getVariations().get(1); decisionService.setForcedVariation(experiment, userProfileId, forcedVariation.getKey()); assertEquals(forcedVariation, - decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), validProjectConfig).getResult()); + decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), validProjectConfig).getResult()); assertTrue(decisionService.setForcedVariation(experiment, userProfileId, null)); assertNull(decisionService.getForcedVariation(experiment, userProfileId).getResult()); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to user profile over audience evaluation. */ @Test @@ -196,16 +203,16 @@ public void getVariationEvaluatesUserProfileBeforeAudienceTargeting() throws Exc DecisionService decisionService = spy(new DecisionService(new Bucketer(), mockErrorHandler, userProfileService)); // ensure that normal users still get excluded from the experiment when they fail audience evaluation - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig).getResult()); // ensure that a user with a saved user profile, sees the same variation regardless of audience evaluation assertEquals(variation, - decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), validProjectConfig).getResult()); + decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), validProjectConfig).getResult()); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives a null variation on a Experiment that is not running. Set the forced variation. * And, test to make sure that after setting forced variation, the getVariation still returns * null. @@ -217,7 +224,7 @@ public void getVariationOnNonRunningExperimentWithForcedVariation() { Variation variation = experiment.getVariations().get(0); // ensure that the not running variation returns null with no forced variation set. - assertNull(decisionService.getVariation(experiment, "userId", Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext("userId", Collections.emptyMap()), validProjectConfig).getResult()); // we call getVariation 3 times on an experiment that is not running. logbackVerifier.expectMessage(Level.INFO, @@ -228,12 +235,12 @@ public void getVariationOnNonRunningExperimentWithForcedVariation() { // ensure that a user with a forced variation set // still gets back a null variation if the variation is not running. - assertNull(decisionService.getVariation(experiment, "userId", Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext("userId", Collections.emptyMap()), validProjectConfig).getResult()); // set the forced variation back to null assertTrue(decisionService.setForcedVariation(experiment, "userId", null)); // test one more time that the getVariation returns null for the experiment that is not running. - assertNull(decisionService.getVariation(experiment, "userId", Collections.emptyMap(), validProjectConfig).getResult()); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext("userid", Collections.emptyMap()), validProjectConfig).getResult()); } @@ -241,7 +248,7 @@ public void getVariationOnNonRunningExperimentWithForcedVariation() { //========== get Variation for Feature tests ==========// /** - * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns null when the {@link FeatureFlag} is not used in any experiments or rollouts. */ @Test @@ -263,8 +270,7 @@ public void getVariationForFeatureReturnsNullWhenFeatureFlagExperimentIdsIsEmpty FeatureDecision featureDecision = decisionService.getVariationForFeature( emptyFeatureFlag, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig).getResult(); assertNull(featureDecision.variation); assertNull(featureDecision.decisionSource); @@ -275,7 +281,7 @@ public void getVariationForFeatureReturnsNullWhenFeatureFlagExperimentIdsIsEmpty } /** - * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns null when the user is not bucketed into any experiments or rollouts for the {@link FeatureFlag}. */ @Test @@ -286,24 +292,21 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment // do not bucket to any experiments doReturn(DecisionResponse.nullNoReasons()).when(decisionService).getVariation( any(Experiment.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject() ); // do not bucket to any rollouts doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(null, null, null))).when(decisionService).getVariationForFeatureInRollout( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); // try to get a variation back from the decision service for the feature flag FeatureDecision featureDecision = decisionService.getVariationForFeature( spyFeatureFlag, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig ).getResult(); assertNull(featureDecision.variation); @@ -314,11 +317,11 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment FEATURE_MULTI_VARIATE_FEATURE_KEY + "\"."); verify(spyFeatureFlag, times(2)).getExperimentIds(); - verify(spyFeatureFlag, times(1)).getKey(); + verify(spyFeatureFlag, times(2)).getKey(); } /** - * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of the experiment a user gets bucketed into for an experiment. */ @Test @@ -328,36 +331,33 @@ public void getVariationForFeatureReturnsVariationReturnedFromGetVariation() { doReturn(DecisionResponse.nullNoReasons()).when(decisionService).getVariation( eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject() ); doReturn(DecisionResponse.responseNoReasons(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1)).when(decisionService).getVariation( eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject() ); FeatureDecision featureDecision = decisionService.getVariationForFeature( spyFeatureFlag, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), v4ProjectConfig ).getResult(); assertEquals(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.FEATURE_TEST, featureDecision.decisionSource); verify(spyFeatureFlag, times(2)).getExperimentIds(); - verify(spyFeatureFlag, never()).getKey(); + verify(spyFeatureFlag, times(2)).getKey(); } /** * Verify that when getting a {@link Variation} for a {@link FeatureFlag} in - * {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map, ProjectConfig)}, + * {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)}, * check first if the user is bucketed to an {@link Experiment} * then check if the user is not bucketed to an experiment, * check for a {@link Rollout}. @@ -376,8 +376,7 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() doReturn(DecisionResponse.responseNoReasons(experimentVariation)) .when(decisionService).getVariation( eq(featureExperiment), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject() ); @@ -386,16 +385,14 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(rolloutExperiment, rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT))) .when(decisionService).getVariationForFeatureInRollout( eq(featureFlag), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); // make sure we get the right variation back FeatureDecision featureDecision = decisionService.getVariationForFeature( featureFlag, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), v4ProjectConfig ).getResult(); assertEquals(experimentVariation, featureDecision.variation); @@ -404,16 +401,14 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() // make sure we do not even check for rollout bucketing verify(decisionService, never()).getVariationForFeatureInRollout( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); // make sure we ask for experiment bucketing once verify(decisionService, times(1)).getVariation( any(Experiment.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject() ); @@ -421,7 +416,7 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() /** * Verify that when getting a {@link Variation} for a {@link FeatureFlag} in - * {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map, ProjectConfig)}, + * {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)}, * check first if the user is bucketed to an {@link Rollout} * if the user is not bucketed to an experiment. */ @@ -438,8 +433,7 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails doReturn(DecisionResponse.nullNoReasons()) .when(decisionService).getVariation( eq(featureExperiment), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject() ); @@ -448,16 +442,14 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(rolloutExperiment, rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT))) .when(decisionService).getVariationForFeatureInRollout( eq(featureFlag), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); // make sure we get the right variation back FeatureDecision featureDecision = decisionService.getVariationForFeature( featureFlag, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), v4ProjectConfig ).getResult(); assertEquals(rolloutVariation, featureDecision.variation); @@ -466,16 +458,14 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails // make sure we do not even check for rollout bucketing verify(decisionService, times(1)).getVariationForFeatureInRollout( any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class) ); // make sure we ask for experiment bucketing once verify(decisionService, times(1)).getVariation( any(Experiment.class), - anyString(), - anyMapOf(String.class, String.class), + any(OptimizelyUserContext.class), any(ProjectConfig.class), anyObject() ); @@ -490,7 +480,7 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails //========== getVariationForFeatureInRollout tests ==========// /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns null when trying to bucket a user into a {@link FeatureFlag} * that does not have a {@link Rollout} attached. */ @@ -503,8 +493,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenFeatureIsNotAttachedTo FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( mockFeatureFlag, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), validProjectConfig ).getResult(); assertNull(featureDecision.variation); @@ -517,7 +506,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenFeatureIsNotAttachedTo } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * return null when a user is excluded from every rule of a rollout due to traffic allocation. */ @Test @@ -533,10 +522,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllT FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.singletonMap( - ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE - ), + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), v4ProjectConfig ).getResult(); assertNull(featureDecision.variation); @@ -549,7 +535,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllT } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns null when a user is excluded from every rule of a rollout due to targeting * and also fails traffic allocation in the everyone else rollout. */ @@ -562,8 +548,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserFailsAllAudiencesA FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), v4ProjectConfig ).getResult(); assertNull(featureDecision.variation); @@ -574,7 +559,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserFailsAllAudiencesA } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of "Everyone Else" rule * when the user fails targeting for all rules, but is bucketed into the "Everyone Else" rule. */ @@ -594,8 +579,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.emptyMap(), + optimizely.createUserContext(genericUserId, Collections.emptyMap()), v4ProjectConfig ).getResult(); logbackVerifier.expectMessage(Level.DEBUG, "Evaluating audiences for rule \"1\": [3468206642]."); @@ -614,7 +598,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of "Everyone Else" rule * when the user passes targeting for a rule, but was failed the traffic allocation for that rule, * and is bucketed successfully into the "Everyone Else" rule. @@ -636,10 +620,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.singletonMap( - ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE - ), + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), v4ProjectConfig ).getResult(); assertEquals(expectedVariation, featureDecision.variation); @@ -652,7 +633,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of "Everyone Else" rule * when the user passes targeting for a rule, but was failed the traffic allocation for that rule, * and is bucketed successfully into the "Everyone Else" rule. @@ -679,15 +660,14 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - DatafileProjectConfigTestUtils.createMapOfObjects( + optimizely.createUserContext(genericUserId, DatafileProjectConfigTestUtils.createMapOfObjects( DatafileProjectConfigTestUtils.createListOfObjects( ATTRIBUTE_HOUSE_KEY, ATTRIBUTE_NATIONALITY_KEY ), DatafileProjectConfigTestUtils.createListOfObjects( AUDIENCE_GRYFFINDOR_VALUE, AUDIENCE_ENGLISH_CITIZENS_VALUE ) - ), + )), v4ProjectConfig ).getResult(); assertEquals(expectedVariation, featureDecision.variation); @@ -698,7 +678,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of "English Citizens" rule * when the user fails targeting for previous rules, but passes targeting and traffic for Rule 3. */ @@ -718,10 +698,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.singletonMap( - ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE - ), + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE)), v4ProjectConfig ).getResult(); assertEquals(englishCitizenVariation, featureDecision.variation); @@ -735,6 +712,55 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } + @Test + public void getVariationFromDeliveryRuleTest() { + int index = 3; + List rules = ROLLOUT_2.getExperiments(); + Experiment experiment = ROLLOUT_2.getExperiments().get(index); + Variation expectedVariation = null; + for (Variation variation : experiment.getVariations()) { + if (variation.getKey().equals("3137445031")) { + expectedVariation = variation; + } + } + DecisionResponse decisionResponse = decisionService.getVariationFromDeliveryRule( + v4ProjectConfig, + FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), + rules, + index, + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE)) + ); + + Variation variation = (Variation) decisionResponse.getResult().getKey(); + Boolean skipToEveryoneElse = (Boolean) decisionResponse.getResult().getValue(); + assertNotNull(decisionResponse.getResult()); + assertNotNull(variation); + assertNotNull(expectedVariation); + assertEquals(expectedVariation, variation); + assertFalse(skipToEveryoneElse); + } + + @Test + public void getVariationFromExperimentRuleTest() { + int index = 3; + Experiment experiment = ROLLOUT_2.getExperiments().get(index); + Variation expectedVariation = null; + for (Variation variation : experiment.getVariations()) { + if (variation.getKey().equals("3137445031")) { + expectedVariation = variation; + } + } + DecisionResponse decisionResponse = decisionService.getVariationFromExperimentRule( + v4ProjectConfig, + FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), + experiment, + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE)), + Collections.emptyList() + ); + + assertEquals(expectedVariation, decisionResponse.getResult()); + } + //========= white list tests ==========/ /** @@ -811,7 +837,7 @@ public void bucketReturnsVariationStoredInUserProfile() throws Exception { // ensure user with an entry in the user profile is bucketed into the corresponding stored variation assertEquals(variation, - decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig).getResult()); + decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), noAudienceProjectConfig).getResult()); verify(userProfileService).lookup(userProfileId); } @@ -867,7 +893,7 @@ public void getStoredVariationReturnsNullWhenVariationIsNoLongerInConfig() throw } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * saves a {@link Variation}of an {@link Experiment} for a user when a {@link UserProfileService} is present. */ @SuppressFBWarnings @@ -890,7 +916,7 @@ public void getVariationSavesBucketedVariationIntoUserProfile() throws Exception DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, userProfileService); assertEquals(variation, decisionService.getVariation( - experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig).getResult() + experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), noAudienceProjectConfig).getResult() ); logbackVerifier.expectMessage(Level.INFO, String.format("Saved variation \"%s\" of experiment \"%s\" for user \"" + userProfileId + "\".", variation.getId(), @@ -900,7 +926,7 @@ public void getVariationSavesBucketedVariationIntoUserProfile() throws Exception } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map, ProjectConfig)} logs correctly + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} logs correctly * when a {@link UserProfileService} is present but fails to save an activation. */ @Test @@ -950,7 +976,7 @@ public void getVariationSavesANewUserProfile() throws Exception { when(bucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); when(userProfileService.lookup(userProfileId)).thenReturn(null); - assertEquals(variation, decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig).getResult()); + assertEquals(variation, decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), noAudienceProjectConfig).getResult()); verify(userProfileService).save(expectedUserProfile.toMap()); } @@ -963,15 +989,15 @@ public void getVariationBucketingId() throws Exception { when(bucketer.bucket(eq(experiment), eq("bucketId"), eq(validProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(expectedVariation)); - Map attr = new HashMap(); + Map attr = new HashMap(); attr.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), "bucketId"); // user excluded without audiences and whitelisting - assertThat(decisionService.getVariation(experiment, genericUserId, attr, validProjectConfig).getResult(), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, attr), validProjectConfig).getResult(), is(expectedVariation)); } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map, ProjectConfig)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * uses bucketing ID to bucket the user into rollouts. */ @Test @@ -981,7 +1007,7 @@ public void getVariationForRolloutWithBucketingId() { FeatureFlag featureFlag = FEATURE_FLAG_SINGLE_VARIABLE_INTEGER; String bucketingId = "user_bucketing_id"; String userId = "user_id"; - Map attributes = new HashMap(); + Map attributes = new HashMap(); attributes.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), bucketingId); Bucketer bucketer = mock(Bucketer.class); @@ -999,7 +1025,7 @@ public void getVariationForRolloutWithBucketingId() { rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT); - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, attributes, v4ProjectConfig).getResult(); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, optimizely.createUserContext(userId, attributes), v4ProjectConfig).getResult(); assertEquals(expectedFeatureDecision, featureDecision); } diff --git a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTest.java b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTest.java index cab4face3..41b02ea91 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTest.java @@ -17,15 +17,14 @@ package com.optimizely.ab.config; import ch.qos.logback.classic.Level; +import com.google.errorprone.annotations.Var; import com.optimizely.ab.config.audience.AndCondition; import com.optimizely.ab.config.audience.Condition; import com.optimizely.ab.config.audience.NotCondition; import com.optimizely.ab.config.audience.OrCondition; import com.optimizely.ab.config.audience.UserAttribute; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; +import java.util.*; import static java.util.Arrays.asList; import static org.hamcrest.CoreMatchers.is; @@ -170,4 +169,22 @@ public void getAttributeIDWhenAttributeKeyPrefixIsMatched() { " has reserved prefix $opt_; using attribute ID instead of reserved attribute name."); } + @Test + public void confirmUniqueVariationsInFlagVariationsMapTest() { + // Test to confirm no duplicate variations are added for each flag + // This should never happen as a Map is used for each flag based on variation ID as the key + Map> flagVariationsMap = projectConfig.getFlagVariationsMap(); + for (List variationsList : flagVariationsMap.values()) { + Boolean duplicate = false; + Map variationIdToVariationsMap = new HashMap<>(); + for (Variation variation : variationsList) { + if (variationIdToVariationsMap.containsKey(variation.getId())) { + duplicate = true; + } + variationIdToVariationsMap.put(variation.getId(), variation); + } + assertFalse(duplicate); + } + } + }