diff --git a/.travis.yml b/.travis.yml index 5478b88cb..417ca7e6f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,9 @@ language: java +dist: trusty jdk: - - openjdk7 - - oraclejdk7 + - openjdk8 - oraclejdk8 + - oraclejdk9 install: true script: - "./gradlew clean" @@ -20,4 +21,4 @@ cache: branches: only: - master - - /^\d+\.\d+\.\d+(-SNAPSHOT|-alpha)?$/ # trigger builds on tags which are semantically versioned to ship the SDK. + - /^\d+\.\d+\.\d+(-SNAPSHOT|-alpha|-beta)?$/ # trigger builds on tags which are semantically versioned to ship the SDK. diff --git a/CHANGELOG.md b/CHANGELOG.md index 64cdfd53f..2bd9c8d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,63 @@ -## 2.0.0 +# Optimizely Java X SDK Changelog +## 2.0.0 Beta +September 29, 2017 + +This release is a beta release supporting feature flags and rollouts. + +### New Features +#### Feature Flag Accessors +You can now use feature flags in the Java SDK. You can experiment on features and rollout features through the Optimizely UI. + +- `isFeatureEnabled` +- `getFeatureVariableBoolean` +- `getFeatureVariableDouble` +- `getFeatureVariableInteger` +- `getFeatureVariableString` + +### Breaking Changes + +- Remove Live Variables accessors + - `getVariableString` + - `getVariableBoolean` + - `getVariableInteger` + - `getVariableDouble` +- Remove track with revenue as a parameter. Pass the revenue value as an event tag instead + - `track(String, String, long)` + - `track(String, String, Map, long)` +- We will no longer run all unit tests in travis-ci against Java 7. + We will still continue to set `sourceCompatibility` and `targetCompatibility` to 1.6 so that we build for Java 6. + +## 1.8.0 + +August 29, 2017 + +This release adds support for numeric metrics and forced bucketing (in code as opposed to whitelisting via project file). + +### New Features + +- Added `setForcedVariation` and `getForcedVariation` +- Added any numeric metric to event metrics. + +### Breaking Changes + +- Nothing breaking from 1.7.0 + +## 1.7.0 + +July 12, 2017 + +This release will support Android SDK release 1.4.0 + +### New Features + +- Added `UserProfileService` interface to allow for sticky bucketing + +### Breaking Changes + +- Removed `UserProfile` interface. Replaced with `UserProfileService` interface. +- Removed support for v1 datafiles. + +## 2.0.0-alpha May 19, 2017 @@ -110,4 +169,4 @@ August 29, 2016 July 26, 2016 -- Beta release of the Java SDK for server-side testing \ No newline at end of file +- Beta release of the Java SDK for server-side testing diff --git a/README.md b/README.md index 814a1c12f..cd811aa98 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Optimizely Java SDK =================== [![Build Status](https://travis-ci.org/optimizely/java-sdk.svg?branch=master)](https://travis-ci.org/optimizely/java-sdk) -[![Apache 2.0](https://img.shields.io/github/license/nebula-plugins/gradle-extra-configurations-plugin.svg)](http://www.apache.org/licenses/LICENSE-2.0) +[![Apache 2.0](https://img.shields.io/badge/license-APACHE%202.0-blue.svg)](http://www.apache.org/licenses/LICENSE-2.0) This repository houses the Java SDK for Optimizely's Full Stack product. diff --git a/build.gradle b/build.gradle index 8d6721170..bfea9deb5 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,7 @@ buildscript { } plugins { - id 'nebula.optional-base' version '3.0.3' + id 'nebula.optional-base' version '3.2.0' id 'me.champeau.gradle.jmh' version '0.3.1' } @@ -37,7 +37,6 @@ subprojects { apply plugin: 'jacoco' apply plugin: 'maven-publish' apply plugin: 'me.champeau.gradle.jmh' - apply plugin: 'nebula.provided-base' apply plugin: 'nebula.optional-base' sourceCompatibility = 1.6 @@ -108,6 +107,11 @@ subprojects { // logging dependencies (logback) testCompile group: 'ch.qos.logback', name: 'logback-classic', version: logbackVersion testCompile group: 'ch.qos.logback', name: 'logback-core', version: logbackVersion + + testCompile group: 'com.google.code.gson', name: 'gson', version: gsonVersion + testCompile group: 'org.json', name: 'json', version: jsonVersion + testCompile group: 'com.googlecode.json-simple', name: 'json-simple', version: jsonSimpleVersion + testCompile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: jacksonVersion } publishing { @@ -172,7 +176,3 @@ task ship() { dependsOn(':core-api:ship', ':core-httpclient-impl:ship') } -// todo: remove this wrapper version once we're publishing to jcenter/maven central -task wrapper(type: Wrapper) { - distributionUrl = gradleWrapperUrl -} diff --git a/core-api/build.gradle b/core-api/build.gradle index bbbca21ce..cd1d1fa9e 100644 --- a/core-api/build.gradle +++ b/core-api/build.gradle @@ -2,14 +2,14 @@ dependencies { compile group: 'org.slf4j', name: 'slf4j-api', version: slf4jVersion compile group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: jacksonVersion - provided group: 'com.google.code.findbugs', name: 'annotations', version: findbugsVersion - provided group: 'com.google.code.findbugs', name: 'jsr305', version: findbugsVersion + compile group: 'com.google.code.findbugs', name: 'annotations', version: findbugsVersion + compile group: 'com.google.code.findbugs', name: 'jsr305', version: findbugsVersion // an assortment of json parsers - provided group: 'com.google.code.gson', name: 'gson', version: gsonVersion, optional - provided group: 'org.json', name: 'json', version: jsonVersion, optional - provided group: 'com.googlecode.json-simple', name: 'json-simple', version: jsonSimpleVersion, optional - provided group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: jacksonVersion, optional + compileOnly group: 'com.google.code.gson', name: 'gson', version: gsonVersion, optional + compileOnly group: 'org.json', name: 'json', version: jsonVersion, optional + compileOnly group: 'com.googlecode.json-simple', name: 'json-simple', version: jsonSimpleVersion, optional + compileOnly group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: jacksonVersion, optional } task generateVersionFile { diff --git a/core-api/src/jmh/java/com/optimizely/ab/OptimizelyBenchmark.java b/core-api/src/jmh/java/com/optimizely/ab/OptimizelyBenchmark.java index 1e902f473..0e95c190f 100644 --- a/core-api/src/jmh/java/com/optimizely/ab/OptimizelyBenchmark.java +++ b/core-api/src/jmh/java/com/optimizely/ab/OptimizelyBenchmark.java @@ -157,49 +157,27 @@ public Variation measureActivateForGroupExperimentWithForcedVariation() { } @Benchmark - public void measureTrackWithNoAttributesAndNoRevenue() { + public void measureTrackWithNoAttributes() { optimizely.track("testEventWithMultipleExperiments", "optimizely_user" + random.nextInt()); } @Benchmark - public void measureTrackWithNoAttributesAndRevenue() { - optimizely.track("testEventWithMultipleExperiments", "optimizely_user" + random.nextInt(), 50000); - } - - @Benchmark - public void measureTrackWithAttributesAndNoRevenue() { + public void measureTrackWithAttributes() { optimizely.track("testEventWithMultipleExperiments", "optimizely_user" + random.nextInt(), Collections.singletonMap("browser_type", "firefox")); } @Benchmark - public void measureTrackWithAttributesAndRevenue() { - optimizely.track("testEventWithMultipleExperiments", "optimizely_user" + random.nextInt(), - Collections.singletonMap("browser_type", "firefox"), 50000); - } - - @Benchmark - public void measureTrackWithGroupExperimentsNoAttributesNoRevenue() { + public void measureTrackWithGroupExperimentsNoAttributes() { optimizely.track("testEventWithMultipleExperiments", trackGroupExperimentUserId); } @Benchmark - public void measureTrackWithGroupExperimentsNoAttributesAndRevenue() { - optimizely.track("testEventWithMultipleExperiments", trackGroupExperimentUserId, 50000); - } - - @Benchmark - public void measureTrackWithGroupExperimentsNoRevenueAndAttributes() { + public void measureTrackWithGroupExperimentsAndAttributes() { optimizely.track("testEventWithMultipleExperiments", trackGroupExperimentAttributesUserId, Collections.singletonMap("browser_type", "chrome")); } - @Benchmark - public void measureTrackWithGroupExperimentsAndAttributesAndRevenue() { - optimizely.track("testEventWithMultipleExperiments", trackGroupExperimentAttributesUserId, - Collections.singletonMap("browser_type", "chrome"), 50000); - } - @Benchmark public void measureTrackWithGroupExperimentsAndForcedVariation() { optimizely.track("testEventWithMultipleExperiments", "user_a"); 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 d05df8079..1ee6d498e 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -22,6 +22,7 @@ import com.optimizely.ab.config.Attribute; import com.optimizely.ab.config.EventType; import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.FeatureFlag; import com.optimizely.ab.config.LiveVariable; import com.optimizely.ab.config.LiveVariableUsageInstance; import com.optimizely.ab.config.ProjectConfig; @@ -38,7 +39,6 @@ import com.optimizely.ab.event.internal.EventBuilderV2; import com.optimizely.ab.event.internal.payload.Event.ClientEngine; import com.optimizely.ab.internal.EventTagUtils; -import com.optimizely.ab.internal.ReservedEventKey; import com.optimizely.ab.notification.NotificationBroadcaster; import com.optimizely.ab.notification.NotificationListener; import org.slf4j.Logger; @@ -175,6 +175,16 @@ Variation activate(@Nonnull ProjectConfig projectConfig, return null; } + sendImpression(projectConfig, experiment, userId, filteredAttributes, variation); + + return variation; + } + + private void sendImpression(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nonnull Variation variation) { if (experiment.isRunning()) { LogEvent impressionEvent = eventBuilder.createImpressionEvent( projectConfig, @@ -196,8 +206,6 @@ Variation activate(@Nonnull ProjectConfig projectConfig, } else { logger.info("Experiment has \"Launched\" status so not dispatching event during activation."); } - - return variation; } //======== track calls ========// @@ -213,26 +221,6 @@ public void track(@Nonnull String eventName, track(eventName, userId, attributes, Collections.emptyMap()); } - /** - * @deprecated see {@link #track(String, String, Map)} and pass in the revenue value as an event tag instead. - */ - public void track(@Nonnull String eventName, - @Nonnull String userId, - long eventValue) throws UnknownEventTypeException { - track(eventName, userId, Collections.emptyMap(), Collections.singletonMap( - ReservedEventKey.REVENUE.toString(), eventValue)); - } - - /** - * @deprecated see {@link #track(String, String, Map, long)} and pass in the revenue value as an event tag instead. - */ - public void track(@Nonnull String eventName, - @Nonnull String userId, - @Nonnull Map attributes, - long eventValue) throws UnknownEventTypeException { - track(eventName, userId, attributes, Collections.singletonMap(ReservedEventKey.REVENUE.toString(), eventValue)); - } - public void track(@Nonnull String eventName, @Nonnull String userId, @Nonnull Map attributes, @@ -303,131 +291,284 @@ public void track(@Nonnull String eventName, conversionEvent); } - //======== live variable getters ========// + //======== FeatureFlag APIs ========// - public @Nullable - String getVariableString(@Nonnull String variableKey, - @Nonnull String userId, - boolean activateExperiment) throws UnknownLiveVariableException { - return getVariableString(variableKey, userId, Collections.emptyMap(), activateExperiment); + /** + * Determine whether a boolean feature is enabled. + * Send an impression event if the user is bucketed into an experiment using the feature. + * + * @param featureKey The unique key of the feature. + * @param userId The ID of the user. + * @return True if the feature is enabled. + * False if the feature is disabled. + * False if the feature is not found. + */ + public @Nonnull Boolean isFeatureEnabled(@Nonnull String featureKey, + @Nonnull String userId) { + return isFeatureEnabled(featureKey, userId, Collections.emptyMap()); } - public @Nullable - String getVariableString(@Nonnull String variableKey, - @Nonnull String userId, - @Nonnull Map attributes, - boolean activateExperiment) - throws UnknownLiveVariableException { - - LiveVariable variable = getLiveVariableOrThrow(projectConfig, variableKey); - if (variable == null) { - return null; + /** + * Determine whether a boolean feature is enabled. + * Send an impression event if the user is bucketed into an experiment using the feature. + * + * @param featureKey The unique key of the feature. + * @param userId The ID of the user. + * @param attributes The user's attributes. + * @return True if the feature is enabled. + * False if the feature is disabled. + * False if the feature is not found. + */ + public @Nonnull Boolean isFeatureEnabled(@Nonnull String featureKey, + @Nonnull String userId, + @Nonnull Map attributes) { + FeatureFlag featureFlag = projectConfig.getFeatureKeyMapping().get(featureKey); + if (featureFlag == null) { + logger.info("No feature flag was found for key \"" + featureKey + "\"."); + return false; } - List experimentsUsingLiveVariable = - projectConfig.getLiveVariableIdToExperimentsMapping().get(variable.getId()); - Map> variationToLiveVariableUsageInstanceMapping = - projectConfig.getVariationToLiveVariableUsageInstanceMapping(); - - if (experimentsUsingLiveVariable == null) { - logger.warn("No experiment is using variable \"{}\".", variable.getKey()); - return variable.getDefaultValue(); - } + Map filteredAttributes = filterAttributes(projectConfig, attributes); - for (Experiment experiment : experimentsUsingLiveVariable) { - Variation variation; - if (activateExperiment) { - variation = activate(experiment, userId, attributes); - } else { - variation = getVariation(experiment, userId, attributes); + Variation variation = decisionService.getVariationForFeature(featureFlag, userId, filteredAttributes); + + if (variation != null) { + Experiment experiment = projectConfig.getExperimentForVariationId(variation.getId()); + if (experiment != null) { + // the user is in an experiment for the feature + sendImpression( + projectConfig, + experiment, + userId, + filteredAttributes, + variation); } - - if (variation != null) { - LiveVariableUsageInstance usageInstance = - variationToLiveVariableUsageInstanceMapping.get(variation.getId()).get(variable.getId()); - return usageInstance.getValue(); + else { + logger.info("The user \"" + userId + + "\" is not being experimented on in feature \"" + featureKey + "\"."); } + logger.info("Feature \"" + featureKey + "\" is enabled for user \"" + userId + "\"."); + return true; + } + else { + logger.info("Feature \"" + featureKey + "\" is not enabled for user \"" + userId + "\"."); + return false; } - - return variable.getDefaultValue(); } - public @Nullable - Boolean getVariableBoolean(@Nonnull String variableKey, - @Nonnull String userId, - boolean activateExperiment) throws UnknownLiveVariableException { - return getVariableBoolean(variableKey, userId, Collections.emptyMap(), activateExperiment); + /** + * Get the Boolean value of the specified variable in the feature. + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @return The Boolean value of the boolean single variable feature. + * Null if the feature could not be found. + */ + public @Nullable Boolean getFeatureVariableBoolean(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId) { + return getFeatureVariableBoolean(featureKey, variableKey, userId, Collections.emptyMap()); } - public @Nullable - Boolean getVariableBoolean(@Nonnull String variableKey, - @Nonnull String userId, - @Nonnull Map attributes, - boolean activateExperiment) - throws UnknownLiveVariableException { - - String variableValueString = getVariableString(variableKey, userId, attributes, activateExperiment); - if (variableValueString != null) { - return Boolean.parseBoolean(variableValueString); + /** + * Get the Boolean value of the specified variable in the feature. + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @param attributes The user's attributes. + * @return The Boolean value of the boolean single variable feature. + * Null if the feature or variable could not be found. + */ + public @Nullable Boolean getFeatureVariableBoolean(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map attributes) { + String variableValue = getFeatureVariableValueForType( + featureKey, + variableKey, + userId, + attributes, + LiveVariable.VariableType.BOOLEAN + ); + if (variableValue != null) { + return Boolean.parseBoolean(variableValue); } - return null; } - public @Nullable - Integer getVariableInteger(@Nonnull String variableKey, - @Nonnull String userId, - boolean activateExperiment) throws UnknownLiveVariableException { - return getVariableInteger(variableKey, userId, Collections.emptyMap(), activateExperiment); + /** + * Get the Double value of the specified variable in the feature. + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @return The Double value of the double single variable feature. + * Null if the feature or variable could not be found. + */ + public @Nullable Double getFeatureVariableDouble(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId) { + return getFeatureVariableDouble(featureKey, variableKey, userId, Collections.emptyMap()); } - public @Nullable - Integer getVariableInteger(@Nonnull String variableKey, - @Nonnull String userId, - @Nonnull Map attributes, - boolean activateExperiment) - throws UnknownLiveVariableException { - - String variableValueString = getVariableString(variableKey, userId, attributes, activateExperiment); - if (variableValueString != null) { + /** + * Get the Double value of the specified variable in the feature. + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @param attributes The user's attributes. + * @return The Double value of the double single variable feature. + * Null if the feature or variable could not be found. + */ + public @Nullable Double getFeatureVariableDouble(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map attributes) { + String variableValue = getFeatureVariableValueForType( + featureKey, + variableKey, + userId, + attributes, + LiveVariable.VariableType.DOUBLE + ); + if (variableValue != null) { try { - return Integer.parseInt(variableValueString); - } catch (NumberFormatException e) { - logger.error("Variable value \"{}\" for live variable \"{}\" is not an integer.", variableValueString, - variableKey); + return Double.parseDouble(variableValue); + } + catch (NumberFormatException exception) { + logger.error("NumberFormatException while trying to parse \"" + variableValue + + "\" as Double. " + exception); } } - return null; } - public @Nullable - Double getVariableDouble(@Nonnull String variableKey, - @Nonnull String userId, - boolean activateExperiment) throws UnknownLiveVariableException { - return getVariableDouble(variableKey, userId, Collections.emptyMap(), activateExperiment); + /** + * Get the Integer value of the specified variable in the feature. + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @return The Integer value of the integer single variable feature. + * Null if the feature or variable could not be found. + */ + public @Nullable Integer getFeatureVariableInteger(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId) { + return getFeatureVariableInteger(featureKey, variableKey, userId, Collections.emptyMap()); } - public @Nullable - Double getVariableDouble(@Nonnull String variableKey, - @Nonnull String userId, - @Nonnull Map attributes, - boolean activateExperiment) - throws UnknownLiveVariableException { - - String variableValueString = getVariableString(variableKey, userId, attributes, activateExperiment); - if (variableValueString != null) { + /** + * Get the Integer value of the specified variable in the feature. + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @param attributes The user's attributes. + * @return The Integer value of the integer single variable feature. + * Null if the feature or variable could not be found. + */ + public @Nullable Integer getFeatureVariableInteger(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map attributes) { + String variableValue = getFeatureVariableValueForType( + featureKey, + variableKey, + userId, + attributes, + LiveVariable.VariableType.INTEGER + ); + if (variableValue != null) { try { - return Double.parseDouble(variableValueString); - } catch (NumberFormatException e) { - logger.error("Variable value \"{}\" for live variable \"{}\" is not a double.", variableValueString, - variableKey); + return Integer.parseInt(variableValue); + } + catch (NumberFormatException exception) { + logger.error("NumberFormatException while trying to parse \"" + variableValue + + "\" as Integer. " + exception.toString()); } } - return null; } + /** + * Get the String value of the specified variable in the feature. + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @return The String value of the string single variable feature. + * Null if the feature or variable could not be found. + */ + public @Nullable String getFeatureVariableString(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId) { + return getFeatureVariableString(featureKey, variableKey, userId, Collections.emptyMap()); + } + + /** + * Get the String value of the specified variable in the feature. + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @param attributes The user's attributes. + * @return The String value of the string single variable feature. + * Null if the feature or variable could not be found. + */ + public @Nullable String getFeatureVariableString(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map attributes) { + return getFeatureVariableValueForType( + featureKey, + variableKey, + userId, + attributes, + LiveVariable.VariableType.STRING); + } + + @VisibleForTesting + String getFeatureVariableValueForType(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map attributes, + @Nonnull LiveVariable.VariableType variableType) { + FeatureFlag featureFlag = projectConfig.getFeatureKeyMapping().get(featureKey); + if (featureFlag == null) { + logger.info("No feature flag was found for key \"" + featureKey + "\"."); + return null; + } + + LiveVariable variable = featureFlag.getVariableKeyToLiveVariableMap().get(variableKey); + if (variable == null) { + logger.info("No feature variable was found for key \"" + variableKey + "\" in feature flag \"" + + featureKey + "\"."); + return null; + } + else if (!variable.getType().equals(variableType)) { + logger.info("The feature variable \"" + variableKey + + "\" is actually of type \"" + variable.getType().toString() + + "\" type. You tried to access it as type \"" + variableType.toString() + + "\". Please use the appropriate feature variable accessor."); + return null; + } + + String variableValue = variable.getDefaultValue(); + + Variation variation = decisionService.getVariationForFeature(featureFlag, userId, attributes); + + if (variation != null) { + LiveVariableUsageInstance liveVariableUsageInstance = + variation.getVariableIdToLiveVariableUsageInstanceMap().get(variable.getId()); + variableValue = liveVariableUsageInstance.getValue(); + } + else { + logger.info("User \"" + userId + + "\" was not bucketed into any variation for feature flag \"" + featureKey + + "\". The default value \"" + variableValue + + "\" for \"" + variableKey + "\" is being returned." + ); + } + + return variableValue; + } + //======== getVariation calls ========// public @Nullable @@ -475,6 +616,42 @@ Variation getVariation(@Nonnull String experimentKey, return decisionService.getVariation(experiment,userId,filteredAttributes); } + /** + * Force a user into a variation for a given experiment. + * The forced variation value does not persist across application launches. + * If the experiment key is not in the project file, this call fails and returns false. + * If the variationKey is not in the experiment, this call fails. + * @param experimentKey The key for the experiment. + * @param userId The user ID to be used for bucketing. + * @param variationKey The variation key to force the user into. If the variation key is null + * then the forcedVariation for that experiment is removed. + * + * @return boolean A boolean value that indicates if the set completed successfully. + */ + public boolean setForcedVariation(@Nonnull String experimentKey, + @Nonnull String userId, + @Nullable String variationKey) { + + + return projectConfig.setForcedVariation(experimentKey, userId, variationKey); + } + + /** + * Gets the forced variation for a given user and experiment. + * This method just calls into the {@link com.optimizely.ab.config.ProjectConfig#getForcedVariation(String, String)} + * method of the same signature. + * + * @param experimentKey The key for the experiment. + * @param userId The user ID to be used for bucketing. + * + * @return The variation the user was bucketed into. This value can be null if the + * forced variation fails. + */ + public @Nullable Variation getForcedVariation(@Nonnull String experimentKey, + @Nonnull String userId) { + return projectConfig.getForcedVariation(experimentKey, userId); + } + /** * @return the current {@link ProjectConfig} instance. */ @@ -595,36 +772,6 @@ private EventType getEventTypeOrThrow(ProjectConfig projectConfig, String eventN return eventType; } - /** - * Helper method to retrieve the {@link LiveVariable} for the given variable key. - * If {@link RaiseExceptionErrorHandler} is provided, either a live variable is returned, or an exception is - * thrown. - * If {@link NoOpErrorHandler} is used, either a live variable or {@code null} is returned. - * - * @param projectConfig the current project config - * @param variableKey the key for the live variable being retrieved from the current project config - * @return the live variable to retrieve for the given variable key - * - * @throws UnknownLiveVariableException if there are no event types in the current project config with the given - * name - */ - private LiveVariable getLiveVariableOrThrow(ProjectConfig projectConfig, String variableKey) - throws UnknownLiveVariableException { - - LiveVariable liveVariable = projectConfig - .getLiveVariableKeyMapping() - .get(variableKey); - - if (liveVariable == null) { - String unknownLiveVariableKeyError = - String.format("Live variable \"%s\" is not in the datafile.", variableKey); - logger.error(unknownLiveVariableKeyError); - errorHandler.handleError(new UnknownLiveVariableException(unknownLiveVariableKeyError)); - } - - return liveVariable; - } - /** * Helper method to verify that the given attributes map contains only keys that are present in the * {@link ProjectConfig}. 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 cc754a609..27c262088 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 @@ -18,8 +18,11 @@ import com.optimizely.ab.OptimizelyRuntimeException; import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.FeatureFlag; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.Rollout; import com.optimizely.ab.config.Variation; +import com.optimizely.ab.config.audience.Audience; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.internal.ExperimentUtils; import org.slf4j.Logger; @@ -81,8 +84,14 @@ public DecisionService(@Nonnull Bucketer bucketer, return null; } + // look for forced bucketing first. + Variation variation = projectConfig.getForcedVariation(experiment.getKey(), userId); + // check for whitelisting - Variation variation = getWhitelistedVariation(experiment, userId); + if (variation == null) { + variation = getWhitelistedVariation(experiment, userId); + } + if (variation != null) { return variation; } @@ -134,6 +143,102 @@ public DecisionService(@Nonnull Bucketer bucketer, return null; } + /** + * 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. + * @return null if the user is not bucketed into any variation + * {@link Variation} the user is bucketed into if the user is successfully bucketed. + */ + public @Nullable Variation getVariationForFeature(@Nonnull FeatureFlag featureFlag, + @Nonnull String userId, + @Nonnull Map filteredAttributes) { + if (!featureFlag.getExperimentIds().isEmpty()) { + for (String experimentId : featureFlag.getExperimentIds()) { + Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId); + Variation variation = this.getVariation(experiment, userId, filteredAttributes); + if (variation != null) { + return variation; + } + } + } + else { + logger.info("The feature flag \"" + featureFlag.getKey() + "\" is not used in any experiments."); + } + + Variation variation = getVariationForFeatureInRollout(featureFlag, userId, filteredAttributes); + if (variation == null) { + logger.info("The user \"" + userId + "\" was not bucketed into a rollout for feature flag \"" + + featureFlag.getKey() + "\"."); + } + else { + logger.info("The user \"" + userId + "\" was bucketed into a rollout for feature flag \"" + + featureFlag.getKey() + "\"."); + } + return variation; + } + + /** + * Try to bucket the user into a rollout rule. + * Evaluate the user for rules in priority order by seeing if the user satisfies the audience. + * 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. + * @return null if the user is not bucketed into the rollout or if the feature flag was not attached to a rollout. + * {@link Variation} the user is bucketed into fi the user is successfully bucketed. + */ + @Nullable Variation getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag, + @Nonnull String userId, + @Nonnull Map filteredAttributes) { + // use rollout to get variation for feature + if (featureFlag.getRolloutId().isEmpty()) { + logger.info("The feature flag \"" + featureFlag.getKey() + "\" is not used in a rollout."); + return null; + } + Rollout rollout = projectConfig.getRolloutIdMapping().get(featureFlag.getRolloutId()); + if (rollout == null) { + logger.error("The rollout with id \"" + featureFlag.getRolloutId() + + "\" was not found in the datafile for feature flag \"" + featureFlag.getKey() + + "\"."); + return null; + } + int rolloutRulesLength = rollout.getExperiments().size(); + Variation variation; + // for all rules before the everyone else rule + for (int i = 0; i < rolloutRulesLength - 1; i++) { + Experiment rolloutRule= rollout.getExperiments().get(i); + Audience audience = projectConfig.getAudienceIdMapping().get(rolloutRule.getAudienceIds().get(0)); + if (ExperimentUtils.isUserInExperiment(projectConfig, rolloutRule, filteredAttributes)) { + logger.debug("Attempting to bucket user \"" + userId + + "\" into rollout rule for audience \"" + audience.getName() + + "\"."); + variation = bucketer.bucket(rolloutRule, userId); + if (variation == null) { + logger.debug("User \"" + userId + + "\" was excluded due to traffic allocation."); + break; + } + return variation; + } + else { + logger.debug("User \"" + userId + + "\" did not meet the conditions to be in rollout rule for audience \"" + audience.getName() + + "\"."); + } + } + // get last rule which is the everyone else rule + Experiment everyoneElseRule = rollout.getExperiments().get(rolloutRulesLength - 1); + variation = bucketer.bucket(everyoneElseRule, userId); // ignore audience + if (variation == null) { + logger.debug("User \"" + userId + + "\" was excluded from the \"Everyone Else\" rule for feature flag \"" + featureFlag.getKey() + + "\"."); + } + return variation; + } + /** * Get the variation the user has been whitelisted into. * @param experiment {@link Experiment} in which user is to be bucketed. diff --git a/core-api/src/main/java/com/optimizely/ab/config/FeatureFlag.java b/core-api/src/main/java/com/optimizely/ab/config/FeatureFlag.java new file mode 100644 index 000000000..bbe7a88ba --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/FeatureFlag.java @@ -0,0 +1,114 @@ +/** + * + * Copyright 2017, 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.config; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Map; + +/** + * Represents a FeatureFlag definition at the project level + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class FeatureFlag implements IdKeyMapped{ + + private final String id; + private final String key; + private final String rolloutId; + private final List experimentIds; + private final List variables; + private final Map variableKeyToLiveVariableMap; + + @JsonCreator + public FeatureFlag(@JsonProperty("id") String id, + @JsonProperty("key") String key, + @JsonProperty("rolloutId") String rolloutId, + @JsonProperty("experimentIds") List experimentIds, + @JsonProperty("variables") List variables) { + this.id = id; + this.key = key; + this.rolloutId = rolloutId; + this.experimentIds = experimentIds; + this.variables = variables; + this.variableKeyToLiveVariableMap = ProjectConfigUtils.generateNameMapping(variables); + } + + public String getId() { + return id; + } + + public String getKey() { + return key; + } + + public String getRolloutId() { + return rolloutId; + } + + public List getExperimentIds() { + return experimentIds; + } + + public List getVariables() { + return variables; + } + + public Map getVariableKeyToLiveVariableMap() { + return variableKeyToLiveVariableMap; + } + + @Override + public String toString() { + return "FeatureFlag{" + + "id='" + id + '\'' + + ", key='" + key + '\'' + + ", rolloutId='" + rolloutId + '\'' + + ", experimentIds=" + experimentIds + + ", variables=" + variables + + ", variableKeyToLiveVariableMap=" + variableKeyToLiveVariableMap + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + FeatureFlag that = (FeatureFlag) o; + + if (!id.equals(that.id)) return false; + if (!key.equals(that.key)) return false; + if (!rolloutId.equals(that.rolloutId)) return false; + if (!experimentIds.equals(that.experimentIds)) return false; + if (!variables.equals(that.variables)) return false; + return variableKeyToLiveVariableMap.equals(that.variableKeyToLiveVariableMap); + } + + @Override + public int hashCode() { + int result = id.hashCode(); + result = 31 * result + key.hashCode(); + result = 31 * result + rolloutId.hashCode(); + result = 31 * result + experimentIds.hashCode(); + result = 31 * result + variables.hashCode(); + result = 31 * result + variableKeyToLiveVariableMap.hashCode(); + return result; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/LiveVariable.java b/core-api/src/main/java/com/optimizely/ab/config/LiveVariable.java index 4f6049282..4ae910301 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/LiveVariable.java +++ b/core-api/src/main/java/com/optimizely/ab/config/LiveVariable.java @@ -22,6 +22,8 @@ import com.fasterxml.jackson.annotation.JsonValue; import com.google.gson.annotations.SerializedName; +import javax.annotation.Nullable; + /** * Represents a live variable definition at the project level */ @@ -100,7 +102,7 @@ public static VariableType fromString(String variableTypeString) { private final String key; private final String defaultValue; private final VariableType type; - private final VariableStatus status; + @Nullable private final VariableStatus status; @JsonCreator public LiveVariable(@JsonProperty("id") String id, @@ -115,7 +117,7 @@ public LiveVariable(@JsonProperty("id") String id, this.type = type; } - public VariableStatus getStatus() { + public @Nullable VariableStatus getStatus() { return status; } @@ -145,4 +147,28 @@ public String toString() { ", status=" + status + '}'; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + LiveVariable variable = (LiveVariable) o; + + if (!id.equals(variable.id)) return false; + if (!key.equals(variable.key)) return false; + if (!defaultValue.equals(variable.defaultValue)) return false; + if (type != variable.type) return false; + return status == variable.status; + } + + @Override + public int hashCode() { + int result = id.hashCode(); + result = 31 * result + key.hashCode(); + result = 31 * result + defaultValue.hashCode(); + result = 31 * result + type.hashCode(); + result = 31 * result + status.hashCode(); + return result; + } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/LiveVariableUsageInstance.java b/core-api/src/main/java/com/optimizely/ab/config/LiveVariableUsageInstance.java index 05378b808..79cf05620 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/LiveVariableUsageInstance.java +++ b/core-api/src/main/java/com/optimizely/ab/config/LiveVariableUsageInstance.java @@ -24,7 +24,7 @@ * Represents the value of a live variable for a variation */ @JsonIgnoreProperties(ignoreUnknown = true) -public class LiveVariableUsageInstance { +public class LiveVariableUsageInstance implements IdMapped { private final String id; private final String value; 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 3915fec8e..539703176 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 @@ -19,12 +19,18 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.optimizely.ab.config.audience.Audience; import com.optimizely.ab.config.audience.Condition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * Represents the Optimizely Project configuration. @@ -36,9 +42,9 @@ public class ProjectConfig { public enum Version { - V1 ("1"), V2 ("2"), - V3 ("3"); + V3 ("3"), + V4 ("4"); private final String version; @@ -52,29 +58,51 @@ public String toString() { } } + // logger + private static final Logger logger = LoggerFactory.getLogger(ProjectConfig.class); + + // ProjectConfig properties private final String accountId; private final String projectId; private final String revision; private final String version; private final boolean anonymizeIP; - private final List groups; - private final List experiments; private final List attributes; - private final List events; private final List audiences; + private final List events; + private final List experiments; + private final List featureFlags; + private final List groups; private final List liveVariables; + private final List rollouts; - // convenience mappings for efficient lookup - private final Map experimentKeyMapping; + // key to entity mappings private final Map attributeKeyMapping; - private final Map liveVariableKeyMapping; private final Map eventNameMapping; + private final Map experimentKeyMapping; + private final Map featureKeyMapping; + private final Map liveVariableKeyMapping; + + // id to entity mappings private final Map audienceIdMapping; private final Map experimentIdMapping; private final Map groupIdMapping; + private final Map rolloutIdMapping; + + // other mappings private final Map> liveVariableIdToExperimentsMapping; private final Map> variationToLiveVariableUsageInstanceMapping; + private final Map variationIdToExperimentMapping; + /** + * Forced variations supersede any other mappings. They are transient and are not persistent or part of + * the actual datafile. This contains all the forced variations + * set by the user by calling {@link ProjectConfig#setForcedVariation(String, String, String)} (it is not the same as the + * whitelisting forcedVariations data structure in the Experiments class). + */ + private transient ConcurrentHashMap> forcedVariationMapping = new ConcurrentHashMap>(); + + // v2 constructor public ProjectConfig(String accountId, String projectId, String version, String revision, List groups, List experiments, List attributes, List eventType, List audiences) { @@ -82,9 +110,41 @@ public ProjectConfig(String accountId, String projectId, String version, String null); } + // v3 constructor public ProjectConfig(String accountId, String projectId, String version, String revision, List groups, List experiments, List attributes, List eventType, List audiences, boolean anonymizeIP, List liveVariables) { + this( + accountId, + anonymizeIP, + projectId, + revision, + version, + attributes, + audiences, + eventType, + experiments, + null, + groups, + liveVariables, + null + ); + } + + // v4 constructor + public ProjectConfig(String accountId, + boolean anonymizeIP, + String projectId, + String revision, + String version, + List attributes, + List audiences, + List events, + List experiments, + List featureFlags, + List groups, + List liveVariables, + List rollouts) { this.accountId = accountId; this.projectId = projectId; @@ -92,24 +152,48 @@ public ProjectConfig(String accountId, String projectId, String version, String this.revision = revision; this.anonymizeIP = anonymizeIP; + this.attributes = Collections.unmodifiableList(attributes); + this.audiences = Collections.unmodifiableList(audiences); + this.events = Collections.unmodifiableList(events); + if (featureFlags == null) { + this.featureFlags = Collections.emptyList(); + } + else { + this.featureFlags = Collections.unmodifiableList(featureFlags); + } + if (rollouts == null) { + this.rollouts = Collections.emptyList(); + } + else { + this.rollouts = Collections.unmodifiableList(rollouts); + } + this.groups = Collections.unmodifiableList(groups); + List allExperiments = new ArrayList(); allExperiments.addAll(experiments); allExperiments.addAll(aggregateGroupExperiments(groups)); this.experiments = Collections.unmodifiableList(allExperiments); - this.attributes = Collections.unmodifiableList(attributes); - this.events = Collections.unmodifiableList(eventType); - this.audiences = Collections.unmodifiableList(audiences); + + Map variationIdToExperimentMap = new HashMap(); + for (Experiment experiment : this.experiments) { + for (Variation variation: experiment.getVariations()) { + variationIdToExperimentMap.put(variation.getId(), experiment); + } + } + this.variationIdToExperimentMapping = Collections.unmodifiableMap(variationIdToExperimentMap); // generate the name mappers - this.experimentKeyMapping = ProjectConfigUtils.generateNameMapping(this.experiments); this.attributeKeyMapping = ProjectConfigUtils.generateNameMapping(attributes); - this.eventNameMapping = ProjectConfigUtils.generateNameMapping(events); + this.eventNameMapping = ProjectConfigUtils.generateNameMapping(this.events); + this.experimentKeyMapping = ProjectConfigUtils.generateNameMapping(this.experiments); + this.featureKeyMapping = ProjectConfigUtils.generateNameMapping(this.featureFlags); // generate audience id to audience mapping this.audienceIdMapping = ProjectConfigUtils.generateIdMapping(audiences); this.experimentIdMapping = ProjectConfigUtils.generateIdMapping(this.experiments); this.groupIdMapping = ProjectConfigUtils.generateIdMapping(groups); + this.rolloutIdMapping = ProjectConfigUtils.generateIdMapping(this.rollouts); if (liveVariables == null) { this.liveVariables = null; @@ -126,6 +210,10 @@ public ProjectConfig(String accountId, String projectId, String version, String } } + public @Nullable Experiment getExperimentForVariationId(String variationId) { + return this.variationIdToExperimentMapping.get(variationId); + } + private List aggregateGroupExperiments(List groups) { List groupExperiments = new ArrayList(); for (Group group : groups) { @@ -178,6 +266,14 @@ public List getExperimentsForEventKey(String eventKey) { return Collections.emptyList(); } + public List getFeatureFlags() { + return featureFlags; + } + + public List getRollouts() { + return rollouts; + } + public List getAttributes() { return attributes; } @@ -224,6 +320,10 @@ public Map getGroupIdMapping() { return groupIdMapping; } + public Map getRolloutIdMapping() { + return rolloutIdMapping; + } + public Map getLiveVariableKeyMapping() { return liveVariableKeyMapping; } @@ -236,6 +336,140 @@ public Map> getVariationToLiveVar return variationToLiveVariableUsageInstanceMapping; } + public Map getFeatureKeyMapping() { + return featureKeyMapping; + } + + public ConcurrentHashMap> getForcedVariationMapping() { return forcedVariationMapping; } + + /** + * Force a user into a variation for a given experiment. + * The forced variation value does not persist across application launches. + * If the experiment key is not in the project file, this call fails and returns false. + * + * @param experimentKey The key for the experiment. + * @param userId The user ID to be used for bucketing. + * @param variationKey The variation key to force the user into. If the variation key is null + * then the forcedVariation for that experiment is removed. + * + * @return boolean A boolean value that indicates if the set completed successfully. + */ + public boolean setForcedVariation(@Nonnull String experimentKey, + @Nonnull String userId, + @Nullable String variationKey) { + + // if the experiment is not a valid experiment key, don't set it. + Experiment experiment = getExperimentKeyMapping().get(experimentKey); + if (experiment == null){ + logger.error("Experiment {} does not exist in ProjectConfig for project {}", experimentKey, projectId); + return false; + } + + Variation variation = null; + + // keep in mind that you can pass in a variationKey that is null if you want to + // remove the variation. + if (variationKey != null) { + variation = experiment.getVariationKeyToVariationMap().get(variationKey); + // if the variation is not part of the experiment, return false. + if (variation == null) { + logger.error("Variation {} does not exist for experiment {}", variationKey, experimentKey); + return false; + } + } + + // if the user id is invalid, return false. + if (userId == null || userId.trim().isEmpty()) { + logger.error("User ID is invalid"); + return false; + } + + ConcurrentHashMap experimentToVariation; + if (!forcedVariationMapping.containsKey(userId)) { + forcedVariationMapping.putIfAbsent(userId, new ConcurrentHashMap()); + } + experimentToVariation = forcedVariationMapping.get(userId); + + boolean retVal = true; + // if it is null remove the variation if it exists. + if (variationKey == null) { + String removedVariationId = experimentToVariation.remove(experiment.getId()); + if (removedVariationId != null) { + Variation removedVariation = experiment.getVariationIdToVariationMap().get(removedVariationId); + if (removedVariation != null) { + logger.debug("Variation mapped to experiment \"{}\" has been removed for user \"{}\"", experiment.getKey(), userId); + } + else { + logger.debug("Removed forced variation that did not exist in experiment"); + } + } + else { + logger.debug("No variation for experiment {}", experimentKey); + retVal = false; + } + } + else { + String previous = experimentToVariation.put(experiment.getId(), variation.getId()); + logger.debug("Set variation \"{}\" for experiment \"{}\" and user \"{}\" in the forced variation map.", + variation.getKey(), experiment.getKey(), userId); + if (previous != null) { + Variation previousVariation = experiment.getVariationIdToVariationMap().get(previous); + if (previousVariation != null) { + logger.debug("forced variation {} replaced forced variation {} in forced variation map.", + variation.getKey(), previousVariation.getKey()); + } + } + } + + return retVal; + } + + /** + * Gets the forced variation for a given user and experiment. + * + * @param experimentKey The key for the experiment. + * @param userId The user ID to be used for bucketing. + * + * @return The variation the user was bucketed into. This value can be null if the + * forced variation fails. + */ + public @Nullable Variation getForcedVariation(@Nonnull String experimentKey, + @Nonnull String userId) { + + // if the user id is invalid, return false. + if (userId == null || userId.trim().isEmpty()) { + logger.error("User ID is invalid"); + return null; + } + + if (experimentKey == null || experimentKey.isEmpty()) { + logger.error("experiment key is invalid"); + return null; + } + + Map experimentToVariation = getForcedVariationMapping().get(userId); + if (experimentToVariation != null) { + Experiment experiment = getExperimentKeyMapping().get(experimentKey); + if (experiment == null) { + logger.debug("No experiment \"{}\" mapped to user \"{}\" in the forced variation map ", experimentKey, userId); + return null; + } + String variationId = experimentToVariation.get(experiment.getId()); + if (variationId != null) { + Variation variation = experiment.getVariationIdToVariationMap().get(variationId); + if (variation != null) { + logger.debug("Variation \"{}\" is mapped to experiment \"{}\" and user \"{}\" in the forced variation map", + variation.getKey(), experimentKey, userId); + return variation; + } + } + else { + logger.debug("No variation for experiment \"{}\" mapped to user \"{}\" in the forced variation map ", experimentKey, userId); + } + } + return null; + } + @Override public String toString() { return "ProjectConfig{" + @@ -243,22 +477,28 @@ public String toString() { ", projectId='" + projectId + '\'' + ", revision='" + revision + '\'' + ", version='" + version + '\'' + - ", anonymizeIP='" + anonymizeIP + '\'' + - ", groups=" + groups + - ", experiments=" + experiments + + ", anonymizeIP=" + anonymizeIP + ", attributes=" + attributes + - ", events=" + events + ", audiences=" + audiences + + ", events=" + events + + ", experiments=" + experiments + + ", featureFlags=" + featureFlags + + ", groups=" + groups + ", liveVariables=" + liveVariables + - ", experimentKeyMapping=" + experimentKeyMapping + + ", rollouts=" + rollouts + ", attributeKeyMapping=" + attributeKeyMapping + - ", liveVariableKeyMapping=" + liveVariableKeyMapping + ", eventNameMapping=" + eventNameMapping + + ", experimentKeyMapping=" + experimentKeyMapping + + ", featureKeyMapping=" + featureKeyMapping + + ", liveVariableKeyMapping=" + liveVariableKeyMapping + ", audienceIdMapping=" + audienceIdMapping + ", experimentIdMapping=" + experimentIdMapping + ", groupIdMapping=" + groupIdMapping + + ", rolloutIdMapping=" + rolloutIdMapping + ", liveVariableIdToExperimentsMapping=" + liveVariableIdToExperimentsMapping + ", variationToLiveVariableUsageInstanceMapping=" + variationToLiveVariableUsageInstanceMapping + + ", forcedVariationMapping=" + forcedVariationMapping + + ", variationIdToExperimentMapping=" + variationIdToExperimentMapping + '}'; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/Rollout.java b/core-api/src/main/java/com/optimizely/ab/config/Rollout.java new file mode 100644 index 000000000..b36f33838 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/Rollout.java @@ -0,0 +1,61 @@ +/** + * + * Copyright 2017, 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.config; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import javax.annotation.concurrent.Immutable; +import java.util.List; + +/** + * Represents a Optimizely Rollout configuration + * + * @see Project JSON + */ +@Immutable +@JsonIgnoreProperties(ignoreUnknown = true) +public class Rollout implements IdMapped { + + private final String id; + private final List experiments; + + @JsonCreator + public Rollout(@JsonProperty("id") String id, + @JsonProperty("experiments") List experiments) { + this.id = id; + this.experiments = experiments; + } + + @Override + public String getId() { + return id; + } + + public List getExperiments() { + return experiments; + } + + @Override + public String toString() { + return "Rollout{" + + "id='" + id + '\'' + + ", experiments=" + experiments + + '}'; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/Variation.java b/core-api/src/main/java/com/optimizely/ab/config/Variation.java index 0991a0a5e..02db51eab 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/Variation.java +++ b/core-api/src/main/java/com/optimizely/ab/config/Variation.java @@ -23,7 +23,9 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; +import java.util.Collections; import java.util.List; +import java.util.Map; /** * Represents the Optimizely Variation configuration. @@ -36,6 +38,7 @@ public class Variation implements IdKeyMapped { private final String id; private final String key; private final List liveVariableUsageInstances; + private final Map variableIdToLiveVariableUsageInstanceMap; public Variation(String id, String key) { this(id, key, null); @@ -47,7 +50,13 @@ public Variation(@JsonProperty("id") String id, @JsonProperty("variables") List liveVariableUsageInstances) { this.id = id; this.key = key; - this.liveVariableUsageInstances = liveVariableUsageInstances; + if (liveVariableUsageInstances == null) { + this.liveVariableUsageInstances = Collections.emptyList(); + } + else { + this.liveVariableUsageInstances = liveVariableUsageInstances; + } + this.variableIdToLiveVariableUsageInstanceMap = ProjectConfigUtils.generateIdMapping(this.liveVariableUsageInstances); } public @Nonnull String getId() { @@ -62,6 +71,10 @@ public Variation(@JsonProperty("id") String id, return liveVariableUsageInstances; } + public Map getVariableIdToLiveVariableUsageInstanceMap() { + return variableIdToLiveVariableUsageInstanceMap; + } + public boolean is(String otherKey) { return key.equals(otherKey); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/FeatureFlagGsonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/FeatureFlagGsonDeserializer.java new file mode 100644 index 000000000..e26623a8b --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/FeatureFlagGsonDeserializer.java @@ -0,0 +1,36 @@ +/** + * + * Copyright 2017, 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.config.parser; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.optimizely.ab.config.FeatureFlag; + +import java.lang.reflect.Type; + +public class FeatureFlagGsonDeserializer implements JsonDeserializer { + @Override + public FeatureFlag deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + + JsonObject jsonObject = json.getAsJsonObject(); + return GsonHelpers.parseFeatureFlag(jsonObject, context); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/GsonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/GsonConfigParser.java index b87c0a16a..e20146520 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/GsonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/GsonConfigParser.java @@ -18,11 +18,11 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; - import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.FeatureFlag; import com.optimizely.ab.config.Group; -import com.optimizely.ab.config.audience.Audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.audience.Audience; import javax.annotation.Nonnull; @@ -40,11 +40,12 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse throw new ConfigParseException("Unable to parse empty json."); } Gson gson = new GsonBuilder() - .registerTypeAdapter(ProjectConfig.class, new ProjectConfigGsonDeserializer()) - .registerTypeAdapter(Audience.class, new AudienceGsonDeserializer()) - .registerTypeAdapter(Group.class, new GroupGsonDeserializer()) - .registerTypeAdapter(Experiment.class, new ExperimentGsonDeserializer()) - .create(); + .registerTypeAdapter(Audience.class, new AudienceGsonDeserializer()) + .registerTypeAdapter(Experiment.class, new ExperimentGsonDeserializer()) + .registerTypeAdapter(FeatureFlag.class, new FeatureFlagGsonDeserializer()) + .registerTypeAdapter(Group.class, new GroupGsonDeserializer()) + .registerTypeAdapter(ProjectConfig.class, new ProjectConfigGsonDeserializer()) + .create(); try { return gson.fromJson(json, ProjectConfig.class); diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java b/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java index 7ebdb02d2..5fca45b55 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java @@ -20,23 +20,30 @@ import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; import com.google.gson.reflect.TypeToken; - +import com.optimizely.ab.bucketing.DecisionService; import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.Experiment.ExperimentStatus; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.LiveVariable; import com.optimizely.ab.config.LiveVariableUsageInstance; import com.optimizely.ab.config.TrafficAllocation; import com.optimizely.ab.config.Variation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.lang.reflect.Type; import java.util.ArrayList; -import java.util.List; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Set; final class GsonHelpers { + private static final Logger logger = LoggerFactory.getLogger(DecisionService.class); + private static List parseVariations(JsonArray variationJson, JsonDeserializationContext context) { List variations = new ArrayList(variationJson.size()); for (Object obj : variationJson) { @@ -114,4 +121,35 @@ static Experiment parseExperiment(JsonObject experimentJson, String groupId, Jso static Experiment parseExperiment(JsonObject experimentJson, JsonDeserializationContext context) { return parseExperiment(experimentJson, "", context); } + + static FeatureFlag parseFeatureFlag(JsonObject featureFlagJson, JsonDeserializationContext context) { + String id = featureFlagJson.get("id").getAsString(); + String key = featureFlagJson.get("key").getAsString(); + String layerId = featureFlagJson.get("rolloutId").getAsString(); + + JsonArray experimentIdsJson = featureFlagJson.getAsJsonArray("experimentIds"); + List experimentIds = new ArrayList(); + for (JsonElement experimentIdObj : experimentIdsJson) { + experimentIds.add(experimentIdObj.getAsString()); + } + + List liveVariables = new ArrayList(); + try { + Type liveVariableType = new TypeToken>() {}.getType(); + liveVariables = context.deserialize(featureFlagJson.getAsJsonArray("variables"), + liveVariableType); + } + catch (JsonParseException exception) { + logger.warn("Unable to parse variables for feature \"" + key + + "\". JsonParseException: " + exception); + } + + return new FeatureFlag( + id, + key, + layerId, + experimentIds, + liveVariables + ); + } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java index 56dc808d2..1b2af1079 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java @@ -17,35 +17,35 @@ package com.optimizely.ab.config.parser; import com.optimizely.ab.config.Attribute; -import com.optimizely.ab.config.audience.AndCondition; -import com.optimizely.ab.config.audience.Audience; -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 com.optimizely.ab.config.EventType; import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.Experiment.ExperimentStatus; +import com.optimizely.ab.config.FeatureFlag; import com.optimizely.ab.config.Group; import com.optimizely.ab.config.LiveVariable; -import com.optimizely.ab.config.LiveVariableUsageInstance; import com.optimizely.ab.config.LiveVariable.VariableStatus; import com.optimizely.ab.config.LiveVariable.VariableType; +import com.optimizely.ab.config.LiveVariableUsageInstance; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.Rollout; import com.optimizely.ab.config.TrafficAllocation; import com.optimizely.ab.config.Variation; - +import com.optimizely.ab.config.audience.AndCondition; +import com.optimizely.ab.config.audience.Audience; +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 org.json.JSONArray; import org.json.JSONObject; +import javax.annotation.Nonnull; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; -import javax.annotation.Nonnull; - /** * {@code org.json}-based config parser implementation. */ @@ -60,15 +60,12 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse String projectId = rootObject.getString("projectId"); String revision = rootObject.getString("revision"); String version = rootObject.getString("version"); + int datafileVersion = Integer.parseInt(version); List experiments = parseExperiments(rootObject.getJSONArray("experiments")); List attributes; - if (version.equals(ProjectConfig.Version.V1.toString())) { - attributes = parseAttributes(rootObject.getJSONArray("dimensions")); - } else { - attributes = parseAttributes(rootObject.getJSONArray("attributes")); - } + attributes = parseAttributes(rootObject.getJSONArray("attributes")); List events = parseEvents(rootObject.getJSONArray("events")); List audiences = parseAudiences(rootObject.getJSONArray("audiences")); @@ -76,14 +73,34 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse boolean anonymizeIP = false; List liveVariables = null; - if (version.equals(ProjectConfig.Version.V3.toString())) { + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V3.toString())) { liveVariables = parseLiveVariables(rootObject.getJSONArray("variables")); anonymizeIP = rootObject.getBoolean("anonymizeIP"); } - return new ProjectConfig(accountId, projectId, version, revision, groups, experiments, attributes, events, - audiences, anonymizeIP, liveVariables); + List featureFlags = null; + List rollouts = null; + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { + featureFlags = parseFeatureFlags(rootObject.getJSONArray("featureFlags")); + rollouts = parseRollouts(rootObject.getJSONArray("rollouts")); + } + + return new ProjectConfig( + accountId, + anonymizeIP, + projectId, + revision, + version, + attributes, + audiences, + events, + experiments, + featureFlags, + groups, + liveVariables, + rollouts + ); } catch (Exception e) { throw new ConfigParseException("Unable to parse datafile: " + json, e); } @@ -127,6 +144,41 @@ private List parseExperiments(JSONArray experimentJson, String group return experiments; } + private List parseExperimentIds(JSONArray experimentIdsJson) { + ArrayList experimentIds = new ArrayList(experimentIdsJson.length()); + + for (Object experimentIdObj : experimentIdsJson) { + experimentIds.add((String) experimentIdObj); + } + + return experimentIds; + } + + private List parseFeatureFlags(JSONArray featureFlagJson) { + List featureFlags = new ArrayList(featureFlagJson.length()); + + for (Object obj : featureFlagJson) { + JSONObject featureFlagObject = (JSONObject) obj; + String id = featureFlagObject.getString("id"); + String key = featureFlagObject.getString("key"); + String layerId = featureFlagObject.getString("rolloutId"); + + List experimentIds = parseExperimentIds(featureFlagObject.getJSONArray("experimentIds")); + + List variables = parseLiveVariables(featureFlagObject.getJSONArray("variables")); + + featureFlags.add(new FeatureFlag( + id, + key, + layerId, + experimentIds, + variables + )); + } + + return featureFlags; + } + private List parseVariations(JSONArray variationJson) { List variations = new ArrayList(variationJson.length()); @@ -191,12 +243,7 @@ private List parseEvents(JSONArray eventJson) { for (Object obj : eventJson) { JSONObject eventObject = (JSONObject)obj; - JSONArray experimentIdsJson = eventObject.getJSONArray("experimentIds"); - List experimentIds = new ArrayList(experimentIdsJson.length()); - - for (Object experimentIdObj : experimentIdsJson) { - experimentIds.add((String)experimentIdObj); - } + List experimentIds = parseExperimentIds(eventObject.getJSONArray("experimentIds")); String id = eventObject.getString("id"); String key = eventObject.getString("key"); @@ -277,7 +324,10 @@ private List parseLiveVariables(JSONArray liveVariablesJson) { String key = liveVariableObject.getString("key"); String defaultValue = liveVariableObject.getString("defaultValue"); VariableType type = VariableType.fromString(liveVariableObject.getString("type")); - VariableStatus status = VariableStatus.fromString(liveVariableObject.getString("status")); + VariableStatus status = null; + if (liveVariableObject.has("status")) { + status = VariableStatus.fromString(liveVariableObject.getString("status")); + } liveVariables.add(new LiveVariable(id, key, defaultValue, status, type)); } @@ -298,4 +348,18 @@ private List parseLiveVariableInstances(JSONArray liv return liveVariableUsageInstances; } + + private List parseRollouts(JSONArray rolloutsJson) { + List rollouts = new ArrayList(rolloutsJson.length()); + + for (Object obj : rolloutsJson) { + JSONObject rolloutObject = (JSONObject) obj; + String id = rolloutObject.getString("id"); + List experiments = parseExperiments(rolloutObject.getJSONArray("experiments")); + + rollouts.add(new Rollout(id, experiments)); + } + + return rollouts; + } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java index 7ed9f5b52..be106665d 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java @@ -20,12 +20,14 @@ import com.optimizely.ab.config.EventType; import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.Experiment.ExperimentStatus; +import com.optimizely.ab.config.FeatureFlag; import com.optimizely.ab.config.Group; import com.optimizely.ab.config.LiveVariable; import com.optimizely.ab.config.LiveVariable.VariableStatus; import com.optimizely.ab.config.LiveVariable.VariableType; import com.optimizely.ab.config.LiveVariableUsageInstance; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.Rollout; import com.optimizely.ab.config.TrafficAllocation; import com.optimizely.ab.config.Variation; import com.optimizely.ab.config.audience.AndCondition; @@ -60,15 +62,12 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse String projectId = (String)rootObject.get("projectId"); String revision = (String)rootObject.get("revision"); String version = (String)rootObject.get("version"); + int datafileVersion = Integer.parseInt(version); List experiments = parseExperiments((JSONArray)rootObject.get("experiments")); List attributes; - if (version.equals(ProjectConfig.Version.V1.toString())) { - throw new ConfigParseException("The Java SDK no longer supports datafile version 1. If you wish to use a Classic Custom Project, please use Java SDK version 1.6 or below."); - } else { - attributes = parseAttributes((JSONArray)rootObject.get("attributes")); - } + attributes = parseAttributes((JSONArray)rootObject.get("attributes")); List events = parseEvents((JSONArray)rootObject.get("events")); List audiences = parseAudiences((JSONArray)parser.parse(rootObject.get("audiences").toString())); @@ -76,14 +75,34 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse boolean anonymizeIP = false; List liveVariables = null; - if (version.equals(ProjectConfig.Version.V3.toString())) { + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V3.toString())) { liveVariables = parseLiveVariables((JSONArray)rootObject.get("variables")); anonymizeIP = (Boolean)rootObject.get("anonymizeIP"); } - return new ProjectConfig(accountId, projectId, version, revision, groups, experiments, attributes, events, - audiences, anonymizeIP, liveVariables); + List featureFlags = null; + List rollouts = null; + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { + featureFlags = parseFeatureFlags((JSONArray) rootObject.get("featureFlags")); + rollouts = parseRollouts((JSONArray) rootObject.get("rollouts")); + } + + return new ProjectConfig( + accountId, + anonymizeIP, + projectId, + revision, + version, + attributes, + audiences, + events, + experiments, + featureFlags, + groups, + liveVariables, + rollouts + ); } catch (Exception e) { throw new ConfigParseException("Unable to parse datafile: " + json, e); } @@ -129,6 +148,42 @@ private List parseExperiments(JSONArray experimentJson, String group return experiments; } + private List parseExperimentIds(JSONArray experimentIdsJsonArray) { + List experimentIds = new ArrayList(experimentIdsJsonArray.size()); + + for (Object experimentIdObj : experimentIdsJsonArray) { + experimentIds.add((String)experimentIdObj); + } + + return experimentIds; + } + + private List parseFeatureFlags(JSONArray featureFlagJson) { + List featureFlags = new ArrayList(featureFlagJson.size()); + + for (Object obj : featureFlagJson) { + JSONObject featureFlagObject = (JSONObject)obj; + String id = (String)featureFlagObject.get("id"); + String key = (String)featureFlagObject.get("key"); + String layerId = (String)featureFlagObject.get("rolloutId"); + + JSONArray experimentIdsJsonArray = (JSONArray)featureFlagObject.get("experimentIds"); + List experimentIds = parseExperimentIds(experimentIdsJsonArray); + + List liveVariables = parseLiveVariables((JSONArray) featureFlagObject.get("variables")); + + featureFlags.add(new FeatureFlag( + id, + key, + layerId, + experimentIds, + liveVariables + )); + } + + return featureFlags; + } + private List parseVariations(JSONArray variationJson) { List variations = new ArrayList(variationJson.size()); @@ -193,11 +248,7 @@ private List parseEvents(JSONArray eventJson) { for (Object obj : eventJson) { JSONObject eventObject = (JSONObject)obj; JSONArray experimentIdsJson = (JSONArray)eventObject.get("experimentIds"); - List experimentIds = new ArrayList(experimentIdsJson.size()); - - for (Object experimentIdObj : experimentIdsJson) { - experimentIds.add((String)experimentIdObj); - } + List experimentIds = parseExperimentIds(experimentIdsJson); String id = (String)eventObject.get("id"); String key = (String)eventObject.get("key"); @@ -301,5 +352,19 @@ private List parseLiveVariableInstances(JSONArray liv return liveVariableUsageInstances; } + + private List parseRollouts(JSONArray rolloutsJson) { + List rollouts = new ArrayList(rolloutsJson.size()); + + for (Object obj : rolloutsJson) { + JSONObject rolloutObject = (JSONObject) obj; + String id = (String) rolloutObject.get("id"); + List experiments = parseExperiments((JSONArray) rolloutObject.get("experiments")); + + rollouts.add(new Rollout(id, experiments)); + } + + return rollouts; + } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigGsonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigGsonDeserializer.java index ea3faad91..c9718d851 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigGsonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigGsonDeserializer.java @@ -25,9 +25,11 @@ import com.optimizely.ab.config.Attribute; import com.optimizely.ab.config.EventType; import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.FeatureFlag; import com.optimizely.ab.config.Group; import com.optimizely.ab.config.LiveVariable; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.Rollout; import com.optimizely.ab.config.audience.Audience; import java.lang.reflect.Type; @@ -47,6 +49,7 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa String projectId = jsonObject.get("projectId").getAsString(); String revision = jsonObject.get("revision").getAsString(); String version = jsonObject.get("version").getAsString(); + int datafileVersion = Integer.parseInt(version); // generic list type tokens Type groupsType = new TypeToken>() {}.getType(); @@ -60,11 +63,7 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa context.deserialize(jsonObject.get("experiments").getAsJsonArray(), experimentsType); List attributes; - if (version.equals(ProjectConfig.Version.V1.toString())) { - attributes = context.deserialize(jsonObject.get("dimensions"), attributesType); - } else { - attributes = context.deserialize(jsonObject.get("attributes"), attributesType); - } + attributes = context.deserialize(jsonObject.get("attributes"), attributesType); List events = context.deserialize(jsonObject.get("events").getAsJsonArray(), eventsType); @@ -74,14 +73,36 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa boolean anonymizeIP = false; // live variables should be null if using V2 List liveVariables = null; - if (version.equals(ProjectConfig.Version.V3.toString())) { + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V3.toString())) { Type liveVariablesType = new TypeToken>() {}.getType(); liveVariables = context.deserialize(jsonObject.getAsJsonArray("variables"), liveVariablesType); anonymizeIP = jsonObject.get("anonymizeIP").getAsBoolean(); } - return new ProjectConfig(accountId, projectId, version, revision, groups, experiments, attributes, events, - audiences, anonymizeIP, liveVariables); + List featureFlags = null; + List rollouts = null; + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { + Type featureFlagsType = new TypeToken>() {}.getType(); + featureFlags = context.deserialize(jsonObject.getAsJsonArray("featureFlags"), featureFlagsType); + Type rolloutsType = new TypeToken>() {}.getType(); + rollouts = context.deserialize(jsonObject.get("rollouts").getAsJsonArray(), rolloutsType); + } + + return new ProjectConfig( + accountId, + anonymizeIP, + projectId, + revision, + version, + attributes, + audiences, + events, + experiments, + featureFlags, + groups, + liveVariables, + rollouts + ); } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigJacksonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigJacksonDeserializer.java index 38c844457..6ebd3c4ec 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigJacksonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigJacksonDeserializer.java @@ -23,14 +23,15 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; - import com.optimizely.ab.config.Attribute; -import com.optimizely.ab.config.audience.Audience; import com.optimizely.ab.config.EventType; import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.FeatureFlag; import com.optimizely.ab.config.Group; import com.optimizely.ab.config.LiveVariable; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.Rollout; +import com.optimizely.ab.config.audience.Audience; import java.io.IOException; import java.util.List; @@ -51,17 +52,14 @@ public ProjectConfig deserialize(JsonParser parser, DeserializationContext conte String projectId = node.get("projectId").textValue(); String revision = node.get("revision").textValue(); String version = node.get("version").textValue(); + int datafileVersion = Integer.parseInt(version); List groups = mapper.readValue(node.get("groups").toString(), new TypeReference>() {}); List experiments = mapper.readValue(node.get("experiments").toString(), new TypeReference>() {}); List attributes; - if (version.equals(ProjectConfig.Version.V1.toString())) { - attributes = mapper.readValue(node.get("dimensions").toString(), new TypeReference>() {}); - } else { - attributes = mapper.readValue(node.get("attributes").toString(), new TypeReference>() {}); - } + attributes = mapper.readValue(node.get("attributes").toString(), new TypeReference>() {}); List events = mapper.readValue(node.get("events").toString(), new TypeReference>() {}); @@ -70,13 +68,35 @@ public ProjectConfig deserialize(JsonParser parser, DeserializationContext conte boolean anonymizeIP = false; List liveVariables = null; - if (version.equals(ProjectConfig.Version.V3.toString())) { + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V3.toString())) { liveVariables = mapper.readValue(node.get("variables").toString(), new TypeReference>() {}); anonymizeIP = node.get("anonymizeIP").asBoolean(); } - return new ProjectConfig(accountId, projectId, version, revision, groups, experiments, attributes, events, - audiences, anonymizeIP, liveVariables); + List featureFlags = null; + List rollouts = null; + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { + featureFlags = mapper.readValue(node.get("featureFlags").toString(), + new TypeReference>() {}); + rollouts = mapper.readValue(node.get("rollouts").toString(), + new TypeReference>(){}); + } + + return new ProjectConfig( + accountId, + anonymizeIP, + projectId, + revision, + version, + attributes, + audiences, + events, + experiments, + featureFlags, + groups, + liveVariables, + rollouts + ); } } \ No newline at end of file diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java b/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java index c86568fe8..b600dd763 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java @@ -19,8 +19,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.lang.Exception; + import java.io.BufferedReader; -import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.Charset; @@ -42,17 +43,17 @@ private static String readVersionNumber() { Charset.forName("UTF-8"))); try { return bufferedReader.readLine(); - } catch (IOException e) { + } catch (Exception e) { logger.error("unable to read version number"); return "unknown"; } finally { try { bufferedReader.close(); - } catch (IOException e) { + } catch (Exception e) { logger.error("unable to close reader cleanly"); } } } private BuildVersionInfo() { } -} \ No newline at end of file +} diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV2.java b/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV2.java index a4a25a671..ae13183ae 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV2.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV2.java @@ -112,10 +112,16 @@ public LogEvent createConversionEvent(@Nonnull ProjectConfig projectConfig, List layerStates = createLayerStates(projectConfig, experimentVariationMap); - Long eventValue = EventTagUtils.getRevenueValue(eventTags); - List eventMetrics = Collections.emptyList(); - if (eventValue != null) { - eventMetrics = Collections.singletonList(new EventMetric(EventMetric.REVENUE_METRIC_TYPE, eventValue)); + List eventMetrics = new ArrayList(); + + Long revenueValue = EventTagUtils.getRevenueValue(eventTags); + if (revenueValue != null) { + eventMetrics.add(new EventMetric(EventMetric.REVENUE_METRIC_TYPE, revenueValue)); + } + + Double numericMetricValue = EventTagUtils.getNumericValue(eventTags); + if (numericMetricValue != null) { + eventMetrics.add(new EventMetric(EventMetric.NUMERIC_METRIC_TYPE, numericMetricValue)); } Conversion conversionPayload = new Conversion(); diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventMetric.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventMetric.java index 5303871c9..f77f7e578 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventMetric.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventMetric.java @@ -19,13 +19,14 @@ public class EventMetric { public static final String REVENUE_METRIC_TYPE = "revenue"; + public static final String NUMERIC_METRIC_TYPE = "value"; private String name; - private long value; + private Number value; public EventMetric() { } - public EventMetric(String name, long value) { + public EventMetric(String name, Number value) { this.name = name; this.value = value; } @@ -38,30 +39,29 @@ public void setName(String name) { this.name = name; } - public long getValue() { + public Number getValue() { return value; } - public void setValue(long value) { + public void setValue(Number value) { this.value = value; } - @Override - public boolean equals(Object other) { - if (!(other instanceof EventMetric)) - return false; + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; - EventMetric otherEventMetric = (EventMetric)other; + EventMetric that = (EventMetric) obj; - return name.equals(otherEventMetric.getName()) && value == otherEventMetric.getValue(); + if (!name.equals(that.name)) return false; + return value.equals(that.value); } - @Override public int hashCode() { - int result = name != null ? name.hashCode() : 0; - result = 31 * result + (int) (value ^ (value >>> 32)); + int result = name.hashCode(); + result = 31 * result + value.hashCode(); return result; } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Feature.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Feature.java index 22df21df9..161ee2271 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Feature.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Feature.java @@ -103,7 +103,7 @@ public int hashCode() { @Override public String toString() { - return "Feature{" + + return "FeatureFlag{" + "id='" + id + '\'' + ", name='" + name + '\'' + ", type='" + type + '\'' + diff --git a/core-api/src/main/java/com/optimizely/ab/internal/EventTagUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/EventTagUtils.java index 42dacfc5e..e7c0359c5 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/EventTagUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/EventTagUtils.java @@ -47,4 +47,22 @@ public static Long getRevenueValue(@Nonnull Map eventTags) { } return eventValue; } + + /** + * Fetch the numeric metric value from event tags. "value" is a reserved keyword. + */ + public static Double getNumericValue(@Nonnull Map eventTags) { + Double eventValue = null; + if (eventTags.containsKey(ReservedEventKey.VALUE.toString())) { + Object rawValue = eventTags.get(ReservedEventKey.VALUE.toString()); + if (rawValue instanceof Number) { + eventValue = ((Number) rawValue).doubleValue(); + logger.info("Parsed numeric metric value \"{}\" from event tags.", eventValue); + } else { + logger.warn("Failed to parse numeric metric value \"{}\" from event tags.", rawValue); + } + } + + return eventValue; + } } diff --git a/core-api/src/main/java/com/optimizely/ab/internal/ReservedEventKey.java b/core-api/src/main/java/com/optimizely/ab/internal/ReservedEventKey.java index ecc30f5b6..91d8729d6 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/ReservedEventKey.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/ReservedEventKey.java @@ -17,7 +17,8 @@ package com.optimizely.ab.internal; public enum ReservedEventKey { - REVENUE("revenue"); + REVENUE("revenue"), + VALUE("value"); private final String key; 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 ddecfcd3d..e40e5eb1c 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -19,13 +19,17 @@ import ch.qos.logback.classic.Level; import com.google.common.collect.ImmutableMap; import com.optimizely.ab.bucketing.Bucketer; +import com.optimizely.ab.bucketing.DecisionService; import com.optimizely.ab.config.Attribute; import com.optimizely.ab.config.EventType; import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.LiveVariable; import com.optimizely.ab.config.LiveVariableUsageInstance; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.TrafficAllocation; import com.optimizely.ab.config.Variation; +import com.optimizely.ab.config.parser.ConfigParseException; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.error.NoOpErrorHandler; import com.optimizely.ab.error.RaiseExceptionErrorHandler; @@ -33,9 +37,7 @@ import com.optimizely.ab.event.LogEvent; import com.optimizely.ab.event.internal.EventBuilder; import com.optimizely.ab.event.internal.EventBuilderV2; -import com.optimizely.ab.internal.ExperimentUtils; import com.optimizely.ab.internal.LogbackVerifier; -import com.optimizely.ab.internal.ReservedEventKey; import com.optimizely.ab.notification.NotificationListener; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Rule; @@ -50,6 +52,7 @@ import org.mockito.junit.MockitoRule; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -63,27 +66,55 @@ import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigV3; import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV2; import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV3; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV4; import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV3; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; +import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_HOUSE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_GRYFFINDOR_VALUE; +import static com.optimizely.ab.config.ValidProjectConfigV4.EVENT_BASIC_EVENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.EVENT_LAUNCHED_EXPERIMENT_ONLY_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_BASIC_EXPERIMENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_LAUNCHED_EXPERIMENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_PAUSED_EXPERIMENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_FLAG_MULTI_VARIATE_FEATURE; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_MULTI_VARIATE_FEATURE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_SINGLE_VARIABLE_DOUBLE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GRED; +import static com.optimizely.ab.config.ValidProjectConfigV4.PAUSED_EXPERIMENT_FORCED_VARIATION_USER_ID_CONTROL; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_BOOLEAN_VARIABLE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_DOUBLE_DEFAULT_VALUE; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_DOUBLE_VARIABLE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_FIRST_LETTER_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIATION_MULTIVARIATE_EXPERIMENT_GRED; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY; import static com.optimizely.ab.event.LogEvent.RequestMethod; import static com.optimizely.ab.event.internal.EventBuilderV2Test.createExperimentVariationMap; import static java.util.Arrays.asList; +import static junit.framework.TestCase.assertTrue; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasEntry; import static org.hamcrest.Matchers.hasKey; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; import static org.junit.Assume.assumeTrue; import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyMapOf; import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -110,6 +141,13 @@ public static Collection data() throws IOException { noAudienceProjectConfigJsonV3(), validProjectConfigV3(), noAudienceProjectConfigV3() + }, + { + 4, + validConfigJsonV4(), + validConfigJsonV4(), + validProjectConfigV4(), + validProjectConfigV4() } }); } @@ -126,6 +164,7 @@ public static Collection data() throws IOException { @Mock EventHandler mockEventHandler; @Mock Bucketer mockBucketer; + @Mock DecisionService mockDecisionService; @Mock ErrorHandler mockErrorHandler; private static final String genericUserId = "genericUserId"; @@ -155,7 +194,16 @@ public OptimizelyTest(int datafileVersion, */ @Test public void activateEndToEnd() throws Exception { - Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); + Experiment activatedExperiment; + Map testUserAttributes = new HashMap(); + if(datafileVersion >= 4) { + activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + } + else { + activatedExperiment = validProjectConfig.getExperiments().get(0); + testUserAttributes.put("browser_type", "chrome"); + } Variation bucketedVariation = activatedExperiment.getVariations().get(0); EventBuilder mockEventBuilder = mock(EventBuilder.class); @@ -166,10 +214,8 @@ public void activateEndToEnd() throws Exception { .withErrorHandler(mockErrorHandler) .build(); - Map testUserAttributes = new HashMap(); - testUserAttributes.put("browser_type", "chrome"); - Map testParams = new HashMap(); + testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(validProjectConfig, activatedExperiment, bucketedVariation, "userId", @@ -179,7 +225,8 @@ public void activateEndToEnd() throws Exception { when(mockBucketer.bucket(activatedExperiment, "userId")) .thenReturn(bucketedVariation); - logbackVerifier.expectMessage(Level.INFO, "Activating user \"userId\" in experiment \"etag1\"."); + logbackVerifier.expectMessage(Level.INFO, "Activating user \"userId\" in experiment \"" + + activatedExperiment.getKey() + "\"."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching impression event to URL test_url with params " + testParams + " and payload \"\""); @@ -214,7 +261,8 @@ public void activateForNullVariation() throws Exception { when(mockBucketer.bucket(activatedExperiment, "userId")) .thenReturn(null); - logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"etag1\"."); + logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + + activatedExperiment.getKey() + "\"."); // activate the experiment Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), "userId", testUserAttributes); @@ -253,8 +301,164 @@ public void activateWhenExperimentIsNotInProject() throws Exception { } /** - * Verify that the {@link Optimizely#activate(String, String)} call correctly builds an endpoint url and - * request params and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. + * Verify that the {@link Optimizely#activate(String, String, Map)} call + * uses forced variation to force the user into the second variation. The mock bucket returns + * the first variation. Then remove the forced variation and confirm that the forced variation is null. + */ + @Test + public void activateWithExperimentKeyForced() throws Exception { + Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); + Variation forcedVariation = activatedExperiment.getVariations().get(1); + Variation bucketedVariation = activatedExperiment.getVariations().get(0); + EventBuilder mockEventBuilder = mock(EventBuilder.class); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withBucketing(mockBucketer) + .withEventBuilder(mockEventBuilder) + .withConfig(validProjectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + optimizely.setForcedVariation(activatedExperiment.getKey(), "userId", forcedVariation.getKey() ); + + Map testUserAttributes = new HashMap(); + if (datafileVersion >= 4) { + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + } + else { + testUserAttributes.put("browser_type", "chrome"); + } + + Map testParams = new HashMap(); + testParams.put("test", "params"); + + LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); + when(mockEventBuilder.createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), eq(forcedVariation), + eq("userId"), eq(testUserAttributes))) + .thenReturn(logEventToDispatch); + + when(mockBucketer.bucket(activatedExperiment, "userId")) + .thenReturn(bucketedVariation); + + // activate the experiment + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), "userId", testUserAttributes); + + assertThat(actualVariation, is(forcedVariation)); + + verify(mockEventHandler).dispatchEvent(logEventToDispatch); + + optimizely.setForcedVariation(activatedExperiment.getKey(), "userId", null ); + + assertEquals(optimizely.getForcedVariation(activatedExperiment.getKey(), "userId"), null); + + } + + /** + * Verify that the {@link Optimizely#getVariation(String, String, Map)} call + * uses forced variation to force the user into the second variation. The mock bucket returns + * the first variation. Then remove the forced variation and confirm that the forced variation is null. + */ + @Test + public void getVariationWithExperimentKeyForced() throws Exception { + Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); + Variation forcedVariation = activatedExperiment.getVariations().get(1); + Variation bucketedVariation = activatedExperiment.getVariations().get(0); + EventBuilder mockEventBuilder = mock(EventBuilder.class); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withBucketing(mockBucketer) + .withEventBuilder(mockEventBuilder) + .withConfig(validProjectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + optimizely.setForcedVariation(activatedExperiment.getKey(), "userId", forcedVariation.getKey() ); + + Map testUserAttributes = new HashMap(); + if (datafileVersion >= 4) { + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + } + else { + testUserAttributes.put("browser_type", "chrome"); + } + + Map testParams = new HashMap(); + testParams.put("test", "params"); + + LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); + when(mockEventBuilder.createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), eq(forcedVariation), + eq("userId"), eq(testUserAttributes))) + .thenReturn(logEventToDispatch); + + when(mockBucketer.bucket(activatedExperiment, "userId")) + .thenReturn(bucketedVariation); + + // activate the experiment + Variation actualVariation = optimizely.getVariation(activatedExperiment.getKey(), "userId", testUserAttributes); + + assertThat(actualVariation, is(forcedVariation)); + + optimizely.setForcedVariation(activatedExperiment.getKey(), "userId", null ); + + assertEquals(optimizely.getForcedVariation(activatedExperiment.getKey(), "userId"), null); + + actualVariation = optimizely.getVariation(activatedExperiment.getKey(), "userId", testUserAttributes); + + assertThat(actualVariation, is(bucketedVariation)); + } + + /** + * Verify that the {@link Optimizely#activate(String, String, Map)} call + * uses forced variation to force the user into the second variation. The mock bucket returns + * the first variation. Then remove the forced variation and confirm that the forced variation is null. + */ + @Test + public void isFeatureEnabledWithExperimentKeyForced() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); + Variation forcedVariation = activatedExperiment.getVariations().get(1); + EventBuilder mockEventBuilder = mock(EventBuilder.class); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withBucketing(mockBucketer) + .withEventBuilder(mockEventBuilder) + .withConfig(validProjectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + optimizely.setForcedVariation(activatedExperiment.getKey(), "userId", forcedVariation.getKey() ); + + Map testUserAttributes = new HashMap(); + if (datafileVersion < 4) { + testUserAttributes.put("browser_type", "chrome"); + } + + Map testParams = new HashMap(); + testParams.put("test", "params"); + + LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); + when(mockEventBuilder.createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), eq(forcedVariation), + eq("userId"), eq(testUserAttributes))) + .thenReturn(logEventToDispatch); + + // activate the experiment + assertTrue(optimizely.isFeatureEnabled(FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), "userId")); + + verify(mockEventHandler).dispatchEvent(logEventToDispatch); + + assertTrue(optimizely.setForcedVariation(activatedExperiment.getKey(), "userId", null )); + + assertNull(optimizely.getForcedVariation(activatedExperiment.getKey(), "userId")); + + assertFalse(optimizely.isFeatureEnabled(FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), "userId")); + + } + + /** + * Verify that the {@link Optimizely#activate(String, String, Map)} call + * correctly builds an endpoint url and request params + * and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. */ @Test public void activateWithExperimentKey() throws Exception { @@ -270,7 +474,12 @@ public void activateWithExperimentKey() throws Exception { .build(); Map testUserAttributes = new HashMap(); - testUserAttributes.put("browser_type", "chrome"); + if (datafileVersion >= 4) { + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + } + else { + testUserAttributes.put("browser_type", "chrome"); + } Map testParams = new HashMap(); testParams.put("test", "params"); @@ -386,8 +595,8 @@ public void activateWithAttributes() throws Exception { } /** - * Verify that {@link Optimizely#activate(String, String)} handles the case where an unknown attribute - * (i.e., not in the config) is passed through. + * Verify that {@link Optimizely#activate(String, String, Map)} handles the case + * where an unknown attribute (i.e., not in the config) is passed through. * * In this case, the activate call should remove the unknown attribute from the given map. */ @@ -408,7 +617,12 @@ public void activateWithUnknownAttribute() throws Exception { .build(); Map testUserAttributes = new HashMap(); - testUserAttributes.put("browser_type", "chrome"); + if (datafileVersion >= 4) { + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + } + else { + testUserAttributes.put("browser_type", "chrome"); + } testUserAttributes.put("unknownAttribute", "dimValue"); Map testParams = new HashMap(); @@ -421,7 +635,8 @@ public void activateWithUnknownAttribute() throws Exception { when(mockBucketer.bucket(activatedExperiment, "userId")) .thenReturn(bucketedVariation); - logbackVerifier.expectMessage(Level.INFO, "Activating user \"userId\" in experiment \"etag1\"."); + logbackVerifier.expectMessage(Level.INFO, "Activating user \"userId\" in experiment \"" + + activatedExperiment.getKey() + "\"."); logbackVerifier.expectMessage(Level.WARN, "Attribute(s) [unknownAttribute] not in the datafile."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching impression event to URL test_url with params " + testParams + " and payload \"\""); @@ -555,16 +770,24 @@ public void activateWithNullAttributeValues() throws Exception { */ @Test public void activateDraftExperiment() throws Exception { - Experiment draftExperiment = validProjectConfig.getExperiments().get(1); + Experiment inactiveExperiment; + if (datafileVersion == 4) { + inactiveExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_PAUSED_EXPERIMENT_KEY); + } + else { + inactiveExperiment = validProjectConfig.getExperiments().get(1); + } Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) .withConfig(validProjectConfig) .build(); - logbackVerifier.expectMessage(Level.INFO, "Experiment \"etag2\" is not running."); - logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"etag2\"."); + logbackVerifier.expectMessage(Level.INFO, "Experiment \"" + inactiveExperiment.getKey() + + "\" is not running."); + logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + + inactiveExperiment.getKey() + "\"."); - Variation variation = optimizely.activate(draftExperiment.getKey(), "userId"); + Variation variation = optimizely.activate(inactiveExperiment.getKey(), "userId"); // verify that null is returned, as the experiment isn't running assertNull(variation); @@ -594,7 +817,13 @@ public void activateUserInAudience() throws Exception { */ @Test public void activateUserNotInAudience() throws Exception { - Experiment experimentToCheck = validProjectConfig.getExperiments().get(0); + Experiment experimentToCheck; + if (datafileVersion == 4) { + experimentToCheck = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); + } + else { + experimentToCheck = validProjectConfig.getExperiments().get(0); + } Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) .withConfig(validProjectConfig) @@ -605,8 +834,10 @@ public void activateUserNotInAudience() throws Exception { testUserAttributes.put("browser_type", "firefox"); logbackVerifier.expectMessage(Level.INFO, - "User \"userId\" does not meet conditions to be in experiment \"etag1\"."); - logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"etag1\"."); + "User \"userId\" does not meet conditions to be in experiment \"" + + experimentToCheck.getKey() + "\"."); + logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + + experimentToCheck.getKey() + "\"."); Variation actualVariation = optimizely.activate(experimentToCheck.getKey(), "userId", testUserAttributes); assertNull(actualVariation); @@ -633,14 +864,21 @@ public void activateUserWithNoAudiences() throws Exception { */ @Test public void activateUserNoAttributesWithAudiences() throws Exception { - Experiment experiment = validProjectConfig.getExperiments().get(0); + Experiment experiment; + if (datafileVersion == 4) { + experiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); + } + else { + experiment = validProjectConfig.getExperiments().get(0); + } Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) .build(); logbackVerifier.expectMessage(Level.INFO, - "User \"userId\" does not meet conditions to be in experiment \"etag1\"."); - logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"etag1\"."); + "User \"userId\" does not meet conditions to be in experiment \"" + experiment.getKey() + "\"."); + logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + + experiment.getKey() + "\"."); assertNull(optimizely.activate(experiment.getKey(), "userId")); } @@ -675,6 +913,14 @@ public void activateForGroupExperimentWithMatchingAttributes() throws Exception .get(0); Variation variation = experiment.getVariations().get(0); + Map attributes = new HashMap(); + if (datafileVersion == 4) { + attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + } + else { + attributes.put("browser_type", "chrome"); + } + when(mockBucketer.bucket(experiment, "user")).thenReturn(variation); Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) @@ -682,7 +928,7 @@ public void activateForGroupExperimentWithMatchingAttributes() throws Exception .withBucketing(mockBucketer) .build(); - assertThat(optimizely.activate(experiment.getKey(), "user", Collections.singletonMap("browser_type", "chrome")), + assertThat(optimizely.activate(experiment.getKey(), "user", attributes), is(variation)); } @@ -717,16 +963,29 @@ public void activateForGroupExperimentWithNonMatchingAttributes() throws Excepti */ @Test public void activateForcedVariationPrecedesAudienceEval() throws Exception { - Experiment experiment = validProjectConfig.getExperiments().get(0); - Variation expectedVariation = experiment.getVariations().get(0); + Experiment experiment; + String whitelistedUserId; + Variation expectedVariation; + if (datafileVersion == 4) { + experiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); + whitelistedUserId = MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GRED; + expectedVariation = experiment.getVariationKeyToVariationMap().get(VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY); + } + else { + experiment = validProjectConfig.getExperiments().get(0); + whitelistedUserId = "testUser1"; + expectedVariation = experiment.getVariations().get(0); + } Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) .withConfig(validProjectConfig) .build(); - logbackVerifier.expectMessage(Level.INFO, "User \"testUser1\" is forced in variation \"vtag1\"."); + logbackVerifier.expectMessage(Level.INFO, "User \"" + whitelistedUserId + "\" is forced in variation \"" + + expectedVariation.getKey() + "\"."); // no attributes provided for a experiment that has an audience - assertThat(optimizely.activate(experiment.getKey(), "testUser1"), is(expectedVariation)); + assertTrue(experiment.getUserIdToVariationKeyMap().containsKey(whitelistedUserId)); + assertThat(optimizely.activate(experiment.getKey(), whitelistedUserId), is(expectedVariation)); } /** @@ -735,16 +994,27 @@ public void activateForcedVariationPrecedesAudienceEval() throws Exception { */ @Test public void activateExperimentStatusPrecedesForcedVariation() throws Exception { - Experiment experiment = validProjectConfig.getExperiments().get(1); + Experiment experiment; + String whitelistedUserId; + if (datafileVersion == 4) { + experiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_PAUSED_EXPERIMENT_KEY); + whitelistedUserId = PAUSED_EXPERIMENT_FORCED_VARIATION_USER_ID_CONTROL; + } + else { + experiment = validProjectConfig.getExperiments().get(1); + whitelistedUserId = "testUser3"; + } Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) .withConfig(validProjectConfig) .build(); - logbackVerifier.expectMessage(Level.INFO, "Experiment \"etag2\" is not running."); - logbackVerifier.expectMessage(Level.INFO, "Not activating user \"testUser3\" for experiment \"etag2\"."); + logbackVerifier.expectMessage(Level.INFO, "Experiment \"" + experiment.getKey() + "\" is not running."); + logbackVerifier.expectMessage(Level.INFO, "Not activating user \"" + whitelistedUserId + + "\" for experiment \"" + experiment.getKey() + "\"."); // testUser3 has a corresponding forced variation, but experiment status should be checked first - assertNull(optimizely.activate(experiment.getKey(), "testUser3")); + assertTrue(experiment.getUserIdToVariationKeyMap().containsKey(whitelistedUserId)); + assertNull(optimizely.activate(experiment.getKey(), whitelistedUserId)); } /** @@ -771,7 +1041,13 @@ public void activateDispatchEventThrowsException() throws Exception { */ @Test public void activateLaunchedExperimentDoesNotDispatchEvent() throws Exception { - Experiment launchedExperiment = noAudienceProjectConfig.getExperiments().get(2); + Experiment launchedExperiment; + if (datafileVersion == 4) { + launchedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_LAUNCHED_EXPERIMENT_KEY); + } + else { + launchedExperiment = noAudienceProjectConfig.getExperiments().get(2); + } Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) .withBucketing(mockBucketer) @@ -796,19 +1072,109 @@ public void activateLaunchedExperimentDoesNotDispatchEvent() throws Exception { //======== track tests ========// + /** + * Verify that the {@link Optimizely#track(String, String)} call correctly builds a V2 event and passes it + * through {@link EventHandler#dispatchEvent(LogEvent)}. + */ + @Test + public void trackEventEndToEndForced() throws Exception { + EventType eventType; + String datafile; + ProjectConfig config; + if (datafileVersion >= 4) { + config = spy(validProjectConfig); + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + datafile = validDatafile; + } + else { + config = spy(noAudienceProjectConfig); + eventType = noAudienceProjectConfig.getEventTypes().get(0); + datafile = noAudienceDatafile; + } + List allExperiments = new ArrayList(); + allExperiments.add(config.getExperiments().get(0)); + EventBuilder eventBuilderV2 = new EventBuilderV2(); + DecisionService spyDecisionService = spy(new DecisionService(mockBucketer, + mockErrorHandler, + config, + null)); + + Optimizely optimizely = Optimizely.builder(datafile, mockEventHandler) + .withDecisionService(spyDecisionService) + .withEventBuilder(eventBuilderV2) + .withConfig(noAudienceProjectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + // Bucket to null for all experiments. However, only a subset of the experiments will actually + // call the bucket function. + for (Experiment experiment : allExperiments) { + when(mockBucketer.bucket(experiment, "userId")) + .thenReturn(null); + } + // Force to the first variation for all experiments. However, only a subset of the experiments will actually + // call get forced. + for (Experiment experiment : allExperiments) { + optimizely.projectConfig.setForcedVariation(experiment.getKey(), + "userId", experiment.getVariations().get(0).getKey()); + } + + // call track + optimizely.track(eventType.getKey(), "userId"); + + // verify that the bucketing algorithm was called only on experiments corresponding to the specified goal. + List experimentsForEvent = config.getExperimentsForEventKey(eventType.getKey()); + for (Experiment experiment : allExperiments) { + if (experiment.isRunning() && experimentsForEvent.contains(experiment)) { + verify(spyDecisionService).getVariation(experiment, "userId", + Collections.emptyMap()); + verify(config).getForcedVariation(experiment.getKey(), "userId"); + } else { + verify(spyDecisionService, never()).getVariation(experiment, "userId", + Collections.emptyMap()); + } + } + + // verify that dispatchEvent was called + verify(mockEventHandler).dispatchEvent(any(LogEvent.class)); + + for (Experiment experiment : allExperiments) { + assertEquals(optimizely.projectConfig.getForcedVariation(experiment.getKey(), "userId"), experiment.getVariations().get(0)); + optimizely.projectConfig.setForcedVariation(experiment.getKey(), "userId", null); + assertNull(optimizely.projectConfig.getForcedVariation(experiment.getKey(), "userId")); + } + + } + /** * Verify that the {@link Optimizely#track(String, String)} call correctly builds a V2 event and passes it * through {@link EventHandler#dispatchEvent(LogEvent)}. */ @Test public void trackEventEndToEnd() throws Exception { - List allExperiments = noAudienceProjectConfig.getExperiments(); - EventType eventType = noAudienceProjectConfig.getEventTypes().get(0); + EventType eventType; + String datafile; + ProjectConfig config; + if (datafileVersion >= 4) { + config = spy(validProjectConfig); + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + datafile = validDatafile; + } + else { + config = spy(noAudienceProjectConfig); + eventType = noAudienceProjectConfig.getEventTypes().get(0); + datafile = noAudienceDatafile; + } + List allExperiments = config.getExperiments(); EventBuilder eventBuilderV2 = new EventBuilderV2(); + DecisionService spyDecisionService = spy(new DecisionService(mockBucketer, + mockErrorHandler, + config, + null)); - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withBucketing(mockBucketer) + Optimizely optimizely = Optimizely.builder(datafile, mockEventHandler) + .withDecisionService(spyDecisionService) .withEventBuilder(eventBuilderV2) .withConfig(noAudienceProjectConfig) .withErrorHandler(mockErrorHandler) @@ -825,13 +1191,15 @@ public void trackEventEndToEnd() throws Exception { optimizely.track(eventType.getKey(), "userId"); // verify that the bucketing algorithm was called only on experiments corresponding to the specified goal. - List experimentsForEvent = noAudienceProjectConfig.getExperimentsForEventKey(eventType.getKey()); + List experimentsForEvent = config.getExperimentsForEventKey(eventType.getKey()); for (Experiment experiment : allExperiments) { - if (ExperimentUtils.isExperimentActive(experiment) && - experimentsForEvent.contains(experiment)) { - verify(mockBucketer).bucket(experiment, "userId"); + if (experiment.isRunning() && experimentsForEvent.contains(experiment)) { + verify(spyDecisionService).getVariation(experiment, "userId", + Collections.emptyMap()); + verify(config).getForcedVariation(experiment.getKey(), "userId"); } else { - verify(mockBucketer, never()).bucket(experiment, "userId"); + verify(spyDecisionService, never()).getVariation(experiment, "userId", + Collections.emptyMap()); } } @@ -886,7 +1254,13 @@ public void trackEventWithUnknownEventKeyAndRaiseExceptionErrorHandler() throws @SuppressWarnings("unchecked") public void trackEventWithAttributes() throws Exception { Attribute attribute = validProjectConfig.getAttributes().get(0); - EventType eventType = validProjectConfig.getEventTypes().get(0); + EventType eventType; + if (datafileVersion >= 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } + else { + eventType = validProjectConfig.getEventTypes().get(0); + } // setup a mock event builder to return expected conversion params EventBuilder mockEventBuilder = mock(EventBuilder.class); @@ -903,7 +1277,7 @@ public void trackEventWithAttributes() throws Exception { Map attributes = ImmutableMap.of(attribute.getKey(), "attributeValue"); Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketer, + mockDecisionService, eventType.getKey(), genericUserId, attributes); @@ -918,7 +1292,8 @@ public void trackEventWithAttributes() throws Exception { eq(Collections.emptyMap()))) .thenReturn(logEventToDispatch); - logbackVerifier.expectMessage(Level.INFO, "Tracking event \"clicked_cart\" for user \"" + genericUserId + "\"."); + logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + + "\" for user \"" + genericUserId + "\"."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + testParams + " and payload \"\""); @@ -952,29 +1327,35 @@ public void trackEventWithAttributes() throws Exception { value="NP_NONNULL_PARAM_VIOLATION", justification="testing nullness contract violation") public void trackEventWithNullAttributes() throws Exception { - EventType eventType = noAudienceProjectConfig.getEventTypes().get(0); + EventType eventType; + if (datafileVersion >= 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } + else { + eventType = validProjectConfig.getEventTypes().get(0); + } // setup a mock event builder to return expected conversion params EventBuilder mockEventBuilder = mock(EventBuilder.class); - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) .withBucketing(mockBucketer) .withEventBuilder(mockEventBuilder) - .withConfig(noAudienceProjectConfig) + .withConfig(validProjectConfig) .withErrorHandler(mockErrorHandler) .build(); Map testParams = new HashMap(); testParams.put("test", "params"); Map experimentVariationMap = createExperimentVariationMap( - noAudienceProjectConfig, - mockBucketer, + validProjectConfig, + mockDecisionService, eventType.getKey(), genericUserId, Collections.emptyMap()); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createConversionEvent( - eq(noAudienceProjectConfig), + eq(validProjectConfig), eq(experimentVariationMap), eq(genericUserId), eq(eventType.getId()), @@ -983,7 +1364,8 @@ public void trackEventWithNullAttributes() throws Exception { eq(Collections.emptyMap()))) .thenReturn(logEventToDispatch); - logbackVerifier.expectMessage(Level.INFO, "Tracking event \"clicked_cart\" for user \"" + genericUserId + "\"."); + logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + + "\" for user \"" + genericUserId + "\"."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + testParams + " and payload \"\""); @@ -998,7 +1380,7 @@ public void trackEventWithNullAttributes() throws Exception { // verify that the event builder was called with the expected attributes verify(mockEventBuilder).createConversionEvent( - eq(noAudienceProjectConfig), + eq(validProjectConfig), eq(experimentVariationMap), eq(genericUserId), eq(eventType.getId()), @@ -1017,29 +1399,35 @@ public void trackEventWithNullAttributes() throws Exception { */ @Test public void trackEventWithNullAttributeValues() throws Exception { - EventType eventType = noAudienceProjectConfig.getEventTypes().get(0); + EventType eventType; + if (datafileVersion >= 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } + else { + eventType = validProjectConfig.getEventTypes().get(0); + } // setup a mock event builder to return expected conversion params EventBuilder mockEventBuilder = mock(EventBuilder.class); - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) .withBucketing(mockBucketer) .withEventBuilder(mockEventBuilder) - .withConfig(noAudienceProjectConfig) + .withConfig(validProjectConfig) .withErrorHandler(mockErrorHandler) .build(); Map testParams = new HashMap(); testParams.put("test", "params"); Map experimentVariationMap = createExperimentVariationMap( - noAudienceProjectConfig, - mockBucketer, + validProjectConfig, + mockDecisionService, eventType.getKey(), genericUserId, Collections.emptyMap()); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createConversionEvent( - eq(noAudienceProjectConfig), + eq(validProjectConfig), eq(experimentVariationMap), eq(genericUserId), eq(eventType.getId()), @@ -1048,7 +1436,8 @@ public void trackEventWithNullAttributeValues() throws Exception { eq(Collections.emptyMap()))) .thenReturn(logEventToDispatch); - logbackVerifier.expectMessage(Level.INFO, "Tracking event \"clicked_cart\" for user \"" + genericUserId + "\"."); + logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + + "\" for user \"" + genericUserId + "\"."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + testParams + " and payload \"\""); @@ -1062,7 +1451,7 @@ public void trackEventWithNullAttributeValues() throws Exception { // verify that the event builder was called with the expected attributes verify(mockEventBuilder).createConversionEvent( - eq(noAudienceProjectConfig), + eq(validProjectConfig), eq(experimentVariationMap), eq(genericUserId), eq(eventType.getId()), @@ -1082,7 +1471,13 @@ public void trackEventWithNullAttributeValues() throws Exception { @Test @SuppressWarnings("unchecked") public void trackEventWithUnknownAttribute() throws Exception { - EventType eventType = validProjectConfig.getEventTypes().get(0); + EventType eventType; + if (datafileVersion >= 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } + else { + eventType = validProjectConfig.getEventTypes().get(0); + } // setup a mock event builder to return expected conversion params EventBuilder mockEventBuilder = mock(EventBuilder.class); @@ -1098,7 +1493,7 @@ public void trackEventWithUnknownAttribute() throws Exception { testParams.put("test", "params"); Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketer, + mockDecisionService, eventType.getKey(), genericUserId, Collections.emptyMap()); @@ -1113,7 +1508,8 @@ public void trackEventWithUnknownAttribute() throws Exception { eq(Collections.emptyMap()))) .thenReturn(logEventToDispatch); - logbackVerifier.expectMessage(Level.INFO, "Tracking event \"clicked_cart\" for user \"" + genericUserId + "\"."); + logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + + "\" for user \"" + genericUserId + "\"."); logbackVerifier.expectMessage(Level.WARN, "Attribute(s) [unknownAttribute] not in the datafile."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + testParams + " and payload \"\""); @@ -1141,12 +1537,18 @@ public void trackEventWithUnknownAttribute() throws Exception { } /** - * Verify that {@link Optimizely#track(String, String, long)} passes through revenue. + * Verify that {@link Optimizely#track(String, String, Map, Map)} passes event features to + * {@link EventBuilder#createConversionEvent(ProjectConfig, Map, String, String, String, Map, Map)} */ @Test - public void trackEventWithRevenue() throws Exception { - EventType eventType = validProjectConfig.getEventTypes().get(0); - long revenue = 1234L; + public void trackEventWithEventTags() throws Exception { + EventType eventType; + if (datafileVersion >= 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } + else { + eventType = validProjectConfig.getEventTypes().get(0); + } // setup a mock event builder to return expected conversion params EventBuilder mockEventBuilder = mock(EventBuilder.class); @@ -1160,14 +1562,19 @@ public void trackEventWithRevenue() throws Exception { Map testParams = new HashMap(); testParams.put("test", "params"); - Map eventTags= new HashMap(); - eventTags.put(ReservedEventKey.REVENUE.toString(), revenue); + + Map eventTags = new HashMap(); + eventTags.put("int_param", 123); + eventTags.put("string_param", "123"); + eventTags.put("boolean_param", false); + eventTags.put("float_param", 12.3f); Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketer, + mockDecisionService, eventType.getKey(), genericUserId, Collections.emptyMap()); + LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createConversionEvent( eq(validProjectConfig), @@ -1175,17 +1582,22 @@ public void trackEventWithRevenue() throws Exception { eq(genericUserId), eq(eventType.getId()), eq(eventType.getKey()), - eq(Collections.emptyMap()), + anyMapOf(String.class, String.class), eq(eventTags))) .thenReturn(logEventToDispatch); + logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + "\" for user \"" + + genericUserId + "\"."); + logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + + testParams + " and payload \"\""); + // call track - optimizely.track(eventType.getKey(), genericUserId, revenue); + optimizely.track(eventType.getKey(), genericUserId, Collections.emptyMap(), eventTags); - // setup the event tag map captor (so we can verify its content) + // setup the event map captor (so we can verify its content) ArgumentCaptor eventTagCaptor = ArgumentCaptor.forClass(Map.class); - // verify that the event builder was called with the expected revenue + // verify that the event builder was called with the expected attributes verify(mockEventBuilder).createConversionEvent( eq(validProjectConfig), eq(experimentVariationMap), @@ -1195,95 +1607,31 @@ public void trackEventWithRevenue() throws Exception { eq(Collections.emptyMap()), eventTagCaptor.capture()); - Long actualValue = (Long)eventTagCaptor.getValue().get(ReservedEventKey.REVENUE.toString()); - assertThat(actualValue, is(revenue)); + Map actualValue = eventTagCaptor.getValue(); + assertThat(actualValue, hasEntry("int_param", eventTags.get("int_param"))); + assertThat(actualValue, hasEntry("string_param", eventTags.get("string_param"))); + assertThat(actualValue, hasEntry("boolean_param", eventTags.get("boolean_param"))); + assertThat(actualValue, hasEntry("float_param", eventTags.get("float_param"))); verify(mockEventHandler).dispatchEvent(logEventToDispatch); } /** - * Verify that {@link Optimizely#track(String, String, Map, Map)} passes event features to - * {@link EventBuilder#createConversionEvent(ProjectConfig, Map, String, String, String, Map, Map)} - */ - @Test - public void trackEventWithEventTags() throws Exception { - EventType eventType = validProjectConfig.getEventTypes().get(0); - - // setup a mock event builder to return expected conversion params - EventBuilder mockEventBuilder = mock(EventBuilder.class); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventBuilder) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - Map testParams = new HashMap(); - testParams.put("test", "params"); - - Map eventTags = new HashMap(); - eventTags.put("int_param", 123); - eventTags.put("string_param", "123"); - eventTags.put("boolean_param", false); - eventTags.put("float_param", 12.3f); - Map experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - mockBucketer, - eventType.getKey(), - genericUserId, - Collections.emptyMap()); - - LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); - when(mockEventBuilder.createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - anyMapOf(String.class, String.class), - eq(eventTags))) - .thenReturn(logEventToDispatch); - - logbackVerifier.expectMessage(Level.INFO, "Tracking event \"clicked_cart\" for user \"" + genericUserId + "\"."); - logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); - - // call track - optimizely.track(eventType.getKey(), genericUserId, Collections.emptyMap(), eventTags); - - // setup the event map captor (so we can verify its content) - ArgumentCaptor eventTagCaptor = ArgumentCaptor.forClass(Map.class); - - // verify that the event builder was called with the expected attributes - verify(mockEventBuilder).createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - eq(Collections.emptyMap()), - eventTagCaptor.capture()); - - Map actualValue = eventTagCaptor.getValue(); - assertThat(actualValue, hasEntry("int_param", eventTags.get("int_param"))); - assertThat(actualValue, hasEntry("string_param", eventTags.get("string_param"))); - assertThat(actualValue, hasEntry("boolean_param", eventTags.get("boolean_param"))); - assertThat(actualValue, hasEntry("float_param", eventTags.get("float_param"))); - - verify(mockEventHandler).dispatchEvent(logEventToDispatch); - } - - /** - * Verify that {@link Optimizely#track(String, String, Map, Map)} called with null event tags will default to - * an empty map when calling {@link EventBuilder#createConversionEvent(ProjectConfig, Map, String, String, String, Map, Map)} + * Verify that {@link Optimizely#track(String, String, Map, Map)} called with null event tags will default to + * an empty map when calling {@link EventBuilder#createConversionEvent(ProjectConfig, Map, String, String, String, Map, Map)} */ @Test @SuppressFBWarnings( value="NP_NONNULL_PARAM_VIOLATION", justification="testing nullness contract violation") public void trackEventWithNullEventTags() throws Exception { - EventType eventType = validProjectConfig.getEventTypes().get(0); + EventType eventType; + if (datafileVersion >= 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } + else { + eventType = validProjectConfig.getEventTypes().get(0); + } // setup a mock event builder to return expected conversion params EventBuilder mockEventBuilder = mock(EventBuilder.class); @@ -1299,7 +1647,7 @@ public void trackEventWithNullEventTags() throws Exception { testParams.put("test", "params"); Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketer, + mockDecisionService, eventType.getKey(), genericUserId, Collections.emptyMap()); @@ -1314,7 +1662,8 @@ public void trackEventWithNullEventTags() throws Exception { eq(Collections.emptyMap()))) .thenReturn(logEventToDispatch); - logbackVerifier.expectMessage(Level.INFO, "Tracking event \"clicked_cart\" for user \"" + genericUserId + "\"."); + logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + + "\" for user \"" + genericUserId + "\"."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + testParams + " and payload \"\""); @@ -1340,16 +1689,28 @@ public void trackEventWithNullEventTags() throws Exception { */ @Test public void trackEventWithNoValidExperiments() throws Exception { + EventType eventType; + if (datafileVersion >= 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } + else { + eventType = validProjectConfig.getEventNameMapping().get("clicked_purchase"); + } - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler).build(); + when(mockDecisionService.getVariation(any(Experiment.class), any(String.class), anyMapOf(String.class, String.class))) + .thenReturn(null); + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withDecisionService(mockDecisionService) + .build(); Map attributes = new HashMap(); attributes.put("browser_type", "firefox"); logbackVerifier.expectMessage(Level.INFO, - "There are no valid experiments for event \"clicked_purchase\" to track."); - logbackVerifier.expectMessage(Level.INFO, "Not tracking event \"clicked_purchase\" for user \"userId\"."); - optimizely.track("clicked_purchase", "userId", attributes); + "There are no valid experiments for event \"" + eventType.getKey() + "\" to track."); + logbackVerifier.expectMessage(Level.INFO, "Not tracking event \"" + eventType.getKey() + + "\" for user \"userId\"."); + optimizely.track(eventType.getKey(), "userId", attributes); verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); } @@ -1378,7 +1739,13 @@ public void trackDispatchEventThrowsException() throws Exception { */ @Test public void trackDoesNotSendEventWhenExperimentsAreLaunchedOnly() throws Exception { - EventType eventType = noAudienceProjectConfig.getEventNameMapping().get("launched_exp_event"); + EventType eventType; + if (datafileVersion >= 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_LAUNCHED_EXPERIMENT_ONLY_KEY); + } + else { + eventType = noAudienceProjectConfig.getEventNameMapping().get("launched_exp_event"); + } Bucketer mockBucketAlgorithm = mock(Bucketer.class); for (Experiment experiment : noAudienceProjectConfig.getExperiments()) { Variation variation = experiment.getVariations().get(0); @@ -1429,14 +1796,20 @@ public void trackDoesNotSendEventWhenExperimentsAreLaunchedOnly() throws Excepti @Test public void trackDispatchesWhenEventHasLaunchedAndRunningExperiments() throws Exception { EventBuilder mockEventBuilder = mock(EventBuilder.class); - EventType eventType = noAudienceProjectConfig.getEventNameMapping().get("event_with_launched_and_running_experiments"); + EventType eventType; + if (datafileVersion >= 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } + else { + eventType = noAudienceProjectConfig.getEventNameMapping().get("event_with_launched_and_running_experiments"); + } Bucketer mockBucketAlgorithm = mock(Bucketer.class); - for (Experiment experiment : noAudienceProjectConfig.getExperiments()) { + for (Experiment experiment : validProjectConfig.getExperiments()) { when(mockBucketAlgorithm.bucket(experiment, genericUserId)) .thenReturn(experiment.getVariations().get(0)); } - Optimizely client = Optimizely.builder(noAudienceDatafile, mockEventHandler) + Optimizely client = Optimizely.builder(validDatafile, mockEventHandler) .withConfig(noAudienceProjectConfig) .withBucketing(mockBucketAlgorithm) .withEventBuilder(mockEventBuilder) @@ -1446,14 +1819,18 @@ public void trackDispatchesWhenEventHasLaunchedAndRunningExperiments() throws Ex testParams.put("test", "params"); Map experimentVariationMap = createExperimentVariationMap( noAudienceProjectConfig, - mockBucketAlgorithm, + client.decisionService, eventType.getKey(), genericUserId, - null); + Collections.emptyMap()); + + // Create an Argument Captor to ensure we are creating a correct experiment variation map + ArgumentCaptor experimentVariationMapCaptor = ArgumentCaptor.forClass(Map.class); + LogEvent conversionEvent = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createConversionEvent( eq(noAudienceProjectConfig), - eq(experimentVariationMap), + experimentVariationMapCaptor.capture(), eq(genericUserId), eq(eventType.getId()), eq(eventType.getKey()), @@ -1476,6 +1853,10 @@ public void trackDispatchesWhenEventHasLaunchedAndRunningExperiments() throws Ex // It should send a track event with the running experiment client.track(eventType.getKey(), genericUserId, Collections.emptyMap()); verify(client.eventHandler).dispatchEvent(eq(conversionEvent)); + + // Check the argument captor got the correct arguments + Map actualExperimentVariationMap = experimentVariationMapCaptor.getValue(); + assertEquals(experimentVariationMap, actualExperimentVariationMap); } /** @@ -1500,217 +1881,6 @@ public void trackDoesNotSendEventWhenUserDoesNotSatisfyAudiences() throws Except verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); } - //======== live variable getters tests ========// - - /** - * Verify that {@link Optimizely#getVariableString(String, String, boolean)} returns null and logs properly when - * an invalid live variable key is provided and the {@link NoOpErrorHandler} is used. - */ - @Test - public void getVariableInvalidVariableKeyNoOpErrorHandler() throws Exception { - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build(); - - logbackVerifier.expectMessage(Level.ERROR, "Live variable \"invalid_key\" is not in the datafile."); - assertNull(optimizely.getVariableString("invalid_key", "userId", false)); - } - - /** - * Verify that {@link Optimizely#getVariableString(String, String, boolean)} returns throws an - * {@link UnknownLiveVariableException} when an invalid live variable key is provided and the - * {@link RaiseExceptionErrorHandler} is used. - */ - @Test - public void getVariableInvalidVariableKeyRaiseExceptionErrorHandler() throws Exception { - thrown.expect(UnknownLiveVariableException.class); - - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withErrorHandler(new RaiseExceptionErrorHandler()) - .build(); - - optimizely.getVariableString("invalid_key", "userId", false); - } - - /** - * Verify that {@link Optimizely#getVariableString(String, String, Map, boolean)} returns a string live variable - * value when an proper variable key is provided and dispatches an impression when activateExperiment is true. - */ - @Test - public void getVariableStringActivateExperimentTrue() throws Exception { - - assumeTrue(datafileVersion >= 3); - - Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - - when(mockBucketer.bucket(activatedExperiment, genericUserId)) - .thenReturn(bucketedVariation); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withBucketing(mockBucketer) - .withErrorHandler(new RaiseExceptionErrorHandler()) - .build(); - - String variableKey = "string_variable"; - Map attributes = Collections.singletonMap("browser_type", "chrome"); - - assertThat(optimizely.getVariableString(variableKey, genericUserId, - attributes, true), - is("string_var_vtag1")); - - verify(mockEventHandler).dispatchEvent(any(LogEvent.class)); - } - - /** - * Verify that {@link Optimizely#getVariableString(String, String, Map, boolean)} returns a string live variable - * value when an proper variable key is provided and doesn't dispatch an impression when activateExperiment is - * false. - */ - @Test - public void getVariableStringActivateExperimentFalse() throws Exception { - - assumeTrue(datafileVersion >= 3); - - Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - - when(mockBucketer.bucket(activatedExperiment, "userId")) - .thenReturn(bucketedVariation); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withBucketing(mockBucketer) - .withErrorHandler(new RaiseExceptionErrorHandler()) - .build(); - - assertThat(optimizely.getVariableString("string_variable", "userId", - Collections.singletonMap("browser_type", "chrome"), false), - is("string_var_vtag1")); - verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); - } - - /** - * Verify that {@link Optimizely#getVariableString(String, String, boolean)} returns the default value of - * a live variable when no experiments are using the live variable. - */ - @Test - public void getVariableStringReturnsDefaultValueNoExperimentsUsingLiveVariable() throws Exception { - - assumeTrue(datafileVersion >= 3); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build(); - - logbackVerifier.expectMessage(Level.WARN, "No experiment is using variable \"unused_string_variable\"."); - assertThat(optimizely.getVariableString("unused_string_variable", - "userId", true), is("unused_variable")); - } - - /** - * Verify that {@link Optimizely#getVariableString(String, String, Map, boolean)} returns the default value when - * a user isn't bucketed into a variation in the experiment. - */ - @Test - public void getVariableStringReturnsDefaultValueUserNotInVariation() throws Exception { - - assumeTrue(datafileVersion >= 3); - - // user isn't bucketed into a variation in any experiment - when(mockBucketer.bucket(any(Experiment.class), any(String.class))) - .thenReturn(null); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withBucketing(mockBucketer) - .build(); - - assertThat(optimizely.getVariableString("string_variable", "userId", - Collections.singletonMap("browser_type", "chrome"), true), - is("string_live_variable")); - } - - /** - * Verify that {@link Optimizely#getVariableBoolean(String, String, Map, boolean)} returns a boolean live variable - * value when an proper variable key is provided and dispatches an impression when activateExperiment is true. - */ - @Test - public void getVariableBoolean() throws Exception { - - assumeTrue(datafileVersion >= 3); - - Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - - when(mockBucketer.bucket(activatedExperiment, "userId")) - .thenReturn(bucketedVariation); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withBucketing(mockBucketer) - .build(); - - assertTrue(optimizely.getVariableBoolean("etag1_variable", "userId", - Collections.singletonMap("browser_type", "chrome"), true)); - } - - /** - * Verify that {@link Optimizely#getVariableDouble(String, String, Map, boolean)} returns a double live variable - * value when an proper variable key is provided and dispatches an impression when activateExperiment is true. - */ - @Test - public void getVariableDouble() throws Exception { - - assumeTrue(datafileVersion >= 3); - - Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - - when(mockBucketer.bucket(activatedExperiment, "userId")) - .thenReturn(bucketedVariation); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withBucketing(mockBucketer) - .build(); - - assertThat(optimizely.getVariableDouble("double_variable", "userId", - Collections.singletonMap("browser_type", "chrome"), true), - is(5.3)); - verify(mockEventHandler).dispatchEvent(any(LogEvent.class)); - } - - /** - * Verify that {@link Optimizely#getVariableInteger(String, String, Map, boolean)} returns a integer live variable - * value when an proper variable key is provided and dispatches an impression when activateExperiment is true. - */ - @Test - public void getVariableInteger() throws Exception { - - assumeTrue(datafileVersion >= 3); - - Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - - when(mockBucketer.bucket(activatedExperiment, "userId")) - .thenReturn(bucketedVariation); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withBucketing(mockBucketer) - .build(); - - assertThat(optimizely.getVariableInteger("integer_variable", "userId", - Collections.singletonMap("browser_type", "chrome"), true), - is(10)); - verify(mockEventHandler).dispatchEvent(any(LogEvent.class)); - } - //======== getVariation tests ========// /** @@ -1826,14 +1996,20 @@ public void getVariationWithAudiences() throws Exception { */ @Test public void getVariationWithAudiencesNoAttributes() throws Exception { - Experiment experiment = validProjectConfig.getExperiments().get(0); + Experiment experiment; + if (datafileVersion >= 4) { + experiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); + } + else { + experiment = validProjectConfig.getExperiments().get(0); + } Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) .withErrorHandler(mockErrorHandler) .build(); logbackVerifier.expectMessage(Level.INFO, - "User \"userId\" does not meet conditions to be in experiment \"etag1\"."); + "User \"userId\" does not meet conditions to be in experiment \"" + experiment.getKey() + "\"."); Variation actualVariation = optimizely.getVariation(experiment.getKey(), "userId"); assertNull(actualVariation); @@ -1910,6 +2086,14 @@ public void getVariationForGroupExperimentWithMatchingAttributes() throws Except .get(0); Variation variation = experiment.getVariations().get(0); + Map attributes = new HashMap(); + if (datafileVersion >= 4) { + attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + } + else { + attributes.put("browser_type", "chrome"); + } + when(mockBucketer.bucket(experiment, "user")).thenReturn(variation); Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) @@ -1917,7 +2101,7 @@ public void getVariationForGroupExperimentWithMatchingAttributes() throws Except .withBucketing(mockBucketer) .build(); - assertThat(optimizely.getVariation(experiment.getKey(), "user", Collections.singletonMap("browser_type", "chrome")), + assertThat(optimizely.getVariation(experiment.getKey(), "user", attributes), is(variation)); } @@ -1946,13 +2130,19 @@ public void getVariationForGroupExperimentWithNonMatchingAttributes() throws Exc */ @Test public void getVariationExperimentStatusPrecedesForcedVariation() throws Exception { - Experiment experiment = validProjectConfig.getExperiments().get(1); + Experiment experiment; + if (datafileVersion >= 4) { + experiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_PAUSED_EXPERIMENT_KEY); + } + else { + experiment = validProjectConfig.getExperiments().get(1); + } Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) .withConfig(validProjectConfig) .build(); - logbackVerifier.expectMessage(Level.INFO, "Experiment \"etag2\" is not running."); + logbackVerifier.expectMessage(Level.INFO, "Experiment \"" + experiment.getKey() + "\" is not running."); // testUser3 has a corresponding forced variation, but experiment status should be checked first assertNull(optimizely.getVariation(experiment.getKey(), "testUser3")); } @@ -1966,18 +2156,27 @@ public void getVariationExperimentStatusPrecedesForcedVariation() throws Excepti */ @Test public void addNotificationListener() throws Exception { - Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); + Experiment activatedExperiment; + EventType eventType; + if (datafileVersion >= 4) { + activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_BASIC_EXPERIMENT_KEY); + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } + else { + activatedExperiment = validProjectConfig.getExperiments().get(0); + eventType = validProjectConfig.getEventTypes().get(0); + } Variation bucketedVariation = activatedExperiment.getVariations().get(0); EventBuilder mockEventBuilder = mock(EventBuilder.class); Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) + .withDecisionService(mockDecisionService) .withEventBuilder(mockEventBuilder) .withConfig(validProjectConfig) .withErrorHandler(mockErrorHandler) .build(); - Map attributes = Collections.singletonMap("browser_type", "chrome"); + Map attributes = Collections.emptyMap(); Map testParams = new HashMap(); testParams.put("test", "params"); @@ -1986,13 +2185,12 @@ public void addNotificationListener() throws Exception { bucketedVariation, genericUserId, attributes)) .thenReturn(logEventToDispatch); - when(mockBucketer.bucket(activatedExperiment, genericUserId)) + when(mockDecisionService.getVariation( + eq(activatedExperiment), + eq(genericUserId), + eq(Collections.emptyMap()))) .thenReturn(bucketedVariation); - when(mockEventBuilder.createImpressionEvent(validProjectConfig, activatedExperiment, - bucketedVariation, genericUserId, attributes)) - .thenReturn(logEventToDispatch); - // Add listener NotificationListener listener = mock(NotificationListener.class); optimizely.addNotificationListener(listener); @@ -2002,25 +2200,12 @@ public void addNotificationListener() throws Exception { verify(listener, times(1)) .onExperimentActivated(activatedExperiment, genericUserId, attributes, actualVariation); - // Check if listener is notified when live variable is accessed - boolean activateExperiment = true; - optimizely.getVariableString("string_variable", genericUserId, attributes, activateExperiment); - - if (datafileVersion >= 3) { - verify(listener, times(2)) - .onExperimentActivated(activatedExperiment, genericUserId, attributes, actualVariation); - } else { - verify(listener, times(1)) - .onExperimentActivated(activatedExperiment, genericUserId, attributes, actualVariation); - } - - // Check if listener is notified after an event is tracked - EventType eventType = validProjectConfig.getEventTypes().get(0); - String eventKey = eventType.getKey(); + // Check if listener is notified after an event is tracked + String eventKey = eventType.getKey(); Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketer, + mockDecisionService, eventType.getKey(), genericUserId, attributes); @@ -2057,7 +2242,8 @@ public void removeNotificationListener() throws Exception { .withErrorHandler(mockErrorHandler) .build(); - Map attributes = Collections.singletonMap("browser_type", "chrome"); + Map attributes = new HashMap(); + attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); Map testParams = new HashMap(); testParams.put("test", "params"); @@ -2085,7 +2271,6 @@ public void removeNotificationListener() throws Exception { // Check if listener is notified after a live variable is accessed boolean activateExperiment = true; - optimizely.getVariableString("string_variable", genericUserId, attributes, activateExperiment); verify(listener, never()) .onExperimentActivated(activatedExperiment, genericUserId, attributes, actualVariation); @@ -2095,7 +2280,7 @@ public void removeNotificationListener() throws Exception { Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketer, + mockDecisionService, eventType.getKey(), genericUserId, attributes); @@ -2121,7 +2306,16 @@ public void removeNotificationListener() throws Exception { */ @Test public void clearNotificationListeners() throws Exception { - Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); + Experiment activatedExperiment; + Map attributes = new HashMap(); + if (datafileVersion >= 4) { + activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); + attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + } + else { + activatedExperiment = validProjectConfig.getExperiments().get(0); + attributes.put("browser_type", "chrome"); + } Variation bucketedVariation = activatedExperiment.getVariations().get(0); EventBuilder mockEventBuilder = mock(EventBuilder.class); @@ -2132,8 +2326,6 @@ public void clearNotificationListeners() throws Exception { .withErrorHandler(mockErrorHandler) .build(); - Map attributes = Collections.singletonMap("browser_type", "chrome"); - Map testParams = new HashMap(); testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); @@ -2144,9 +2336,16 @@ public void clearNotificationListeners() throws Exception { when(mockBucketer.bucket(activatedExperiment, genericUserId)) .thenReturn(bucketedVariation); - when(mockEventBuilder.createImpressionEvent(validProjectConfig, activatedExperiment, bucketedVariation, genericUserId, - attributes)) - .thenReturn(logEventToDispatch); + // set up argument captor for the attributes map to compare map equality + ArgumentCaptor attributeCaptor = ArgumentCaptor.forClass(Map.class); + + when(mockEventBuilder.createImpressionEvent( + eq(validProjectConfig), + eq(activatedExperiment), + eq(bucketedVariation), + eq(genericUserId), + attributeCaptor.capture() + )).thenReturn(logEventToDispatch); NotificationListener listener = mock(NotificationListener.class); optimizely.addNotificationListener(listener); @@ -2154,12 +2353,15 @@ public void clearNotificationListeners() throws Exception { // Check if listener is notified after an experiment is activated Variation actualVariation = optimizely.activate(activatedExperiment, genericUserId, attributes); + + // check that the argument that was captured by the mockEventBuilder attribute captor, + // was equal to the attributes passed in to activate + assertEquals(attributes, attributeCaptor.getValue()); verify(listener, never()) .onExperimentActivated(activatedExperiment, genericUserId, attributes, actualVariation); // Check if listener is notified after a live variable is accessed boolean activateExperiment = true; - optimizely.getVariableString("string_variable", genericUserId, attributes, activateExperiment); verify(listener, never()) .onExperimentActivated(activatedExperiment, genericUserId, attributes, actualVariation); @@ -2169,7 +2371,7 @@ public void clearNotificationListeners() throws Exception { Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketer, + mockDecisionService, eventType.getKey(), OptimizelyTest.genericUserId, attributes); @@ -2188,6 +2390,865 @@ public void clearNotificationListeners() throws Exception { .onEventTracked(eventKey, genericUserId, attributes, null, logEventToDispatch); } + //======== Feature Accessor Tests ========// + + /** + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * returns null and logs a message + * when it is called with a feature key that has no corresponding feature in the datafile. + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableValueForTypeReturnsNullWhenFeatureNotFound() throws ConfigParseException { + + String invalidFeatureKey = "nonexistent feature key"; + String invalidVariableKey = "nonexistent variable key"; + Map attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .withDecisionService(mockDecisionService) + .build(); + + String value = optimizely.getFeatureVariableValueForType( + invalidFeatureKey, + invalidVariableKey, + genericUserId, + Collections.emptyMap(), + LiveVariable.VariableType.STRING); + assertNull(value); + + value = optimizely.getFeatureVariableString(invalidFeatureKey, invalidVariableKey, genericUserId, attributes); + assertNull(value); + + logbackVerifier.expectMessage(Level.INFO, + "No feature flag was found for key \"" + invalidFeatureKey + "\".", + times(2)); + + verify(mockDecisionService, never()).getVariation( + any(Experiment.class), + anyString(), + anyMapOf(String.class, String.class)); + } + + /** + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * returns null and logs a message + * when the feature key is valid, but no variable could be found for the variable key in the feature. + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableValueForTypeReturnsNullWhenVariableNotFoundInValidFeature() throws ConfigParseException { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String invalidVariableKey = "nonexistent variable key"; + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .withDecisionService(mockDecisionService) + .build(); + + String value = optimizely.getFeatureVariableValueForType( + validFeatureKey, + invalidVariableKey, + genericUserId, + Collections.emptyMap(), + LiveVariable.VariableType.STRING); + assertNull(value); + + logbackVerifier.expectMessage(Level.INFO, + "No feature variable was found for key \"" + invalidVariableKey + "\" in feature flag \"" + + validFeatureKey + "\"."); + + verify(mockDecisionService, never()).getVariation( + any(Experiment.class), + anyString(), + anyMapOf(String.class, String.class) + ); + } + + /** + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * returns null when the variable's type does not match the type with which it was attempted to be accessed. + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableValueReturnsNullWhenVariableTypeDoesNotMatch() throws ConfigParseException { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_FIRST_LETTER_KEY; + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .withDecisionService(mockDecisionService) + .build(); + + String value = optimizely.getFeatureVariableValueForType( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.emptyMap(), + LiveVariable.VariableType.INTEGER + ); + assertNull(value); + + logbackVerifier.expectMessage( + Level.INFO, + "The feature variable \"" + validVariableKey + + "\" is actually of type \"" + LiveVariable.VariableType.STRING.toString() + + "\" type. You tried to access it as type \"" + LiveVariable.VariableType.INTEGER.toString() + + "\". Please use the appropriate feature variable accessor." + ); + } + + /** + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * returns the String default value of a live variable + * when the feature is not attached to an experiment or a rollout. + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableValueForTypeReturnsDefaultValueWhenFeatureIsNotAttachedToExperimentOrRollout() throws ConfigParseException { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + String validFeatureKey = FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY; + String validVariableKey = VARIABLE_BOOLEAN_VARIABLE_KEY; + String defaultValue = VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE; + Map attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .build(); + + String value = optimizely.getFeatureVariableValueForType( + validFeatureKey, + validVariableKey, + genericUserId, + attributes, + LiveVariable.VariableType.BOOLEAN); + assertEquals(defaultValue, value); + + logbackVerifier.expectMessage( + Level.INFO, + "The feature flag \"" + validFeatureKey + "\" is not used in any experiments." + ); + logbackVerifier.expectMessage( + Level.INFO, + "The feature flag \"" + validFeatureKey + "\" is not used in a rollout." + ); + logbackVerifier.expectMessage( + Level.INFO, + "User \"" + genericUserId + "\" was not bucketed into any variation for feature flag \"" + + validFeatureKey + "\". The default value \"" + + defaultValue + "\" for \"" + + validVariableKey + "\" is being returned." + ); + } + + /** + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * returns the String default value for a live variable + * when the feature is attached to an experiment and no rollout, but the user is excluded from the experiment. + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableValueReturnsDefaultValueWhenFeatureIsAttachedToOneExperimentButFailsTargeting() throws ConfigParseException { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + String validFeatureKey = FEATURE_SINGLE_VARIABLE_DOUBLE_KEY; + String validVariableKey = VARIABLE_DOUBLE_VARIABLE_KEY; + String expectedValue = VARIABLE_DOUBLE_DEFAULT_VALUE; + FeatureFlag featureFlag = FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE; + Experiment experiment = validProjectConfig.getExperimentIdMapping().get(featureFlag.getExperimentIds().get(0)); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .build(); + + String valueWithImproperAttributes = optimizely.getFeatureVariableValueForType( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, "Ravenclaw"), + LiveVariable.VariableType.DOUBLE + ); + assertEquals(expectedValue, valueWithImproperAttributes); + + logbackVerifier.expectMessage( + Level.INFO, + "User \"" + genericUserId + "\" does not meet conditions to be in experiment \"" + + experiment.getKey() + "\"." + ); + logbackVerifier.expectMessage( + Level.INFO, + "The feature flag \"" + validFeatureKey + "\" is not used in a rollout." + ); + logbackVerifier.expectMessage( + Level.INFO, + "User \"" + genericUserId + + "\" was not bucketed into any variation for feature flag \"" + validFeatureKey + + "\". The default value \"" + expectedValue + + "\" for \"" + validVariableKey + "\" is being returned." + ); + } + + /** + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * returns the variable value of the variation the user is bucketed into + * if the variation is not null and the variable has a usage within the variation. + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableValueReturnsVariationValueWhenUserGetsBucketedToVariation() throws ConfigParseException { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_FIRST_LETTER_KEY; + LiveVariable variable = FEATURE_FLAG_MULTI_VARIATE_FEATURE.getVariableKeyToLiveVariableMap().get(validVariableKey); + String expectedValue = VARIATION_MULTIVARIATE_EXPERIMENT_GRED.getVariableIdToLiveVariableUsageInstanceMap().get(variable.getId()).getValue(); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .withDecisionService(mockDecisionService) + .build(); + + doReturn(VARIATION_MULTIVARIATE_EXPERIMENT_GRED).when(mockDecisionService).getVariationForFeature( + FEATURE_FLAG_MULTI_VARIATE_FEATURE, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE) + ); + + String value = optimizely.getFeatureVariableValueForType( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE), + LiveVariable.VariableType.STRING + ); + + assertEquals(expectedValue, value); + } + + /** + * Verify {@link Optimizely#isFeatureEnabled(String, String)} calls into + * {@link Optimizely#isFeatureEnabled(String, String, Map)} and they both + * return False + * when the APIs are called with an feature key that is not in the datafile. + * @throws Exception + */ + @Test + public void isFeatureEnabledReturnsFalseWhenFeatureFlagKeyIsInvalid() throws Exception { + + String invalidFeatureKey = "nonexistent feature key"; + + Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .withDecisionService(mockDecisionService) + .build()); + + assertFalse(spyOptimizely.isFeatureEnabled(invalidFeatureKey, genericUserId)); + + logbackVerifier.expectMessage( + Level.INFO, + "No feature flag was found for key \"" + invalidFeatureKey + "\"." + ); + verify(spyOptimizely, times(1)).isFeatureEnabled( + eq(invalidFeatureKey), + eq(genericUserId), + eq(Collections.emptyMap()) + ); + verify(mockDecisionService, never()).getVariation( + any(Experiment.class), + anyString(), + anyMapOf(String.class, String.class)); + verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); + } + + /** + * Verify {@link Optimizely#isFeatureEnabled(String, String)} calls into + * {@link Optimizely#isFeatureEnabled(String, String, Map)} and they both + * return False + * when the user is not bucketed into any variation for the feature. + * @throws Exception + */ + @Test + public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + + Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .withDecisionService(mockDecisionService) + .build()); + + doReturn(null).when(mockDecisionService).getVariationForFeature( + any(FeatureFlag.class), + anyString(), + anyMapOf(String.class, String.class) + ); + + assertFalse(spyOptimizely.isFeatureEnabled(validFeatureKey, genericUserId)); + + logbackVerifier.expectMessage( + Level.INFO, + "Feature \"" + validFeatureKey + + "\" is not enabled for user \"" + genericUserId + "\"." + ); + verify(spyOptimizely).isFeatureEnabled( + eq(validFeatureKey), + eq(genericUserId), + eq(Collections.emptyMap()) + ); + verify(mockDecisionService).getVariationForFeature( + eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), + eq(genericUserId), + eq(Collections.emptyMap()) + ); + verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); + } + + /** + * Verify {@link Optimizely#isFeatureEnabled(String, String)} calls into + * {@link Optimizely#isFeatureEnabled(String, String, Map)} and they both + * return True + * when the user is bucketed into a variation for the feature. + * An impression event should not be dispatched since the user was not bucketed into an Experiment. + * @throws Exception + */ + @Test + public void isFeatureEnabledReturnsTrueButDoesNotSendWhenUserIsBucketedIntoVariationWithoutExperiment() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + + Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .withDecisionService(mockDecisionService) + .build()); + + doReturn(new Variation("variationId", "variationKey")).when(mockDecisionService).getVariationForFeature( + eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), + eq(genericUserId), + eq(Collections.emptyMap()) + ); + + assertTrue(spyOptimizely.isFeatureEnabled(validFeatureKey, genericUserId)); + + logbackVerifier.expectMessage( + Level.INFO, + "The user \"" + genericUserId + + "\" is not being experimented on in feature \"" + validFeatureKey + "\"." + ); + logbackVerifier.expectMessage( + Level.INFO, + "Feature \"" + validFeatureKey + + "\" is enabled for user \"" + genericUserId + "\"." + ); + verify(spyOptimizely).isFeatureEnabled( + eq(validFeatureKey), + eq(genericUserId), + eq(Collections.emptyMap()) + ); + verify(mockDecisionService).getVariationForFeature( + eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), + eq(genericUserId), + eq(Collections.emptyMap()) + ); + verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); + } + + /** Integration Test + * Verify {@link Optimizely#isFeatureEnabled(String, String, Map)} + * returns True + * when the user is bucketed into a variation for the feature. + * The user is also bucketed into an experiment, so we verify that an event is dispatched. + * @throws Exception + */ + @Test + public void isFeatureEnabledReturnsTrueAndDispatchesEventWhenUserIsBucketedIntoAnExperiment() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .build(); + + assertTrue(optimizely.isFeatureEnabled( + validFeatureKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE) + )); + + logbackVerifier.expectMessage( + Level.INFO, + "Feature \"" + validFeatureKey + + "\" is enabled for user \"" + genericUserId + "\"." + ); + verify(mockEventHandler, times(1)).dispatchEvent(any(LogEvent.class)); + } + + /** + * Verify {@link Optimizely#getFeatureVariableString(String, String, String)} + * calls through to {@link Optimizely#getFeatureVariableString(String, String, String, Map)} + * and returns null + * when {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * returns null + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableStringReturnsNullFromInternal() throws ConfigParseException { + String featureKey = "featureKey"; + String variableKey = "variableKey"; + + Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .build()); + + doReturn(null).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()), + eq(LiveVariable.VariableType.STRING) + ); + + assertNull(spyOptimizely.getFeatureVariableString( + featureKey, + variableKey, + genericUserId + )); + + verify(spyOptimizely).getFeatureVariableString( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()) + ); + } + + /** + * Verify {@link Optimizely#getFeatureVariableString(String, String, String)} + * calls through to {@link Optimizely#getFeatureVariableString(String, String, String, Map)} + * and both return the value returned from + * {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)}. + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableStringReturnsWhatInternalReturns() throws ConfigParseException { + String featureKey = "featureKey"; + String variableKey = "variableKey"; + String valueNoAttributes = "valueNoAttributes"; + String valueWithAttributes = "valueWithAttributes"; + Map attributes = Collections.singletonMap("key", "value"); + + Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .build()); + + + doReturn(valueNoAttributes).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()), + eq(LiveVariable.VariableType.STRING) + ); + + doReturn(valueWithAttributes).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(attributes), + eq(LiveVariable.VariableType.STRING) + ); + + assertEquals(valueNoAttributes, spyOptimizely.getFeatureVariableString( + featureKey, + variableKey, + genericUserId + )); + + verify(spyOptimizely).getFeatureVariableString( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()) + ); + + assertEquals(valueWithAttributes, spyOptimizely.getFeatureVariableString( + featureKey, + variableKey, + genericUserId, + attributes + )); + } + + /** + * Verify {@link Optimizely#getFeatureVariableBoolean(String, String, String)} + * calls through to {@link Optimizely#getFeatureVariableBoolean(String, String, String, Map)} + * and returns null + * when {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * returns null + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableBooleanReturnsNullFromInternal() throws ConfigParseException { + String featureKey = "featureKey"; + String variableKey = "variableKey"; + + Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .build()); + + doReturn(null).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()), + eq(LiveVariable.VariableType.BOOLEAN) + ); + + assertNull(spyOptimizely.getFeatureVariableBoolean( + featureKey, + variableKey, + genericUserId + )); + + verify(spyOptimizely).getFeatureVariableBoolean( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()) + ); + } + + /** + * Verify {@link Optimizely#getFeatureVariableBoolean(String, String, String)} + * calls through to {@link Optimizely#getFeatureVariableBoolean(String, String, String, Map)} + * and both return a Boolean representation of the value returned from + * {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)}. + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableBooleanReturnsWhatInternalReturns() throws ConfigParseException { + String featureKey = "featureKey"; + String variableKey = "variableKey"; + Boolean valueNoAttributes = false; + Boolean valueWithAttributes = true; + Map attributes = Collections.singletonMap("key", "value"); + + Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .build()); + + + doReturn(valueNoAttributes.toString()).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()), + eq(LiveVariable.VariableType.BOOLEAN) + ); + + doReturn(valueWithAttributes.toString()).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(attributes), + eq(LiveVariable.VariableType.BOOLEAN) + ); + + assertEquals(valueNoAttributes, spyOptimizely.getFeatureVariableBoolean( + featureKey, + variableKey, + genericUserId + )); + + verify(spyOptimizely).getFeatureVariableBoolean( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()) + ); + + assertEquals(valueWithAttributes, spyOptimizely.getFeatureVariableBoolean( + featureKey, + variableKey, + genericUserId, + attributes + )); + } + + /** + * Verify {@link Optimizely#getFeatureVariableDouble(String, String, String)} + * calls through to {@link Optimizely#getFeatureVariableDouble(String, String, String, Map)} + * and returns null + * when {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * returns null + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableDoubleReturnsNullFromInternal() throws ConfigParseException { + String featureKey = "featureKey"; + String variableKey = "variableKey"; + + Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .build()); + + doReturn(null).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()), + eq(LiveVariable.VariableType.DOUBLE) + ); + + assertNull(spyOptimizely.getFeatureVariableDouble( + featureKey, + variableKey, + genericUserId + )); + + verify(spyOptimizely).getFeatureVariableDouble( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()) + ); + } + + /** + * Verify {@link Optimizely#getFeatureVariableDouble(String, String, String)} + * calls through to {@link Optimizely#getFeatureVariableDouble(String, String, String, Map)} + * and both return the parsed Double from the value returned from + * {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)}. + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableDoubleReturnsWhatInternalReturns() throws ConfigParseException { + String featureKey = "featureKey"; + String variableKey = "variableKey"; + Double valueNoAttributes = 0.1; + Double valueWithAttributes = 0.2; + Map attributes = Collections.singletonMap("key", "value"); + + Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .build()); + + + doReturn(valueNoAttributes.toString()).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()), + eq(LiveVariable.VariableType.DOUBLE) + ); + + doReturn(valueWithAttributes.toString()).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(attributes), + eq(LiveVariable.VariableType.DOUBLE) + ); + + assertEquals(valueNoAttributes, spyOptimizely.getFeatureVariableDouble( + featureKey, + variableKey, + genericUserId + )); + + verify(spyOptimizely).getFeatureVariableDouble( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()) + ); + + assertEquals(valueWithAttributes, spyOptimizely.getFeatureVariableDouble( + featureKey, + variableKey, + genericUserId, + attributes + )); + } + + /** + * Verify {@link Optimizely#getFeatureVariableInteger(String, String, String)} + * calls through to {@link Optimizely#getFeatureVariableInteger(String, String, String, Map)} + * and returns null + * when {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * returns null + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableIntegerReturnsNullFromInternal() throws ConfigParseException { + String featureKey = "featureKey"; + String variableKey = "variableKey"; + + Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .build()); + + doReturn(null).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()), + eq(LiveVariable.VariableType.INTEGER) + ); + + assertNull(spyOptimizely.getFeatureVariableInteger( + featureKey, + variableKey, + genericUserId + )); + + verify(spyOptimizely).getFeatureVariableInteger( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()) + ); + } + + /** + * Verify that {@link Optimizely#getFeatureVariableDouble(String, String, String)} + * and {@link Optimizely#getFeatureVariableDouble(String, String, String, Map)} + * do not throw errors when they are unable to parse the value into an Double. + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableDoubleCatchesExceptionFromParsing() throws ConfigParseException { + String featureKey = "featureKey"; + String variableKey = "variableKey"; + String unParsableValue = "not_a_double"; + + Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .build()); + + doReturn(unParsableValue).when(spyOptimizely).getFeatureVariableValueForType( + anyString(), + anyString(), + anyString(), + anyMapOf(String.class, String.class), + eq(LiveVariable.VariableType.DOUBLE) + ); + + assertNull(spyOptimizely.getFeatureVariableDouble( + featureKey, + variableKey, + genericUserId + )); + + logbackVerifier.expectMessage( + Level.ERROR, + "NumberFormatException while trying to parse \"" + unParsableValue + + "\" as Double. " + ); + } + + /** + * Verify {@link Optimizely#getFeatureVariableInteger(String, String, String)} + * calls through to {@link Optimizely#getFeatureVariableInteger(String, String, String, Map)} + * and both return the parsed Integer value from the value returned from + * {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)}. + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableIntegerReturnsWhatInternalReturns() throws ConfigParseException { + String featureKey = "featureKey"; + String variableKey = "variableKey"; + Integer valueNoAttributes = 1; + Integer valueWithAttributes = 2; + Map attributes = Collections.singletonMap("key", "value"); + + Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .build()); + + + doReturn(valueNoAttributes.toString()).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()), + eq(LiveVariable.VariableType.INTEGER) + ); + + doReturn(valueWithAttributes.toString()).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(attributes), + eq(LiveVariable.VariableType.INTEGER) + ); + + assertEquals(valueNoAttributes, spyOptimizely.getFeatureVariableInteger( + featureKey, + variableKey, + genericUserId + )); + + verify(spyOptimizely).getFeatureVariableInteger( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.emptyMap()) + ); + + assertEquals(valueWithAttributes, spyOptimizely.getFeatureVariableInteger( + featureKey, + variableKey, + genericUserId, + attributes + )); + } + + /** + * Verify that {@link Optimizely#getFeatureVariableInteger(String, String, String)} + * and {@link Optimizely#getFeatureVariableInteger(String, String, String, Map)} + * do not throw errors when they are unable to parse the value into an Integer. + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableIntegerCatchesExceptionFromParsing() throws ConfigParseException { + String featureKey = "featureKey"; + String variableKey = "variableKey"; + String unParsableValue = "not_an_integer"; + + Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .build()); + + doReturn(unParsableValue).when(spyOptimizely).getFeatureVariableValueForType( + anyString(), + anyString(), + anyString(), + anyMapOf(String.class, String.class), + eq(LiveVariable.VariableType.INTEGER) + ); + + assertNull(spyOptimizely.getFeatureVariableInteger( + featureKey, + variableKey, + genericUserId + )); + + logbackVerifier.expectMessage( + Level.ERROR, + "NumberFormatException while trying to parse \"" + unParsableValue + + "\" as Integer. " + ); + } + //======== Helper methods ========// private Experiment createUnknownExperiment() { 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 0a7ce8e81..9a179fda2 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 @@ -18,8 +18,12 @@ import ch.qos.logback.classic.Level; import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.FeatureFlag; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.ProjectConfigTestUtils; +import com.optimizely.ab.config.Rollout; import com.optimizely.ab.config.TrafficAllocation; +import com.optimizely.ab.config.ValidProjectConfigV4; import com.optimizely.ab.config.Variation; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.internal.LogbackVerifier; @@ -38,17 +42,32 @@ import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigV3; import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV3; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; +import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_HOUSE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_NATIONALITY_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_ENGLISH_CITIZENS_VALUE; +import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_GRYFFINDOR_VALUE; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_FLAG_MULTI_VARIATE_FEATURE; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_MULTI_VARIATE_FEATURE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.ROLLOUT_2; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyMapOf; +import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.atMost; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -65,6 +84,7 @@ public class DecisionServiceTest { @Mock private ErrorHandler mockErrorHandler; private static ProjectConfig noAudienceProjectConfig; + private static ProjectConfig v4ProjectConfig; private static ProjectConfig validProjectConfig; private static Experiment whitelistedExperiment; private static Variation whitelistedVariation; @@ -72,6 +92,7 @@ public class DecisionServiceTest { @BeforeClass public static void setUp() throws Exception { validProjectConfig = validProjectConfigV3(); + v4ProjectConfig = validProjectConfigV4(); noAudienceProjectConfig = noAudienceProjectConfigV3(); whitelistedExperiment = validProjectConfig.getExperimentIdMapping().get("223"); whitelistedVariation = whitelistedExperiment.getVariationKeyToVariationMap().get("vtag1"); @@ -83,11 +104,11 @@ public static void setUp() throws Exception { //========= getVariation tests =========/ /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} gives precedence to forced variation bucketing - * over audience evaluation. + * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} + * gives precedence to forced variation bucketing over audience evaluation. */ @Test - public void getVariationForcedVariationPrecedesAudienceEval() throws Exception { + public void getVariationWhitelistedPrecedesAudienceEval() throws Exception { Bucketer bucketer = spy(new Bucketer(validProjectConfig)); DecisionService decisionService = spy(new DecisionService(bucketer, mockErrorHandler, validProjectConfig, null)); Experiment experiment = validProjectConfig.getExperiments().get(0); @@ -105,6 +126,100 @@ public void getVariationForcedVariationPrecedesAudienceEval() throws Exception { verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class)); } + /** + * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} + * gives precedence to forced variation bucketing over whitelisting. + */ + @Test + public void getForcedVariationBeforeWhitelisting() throws Exception { + Bucketer bucketer = new Bucketer(validProjectConfig); + DecisionService decisionService = spy(new DecisionService(bucketer, mockErrorHandler, validProjectConfig, null)); + Experiment experiment = validProjectConfig.getExperiments().get(0); + Variation whitelistVariation = experiment.getVariations().get(0); + Variation expectedVariation = experiment.getVariations().get(1); + + // user excluded without audiences and whitelisting + assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap())); + + logbackVerifier.expectMessage(Level.INFO, "User \"" + genericUserId + "\" does not meet conditions to be in experiment \"etag1\"."); + + // set the runtimeForcedVariation + validProjectConfig.setForcedVariation(experiment.getKey(), whitelistedUserId, expectedVariation.getKey()); + // no attributes provided for a experiment that has an audience + assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap()), is(expectedVariation)); + + //verify(decisionService).getForcedVariation(experiment.getKey(), whitelistedUserId); + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class)); + assertEquals(decisionService.getWhitelistedVariation(experiment, whitelistedUserId), whitelistVariation); + assertTrue(validProjectConfig.setForcedVariation(experiment.getKey(), whitelistedUserId,null)); + assertNull(validProjectConfig.getForcedVariation(experiment.getKey(), whitelistedUserId)); + assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap()), is(whitelistVariation)); + } + + /** + * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} + * gives precedence to forced variation bucketing over audience evaluation. + */ + @Test + public void getVariationForcedPrecedesAudienceEval() throws Exception { + Bucketer bucketer = spy(new Bucketer(validProjectConfig)); + DecisionService decisionService = spy(new DecisionService(bucketer, mockErrorHandler, validProjectConfig, null)); + Experiment experiment = validProjectConfig.getExperiments().get(0); + Variation expectedVariation = experiment.getVariations().get(1); + + // user excluded without audiences and whitelisting + assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap())); + + logbackVerifier.expectMessage(Level.INFO, "User \"" + genericUserId + "\" does not meet conditions to be in experiment \"etag1\"."); + + // set the runtimeForcedVariation + validProjectConfig.setForcedVariation(experiment.getKey(), genericUserId, expectedVariation.getKey()); + // no attributes provided for a experiment that has an audience + assertThat(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap()), is(expectedVariation)); + + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class)); + assertEquals(validProjectConfig.setForcedVariation(experiment.getKey(), genericUserId,null), true); + assertNull(validProjectConfig.getForcedVariation(experiment.getKey(), genericUserId)); + } + + /** + * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} + * gives precedence to forced variation bucketing over user profile. + */ + @Test + public void getVariationForcedBeforeUserProfile() throws Exception { + Experiment experiment = validProjectConfig.getExperiments().get(0); + Variation variation = experiment.getVariations().get(0); + Bucketer bucketer = spy(new Bucketer(validProjectConfig)); + Decision decision = new Decision(variation.getId()); + UserProfile userProfile = new UserProfile(userProfileId, + Collections.singletonMap(experiment.getId(), decision)); + UserProfileService userProfileService = mock(UserProfileService.class); + when(userProfileService.lookup(userProfileId)).thenReturn(userProfile.toMap()); + + DecisionService decisionService = spy(new DecisionService(bucketer, + mockErrorHandler, validProjectConfig, userProfileService)); + + // ensure that normal users still get excluded from the experiment when they fail audience evaluation + assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap())); + + logbackVerifier.expectMessage(Level.INFO, + "User \"" + genericUserId + "\" does not meet conditions to be in experiment \"" + + experiment.getKey() + "\"."); + + // 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())); + + Variation forcedVariation = experiment.getVariations().get(1); + validProjectConfig.setForcedVariation(experiment.getKey(), userProfileId, forcedVariation.getKey()); + assertEquals(forcedVariation, + decisionService.getVariation(experiment, userProfileId, Collections.emptyMap())); + assertTrue(validProjectConfig.setForcedVariation(experiment.getKey(), userProfileId, null)); + assertNull(validProjectConfig.getForcedVariation(experiment.getKey(), userProfileId)); + + + } /** * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} @@ -134,15 +249,548 @@ public void getVariationEvaluatesUserProfileBeforeAudienceTargeting() throws Exc // 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())); + + } + + /** + * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} + * 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. + */ + @Test + public void getVariationOnNonRunningExperimentWithForcedVariation() { + Experiment experiment = validProjectConfig.getExperiments().get(1); + assertFalse(experiment.isRunning()); + Variation variation = experiment.getVariations().get(0); + Bucketer bucketer = new Bucketer(validProjectConfig); + + DecisionService decisionService = spy(new DecisionService(bucketer, + mockErrorHandler, validProjectConfig, null)); + + // ensure that the not running variation returns null with no forced variation set. + assertNull(decisionService.getVariation(experiment, "userId", Collections.emptyMap())); + + // we call getVariation 3 times on an experiment that is not running. + logbackVerifier.expectMessage(Level.INFO, + "Experiment \"etag2\" is not running.", times(3)); + + // set a forced variation on the user that got back null + assertTrue(validProjectConfig.setForcedVariation(experiment.getKey(), "userId", variation.getKey())); + + // 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())); + + // set the forced variation back to null + assertTrue(validProjectConfig.setForcedVariation(experiment.getKey(), "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())); + + + } + + //========== get Variation for Feature tests ==========// + + /** + * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map)} + * returns null when the {@link FeatureFlag} is not used in an experiments or rollouts. + */ + @Test + @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT") + public void getVariationForFeatureReturnsNullWhenFeatureFlagExperimentIdsIsEmpty() { + FeatureFlag emptyFeatureFlag = mock(FeatureFlag.class); + when(emptyFeatureFlag.getExperimentIds()).thenReturn(Collections.emptyList()); + String featureKey = "testFeatureFlagKey"; + when(emptyFeatureFlag.getKey()).thenReturn(featureKey); + when(emptyFeatureFlag.getRolloutId()).thenReturn(""); + + DecisionService decisionService = new DecisionService( + mock(Bucketer.class), + mockErrorHandler, + validProjectConfig, + null); + + logbackVerifier.expectMessage(Level.INFO, + "The feature flag \"" + featureKey + "\" is not used in any experiments."); + logbackVerifier.expectMessage(Level.INFO, + "The feature flag \"" + featureKey + "\" is not used in a rollout."); + logbackVerifier.expectMessage(Level.INFO, + "The user \"" + genericUserId + "\" was not bucketed into a rollout for feature flag \"" + + featureKey + "\"."); + + assertNull(decisionService.getVariationForFeature( + emptyFeatureFlag, + genericUserId, + Collections.emptyMap())); + + verify(emptyFeatureFlag, times(1)).getExperimentIds(); + verify(emptyFeatureFlag, times(1)).getRolloutId(); + verify(emptyFeatureFlag, times(3)).getKey(); + } + + /** + * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map)} + * returns null when the user is not bucketed into any experiments or rollouts for the {@link FeatureFlag}. + */ + @Test + @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT") + public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperimentsAndRollouts() { + FeatureFlag spyFeatureFlag = spy(FEATURE_FLAG_MULTI_VARIATE_FEATURE); + + DecisionService spyDecisionService = spy(new DecisionService( + mock(Bucketer.class), + mockErrorHandler, + validProjectConfig, + null) + ); + + // do not bucket to any experiments + doReturn(null).when(spyDecisionService).getVariation( + any(Experiment.class), + anyString(), + anyMapOf(String.class, String.class) + ); + // do not bucket to any rollouts + doReturn(null).when(spyDecisionService).getVariationForFeatureInRollout( + any(FeatureFlag.class), + anyString(), + anyMapOf(String.class, String.class) + ); + + // try to get a variation back from the decision service for the feature flag + assertNull(spyDecisionService.getVariationForFeature( + spyFeatureFlag, + genericUserId, + Collections.emptyMap() + )); + + logbackVerifier.expectMessage(Level.INFO, + "The user \"" + genericUserId + "\" was not bucketed into a rollout for feature flag \"" + + FEATURE_MULTI_VARIATE_FEATURE_KEY + "\"."); + + verify(spyFeatureFlag, times(2)).getExperimentIds(); + verify(spyFeatureFlag, times(1)).getKey(); + } + + /** + * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map)} + * returns the variation of the experiment a user gets bucketed into for an experiment. + */ + @Test + @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT") + public void getVariationForFeatureReturnsVariationReturnedFromGetVariation() { + FeatureFlag spyFeatureFlag = spy(ValidProjectConfigV4.FEATURE_FLAG_MUTEX_GROUP_FEATURE); + + DecisionService spyDecisionService = spy(new DecisionService( + mock(Bucketer.class), + mockErrorHandler, + validProjectConfigV4(), + null) + ); + + doReturn(null).when(spyDecisionService).getVariation( + eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1), + anyString(), + anyMapOf(String.class, String.class) + ); + + doReturn(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1).when(spyDecisionService).getVariation( + eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2), + anyString(), + anyMapOf(String.class, String.class) + ); + + assertEquals(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1, + spyDecisionService.getVariationForFeature( + spyFeatureFlag, + genericUserId, + Collections.emptyMap() + )); + + verify(spyFeatureFlag, times(2)).getExperimentIds(); + verify(spyFeatureFlag, never()).getKey(); + } + + /** + * Verify that when getting a {@link Variation} for a {@link FeatureFlag} in + * {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map)}, + * 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}. + */ + @Test + public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() { + FeatureFlag featureFlag = FEATURE_FLAG_MULTI_VARIATE_FEATURE; + Experiment featureExperiment = v4ProjectConfig.getExperimentIdMapping().get(featureFlag.getExperimentIds().get(0)); + assertNotNull(featureExperiment); + Rollout featureRollout = v4ProjectConfig.getRolloutIdMapping().get(featureFlag.getRolloutId()); + Variation experimentVariation = featureExperiment.getVariations().get(0); + Variation rolloutVariation = featureRollout.getExperiments().get(0).getVariations().get(0); + + DecisionService decisionService = spy(new DecisionService( + mock(Bucketer.class), + mockErrorHandler, + v4ProjectConfig, + null + ) + ); + + // return variation for experiment + doReturn(experimentVariation) + .when(decisionService).getVariation( + eq(featureExperiment), + anyString(), + anyMapOf(String.class, String.class) + ); + + // return variation for rollout + doReturn(rolloutVariation) + .when(decisionService).getVariationForFeatureInRollout( + eq(featureFlag), + anyString(), + anyMapOf(String.class, String.class) + ); + + // make sure we get the right variation back + assertEquals(experimentVariation, + decisionService.getVariationForFeature(featureFlag, + genericUserId, + Collections.emptyMap() + ) + ); + + // make sure we do not even check for rollout bucketing + verify(decisionService, never()).getVariationForFeatureInRollout( + any(FeatureFlag.class), + anyString(), + anyMapOf(String.class, String.class) + ); + + // make sure we ask for experiment bucketing once + verify(decisionService, times(1)).getVariation( + any(Experiment.class), + anyString(), + anyMapOf(String.class, String.class) + ); + } + + /** + * Verify that when getting a {@link Variation} for a {@link FeatureFlag} in + * {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map)}, + * check first if the user is bucketed to an {@link Rollout} + * if the user is not bucketed to an experiment. + */ + @Test + public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails() { + FeatureFlag featureFlag = FEATURE_FLAG_MULTI_VARIATE_FEATURE; + Experiment featureExperiment = v4ProjectConfig.getExperimentIdMapping().get(featureFlag.getExperimentIds().get(0)); + assertNotNull(featureExperiment); + Rollout featureRollout = v4ProjectConfig.getRolloutIdMapping().get(featureFlag.getRolloutId()); + Variation rolloutVariation = featureRollout.getExperiments().get(0).getVariations().get(0); + + DecisionService decisionService = spy(new DecisionService( + mock(Bucketer.class), + mockErrorHandler, + v4ProjectConfig, + null + ) + ); + + // return variation for experiment + doReturn(null) + .when(decisionService).getVariation( + eq(featureExperiment), + anyString(), + anyMapOf(String.class, String.class) + ); + + // return variation for rollout + doReturn(rolloutVariation) + .when(decisionService).getVariationForFeatureInRollout( + eq(featureFlag), + anyString(), + anyMapOf(String.class, String.class) + ); + + // make sure we get the right variation back + assertEquals(rolloutVariation, + decisionService.getVariationForFeature(featureFlag, + genericUserId, + Collections.emptyMap() + ) + ); + + // make sure we do not even check for rollout bucketing + verify(decisionService,times(1)).getVariationForFeatureInRollout( + any(FeatureFlag.class), + anyString(), + anyMapOf(String.class, String.class) + ); + + // make sure we ask for experiment bucketing once + verify(decisionService, times(1)).getVariation( + any(Experiment.class), + anyString(), + anyMapOf(String.class, String.class) + ); + + logbackVerifier.expectMessage( + Level.INFO, + "The user \"" + genericUserId + "\" was bucketed into a rollout for feature flag \"" + + featureFlag.getKey() + "\"." + ); + } + + //========== getVariationForFeatureInRollout tests ==========// + + /** + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} + * returns null when trying to bucket a user into a {@link FeatureFlag} + * that does not have a {@link Rollout} attached. + */ + @Test + public void getVariationForFeatureInRolloutReturnsNullWhenFeatureIsNotAttachedToRollout() { + FeatureFlag mockFeatureFlag = mock(FeatureFlag.class); + when(mockFeatureFlag.getRolloutId()).thenReturn(""); + String featureKey = "featureKey"; + when(mockFeatureFlag.getKey()).thenReturn(featureKey); + + DecisionService decisionService = new DecisionService( + mock(Bucketer.class), + mockErrorHandler, + validProjectConfig, + null + ); + + assertNull(decisionService.getVariationForFeatureInRollout( + mockFeatureFlag, + genericUserId, + Collections.emptyMap() + )); + + logbackVerifier.expectMessage( + Level.INFO, + "The feature flag \"" + featureKey + "\" is not used in a rollout." + ); + } + + /** + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} + * return null when a user is excluded from every rule of a rollout due to traffic allocation. + */ + @Test + public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllTraffic() { + Bucketer mockBucketer = mock(Bucketer.class); + when(mockBucketer.bucket(any(Experiment.class), anyString())).thenReturn(null); + + DecisionService decisionService = new DecisionService( + mockBucketer, + mockErrorHandler, + v4ProjectConfig, + null + ); + + assertNull(decisionService.getVariationForFeatureInRollout( + FEATURE_FLAG_MULTI_VARIATE_FEATURE, + genericUserId, + Collections.singletonMap( + ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE + ) + )); + + // with fall back bucketing, the user has at most 2 chances to get bucketed with traffic allocation + // one chance with the audience rollout rule + // one chance with the everyone else rule + verify(mockBucketer, atMost(2)).bucket(any(Experiment.class), anyString()); + } + + /** + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} + * 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. + */ + @Test + public void getVariationForFeatureInRolloutReturnsNullWhenUserFailsAllAudiencesAndTraffic() { + Bucketer mockBucketer = mock(Bucketer.class); + when(mockBucketer.bucket(any(Experiment.class), anyString())).thenReturn(null); + + DecisionService decisionService = new DecisionService( + mockBucketer, + mockErrorHandler, + v4ProjectConfig, + null + ); + + assertNull(decisionService.getVariationForFeatureInRollout( + FEATURE_FLAG_MULTI_VARIATE_FEATURE, + genericUserId, + Collections.emptyMap() + )); + + // user is only bucketed once for the everyone else rule + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString()); + } + + /** + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} + * returns the variation of "Everyone Else" rule + * when the user fails targeting for all rules, but is bucketed into the "Everyone Else" rule. + */ + @Test + public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudienceButSatisfiesTraffic() { + Bucketer mockBucketer = mock(Bucketer.class); + Rollout rollout = ROLLOUT_2; + Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); + Variation expectedVariation = everyoneElseRule.getVariations().get(0); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString())).thenReturn(expectedVariation); + + DecisionService decisionService = new DecisionService( + mockBucketer, + mockErrorHandler, + v4ProjectConfig, + null + ); + + assertEquals(expectedVariation, + decisionService.getVariationForFeatureInRollout( + FEATURE_FLAG_MULTI_VARIATE_FEATURE, + genericUserId, + Collections.emptyMap() + ) + ); + + // verify user is only bucketed once for everyone else rule + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString()); + } + + /** + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} + * 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. + */ + @Test + public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficInRuleAndPassesInEveryoneElse() { + Bucketer mockBucketer = mock(Bucketer.class); + Rollout rollout = ROLLOUT_2; + Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); + Variation expectedVariation = everyoneElseRule.getVariations().get(0); + when(mockBucketer.bucket(any(Experiment.class), anyString())).thenReturn(null); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString())).thenReturn(expectedVariation); + + DecisionService decisionService = new DecisionService( + mockBucketer, + mockErrorHandler, + v4ProjectConfig, + null + ); + + assertEquals(expectedVariation, + decisionService.getVariationForFeatureInRollout( + FEATURE_FLAG_MULTI_VARIATE_FEATURE, + genericUserId, + Collections.singletonMap( + ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE + ) + ) + ); + + // verify user is only bucketed once for everyone else rule + verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString()); + } + + /** + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} + * 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. + * Fallback bucketing should not evaluate any other audiences. + * Even though the user would satisfy a later rollout rule, they are never evaluated for it or bucketed into it. + */ + @Test + public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficInRuleButWouldPassForAnotherRuleAndPassesInEveryoneElse() { + Bucketer mockBucketer = mock(Bucketer.class); + Rollout rollout = ROLLOUT_2; + Experiment englishCitizensRule = rollout.getExperiments().get(2); + Variation englishCitizenVariation = englishCitizensRule.getVariations().get(0); + Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); + Variation expectedVariation = everyoneElseRule.getVariations().get(0); + when(mockBucketer.bucket(any(Experiment.class), anyString())).thenReturn(null); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString())).thenReturn(expectedVariation); + when(mockBucketer.bucket(eq(englishCitizensRule), anyString())).thenReturn(englishCitizenVariation); + + DecisionService decisionService = new DecisionService( + mockBucketer, + mockErrorHandler, + v4ProjectConfig, + null + ); + + assertEquals(expectedVariation, + decisionService.getVariationForFeatureInRollout( + FEATURE_FLAG_MULTI_VARIATE_FEATURE, + genericUserId, + ProjectConfigTestUtils.createMapOfObjects( + ProjectConfigTestUtils.createListOfObjects( + ATTRIBUTE_HOUSE_KEY, ATTRIBUTE_NATIONALITY_KEY + ), + ProjectConfigTestUtils.createListOfObjects( + AUDIENCE_GRYFFINDOR_VALUE, AUDIENCE_ENGLISH_CITIZENS_VALUE + ) + ) + ) + ); + + // verify user is only bucketed once for everyone else rule + verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString()); + } + + /** + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} + * returns the variation of "English Citizens" rule + * when the user fails targeting for previous rules, but passes targeting and traffic for Rule 3. + */ + @Test + public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetingInPreviousRulesButPassesRule3() { + Bucketer mockBucketer = mock(Bucketer.class); + Rollout rollout = ROLLOUT_2; + Experiment englishCitizensRule = rollout.getExperiments().get(2); + Variation englishCitizenVariation = englishCitizensRule.getVariations().get(0); + Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); + Variation everyoneElseVariation = everyoneElseRule.getVariations().get(0); + when(mockBucketer.bucket(any(Experiment.class), anyString())).thenReturn(null); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString())).thenReturn(everyoneElseVariation); + when(mockBucketer.bucket(eq(englishCitizensRule), anyString())).thenReturn(englishCitizenVariation); + + DecisionService decisionService = new DecisionService( + mockBucketer, + mockErrorHandler, + v4ProjectConfig, + null + ); + + assertEquals(englishCitizenVariation, + decisionService.getVariationForFeatureInRollout( + FEATURE_FLAG_MULTI_VARIATE_FEATURE, + genericUserId, + Collections.singletonMap( + ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE + ) + ) + ); + + // verify user is only bucketed once for everyone else rule + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString()); } - //========= white list tests ==========/ + //========= white list tests ==========/ /** * Test {@link DecisionService#getWhitelistedVariation(Experiment, String)} correctly returns a whitelisted variation. */ @Test - public void getForcedVariationReturnsForcedVariation() { + public void getWhitelistedReturnsForcedVariation() { Bucketer bucketer = new Bucketer(validProjectConfig); DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, validProjectConfig, null); @@ -156,7 +804,7 @@ public void getForcedVariationReturnsForcedVariation() { * when an invalid variation key is found in the forced variations mapping. */ @Test - public void getForcedVariationWithInvalidVariation() throws Exception { + public void getWhitelistedWithInvalidVariation() throws Exception { String userId = "testUser1"; String invalidVariationKey = "invalidVarKey"; @@ -187,7 +835,7 @@ public void getForcedVariationWithInvalidVariation() throws Exception { * Verify that {@link DecisionService#getWhitelistedVariation(Experiment, String)} returns null when user is not whitelisted. */ @Test - public void getForcedVariationReturnsNullWhenUserIsNotWhitelisted() throws Exception { + public void getWhitelistedReturnsNullWhenUserIsNotWhitelisted() throws Exception { Bucketer bucketer = new Bucketer(validProjectConfig); DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, validProjectConfig, null); diff --git a/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTest.java b/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTest.java index c0cedc238..609dfddba 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTest.java @@ -33,6 +33,9 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; + import org.junit.Before; import org.junit.Test; @@ -195,4 +198,151 @@ public void verifyAnonymizeIPIsFalseByDefault() throws Exception { ProjectConfig v2ProjectConfig = ProjectConfigTestUtils.validProjectConfigV2(); assertFalse(v2ProjectConfig.getAnonymizeIP()); } + + /** + * Invalid User IDs + + User ID is null + User ID is an empty string + Invalid Experiment IDs + + Experiment key does not exist in the datafile + Experiment key is null + Experiment key is an empty string + Invalid Variation IDs [set only] + + Variation key does not exist in the datafile + Variation key is null + Variation key is an empty string + Multiple set calls [set only] + + Call set variation with different variations on one user/experiment to confirm that each set is expected. + Set variation on multiple variations for one user. + Set variations for multiple users. + */ + /* UserID test */ + @Test + @SuppressFBWarnings("NP") + public void setForcedVariationNullUserId() { + boolean b = projectConfig.setForcedVariation("etag1", null, "vtag1"); + assertFalse(b); + } + @Test + @SuppressFBWarnings("NP") + public void getForcedVariationNullUserId() { + assertNull(projectConfig.getForcedVariation("etag1", null)); + } + + @Test + public void setForcedVariationEmptyUserId() { + assertFalse(projectConfig.setForcedVariation("etag1", "", "vtag1")); + } + @Test + public void getForcedVariationEmptyUserId() { + assertNull(projectConfig.getForcedVariation("etag1", "")); + } + + /* Invalid Experiement */ + @Test + @SuppressFBWarnings("NP") + public void setForcedVariationNullExperimentKey() { + assertFalse(projectConfig.setForcedVariation(null, "testUser1", "vtag1")); + } + @Test + @SuppressFBWarnings("NP") + public void getForcedVariationNullExperimentKey() { + assertNull(projectConfig.getForcedVariation(null, "testUser1")); + } + + @Test + public void setForcedVariationWrongExperimentKey() { + assertFalse(projectConfig.setForcedVariation("wrongKey", "testUser1", "vtag1")); + + } + @Test + public void getForcedVariationWrongExperimentKey() { + assertNull(projectConfig.getForcedVariation("wrongKey", "testUser1")); + } + + @Test + public void setForcedVariationEmptyExperimentKey() { + assertFalse(projectConfig.setForcedVariation("", "testUser1", "vtag1")); + + } + @Test + public void getForcedVariationEmptyExperimentKey() { + assertNull(projectConfig.getForcedVariation("", "testUser1")); + } + + /* Invalid Variation Id (set only */ + @Test + public void setForcedVariationWrongVariationKey() { + assertFalse(projectConfig.setForcedVariation("etag1", "testUser1", "vtag3")); + } + + @Test + public void setForcedVariationNullVariationKey() { + assertFalse(projectConfig.setForcedVariation("etag1", "testUser1", null)); + assertNull(projectConfig.getForcedVariation("etag1", "testUser1")); + } + + @Test + public void setForcedVariationEmptyVariationKey() { + assertFalse(projectConfig.setForcedVariation("etag1", "testUser1", "")); + } + + /* Multiple set calls (set only */ + @Test + public void setForcedVariationDifferentVariations() { + assertTrue(projectConfig.setForcedVariation("etag1", "testUser1", "vtag1")); + assertTrue(projectConfig.setForcedVariation("etag1", "testUser1", "vtag2")); + assertEquals(projectConfig.getForcedVariation("etag1", "testUser1").getKey(), "vtag2"); + assertTrue(projectConfig.setForcedVariation("etag1", "testUser1", null)); + } + + @Test + public void setForcedVariationMultipleVariationsExperiments() { + assertTrue(projectConfig.setForcedVariation("etag1", "testUser1", "vtag1")); + assertTrue(projectConfig.setForcedVariation("etag1", "testUser2", "vtag2")); + assertTrue(projectConfig.setForcedVariation("etag2", "testUser1", "vtag3")); + assertTrue(projectConfig.setForcedVariation("etag2", "testUser2", "vtag4")); + assertEquals(projectConfig.getForcedVariation("etag1", "testUser1").getKey(), "vtag1"); + assertEquals(projectConfig.getForcedVariation("etag1", "testUser2").getKey(), "vtag2"); + assertEquals(projectConfig.getForcedVariation("etag2", "testUser1").getKey(), "vtag3"); + assertEquals(projectConfig.getForcedVariation("etag2", "testUser2").getKey(), "vtag4"); + assertTrue(projectConfig.setForcedVariation("etag1", "testUser1", null)); + assertTrue(projectConfig.setForcedVariation("etag1", "testUser2", null)); + assertTrue(projectConfig.setForcedVariation("etag2", "testUser1", null)); + assertTrue(projectConfig.setForcedVariation("etag2", "testUser2", null)); + assertNull(projectConfig.getForcedVariation("etag1", "testUser1")); + assertNull(projectConfig.getForcedVariation("etag1", "testUser2")); + assertNull(projectConfig.getForcedVariation("etag2", "testUser1")); + assertNull(projectConfig.getForcedVariation("etag2", "testUser2")); + + + } + + @Test + public void setForcedVariationMultipleUsers() { + assertTrue(projectConfig.setForcedVariation("etag1", "testUser1", "vtag1")); + assertTrue(projectConfig.setForcedVariation("etag1", "testUser2", "vtag1")); + assertTrue(projectConfig.setForcedVariation("etag1", "testUser3", "vtag1")); + assertTrue(projectConfig.setForcedVariation("etag1", "testUser4", "vtag1")); + + assertEquals(projectConfig.getForcedVariation("etag1", "testUser1").getKey(), "vtag1"); + assertEquals(projectConfig.getForcedVariation("etag1", "testUser2").getKey(), "vtag1"); + assertEquals(projectConfig.getForcedVariation("etag1", "testUser3").getKey(), "vtag1"); + assertEquals(projectConfig.getForcedVariation("etag1", "testUser4").getKey(), "vtag1"); + + assertTrue(projectConfig.setForcedVariation("etag1", "testUser1", null)); + assertTrue(projectConfig.setForcedVariation("etag1", "testUser2", null)); + assertTrue(projectConfig.setForcedVariation("etag1", "testUser3", null)); + assertTrue(projectConfig.setForcedVariation("etag1", "testUser4", null)); + + assertNull(projectConfig.getForcedVariation("etag1", "testUser1")); + assertNull(projectConfig.getForcedVariation("etag1", "testUser2")); + assertNull(projectConfig.getForcedVariation("etag2", "testUser1")); + assertNull(projectConfig.getForcedVariation("etag2", "testUser2")); + + } } \ No newline at end of file diff --git a/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java b/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java index 0cc3ba1dd..c072d79ee 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java @@ -32,12 +32,14 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; import static java.util.Arrays.asList; import static java.util.Collections.singletonList; import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThat; @@ -377,6 +379,11 @@ private static ProjectConfig generateNoAudienceProjectConfigV3() { events, Collections.emptyList(), true, Collections.emptyList()); } + private static final ProjectConfig VALID_PROJECT_CONFIG_V4 = generateValidProjectConfigV4(); + private static ProjectConfig generateValidProjectConfigV4() { + return ValidProjectConfigV4.generateValidProjectConfigV4(); + } + private ProjectConfigTestUtils() { } public static String validConfigJsonV2() throws IOException { @@ -395,6 +402,10 @@ public static String noAudienceProjectConfigJsonV3() throws IOException { return Resources.toString(Resources.getResource("config/no-audience-project-config-v3.json"), Charsets.UTF_8); } + public static String validConfigJsonV4() throws IOException { + return Resources.toString(Resources.getResource("config/valid-project-config-v4.json"), Charsets.UTF_8); + } + /** * @return the expected {@link ProjectConfig} for the json produced by {@link #validConfigJsonV2()} ()} */ @@ -423,6 +434,10 @@ public static ProjectConfig noAudienceProjectConfigV3() { return NO_AUDIENCE_PROJECT_CONFIG_V3; } + public static ProjectConfig validProjectConfigV4() { + return VALID_PROJECT_CONFIG_V4; + } + /** * Asserts that the provided project configs are equivalent. */ @@ -435,12 +450,14 @@ public static void verifyProjectConfig(@CheckForNull ProjectConfig actual, @Nonn assertThat(actual.getVersion(), is(expected.getVersion())); assertThat(actual.getRevision(), is(expected.getRevision())); - verifyGroups(actual.getGroups(), expected.getGroups()); - verifyExperiments(actual.getExperiments(), expected.getExperiments()); verifyAttributes(actual.getAttributes(), expected.getAttributes()); - verifyEvents(actual.getEventTypes(), expected.getEventTypes()); verifyAudiences(actual.getAudiences(), expected.getAudiences()); + verifyEvents(actual.getEventTypes(), expected.getEventTypes()); + verifyExperiments(actual.getExperiments(), expected.getExperiments()); + verifyFeatureFlags(actual.getFeatureFlags(), expected.getFeatureFlags()); verifyLiveVariables(actual.getLiveVariables(), expected.getLiveVariables()); + verifyGroups(actual.getGroups(), expected.getGroups()); + verifyRollouts(actual.getRollouts(), expected.getRollouts()); } /** @@ -467,6 +484,16 @@ private static void verifyExperiments(List actual, List } } + private static void verifyFeatureFlags(List actual, List expected) { + assertEquals(expected.size(), actual.size()); + for (int i = 0; i < actual.size(); i ++) { + FeatureFlag actualFeatureFlag = actual.get(i); + FeatureFlag expectedFeatureFlag = expected.get(i); + + assertEquals(expectedFeatureFlag, actualFeatureFlag); + } + } + /** * Asserts that the provided variation configs are equivalent. */ @@ -496,7 +523,9 @@ private static void verifyTrafficAllocations(List actual, TrafficAllocation expectedDistribution = expected.get(i); assertThat(actualDistribution.getEntityId(), is(expectedDistribution.getEntityId())); - assertThat(actualDistribution.getEndOfRange(), is(expectedDistribution.getEndOfRange())); + assertEquals("expectedDistribution: " + expectedDistribution.toString() + + "is not equal to the actualDistribution: " + actualDistribution.toString(), + expectedDistribution.getEndOfRange(), actualDistribution.getEndOfRange()); } } @@ -589,6 +618,23 @@ private static void verifyLiveVariables(List actual, List actual, List expected) { + if (expected == null) { + assertNull(actual); + } + else { + assertEquals(expected.size(), actual.size()); + + for (int i = 0; i < actual.size(); i++) { + Rollout actualRollout = actual.get(i); + Rollout expectedRollout = expected.get(i); + + assertEquals(expectedRollout.getId(), actualRollout.getId()); + verifyExperiments(actualRollout.getExperiments(), expectedRollout.getExperiments()); + } + } + } + /** * Verify that the provided variation-level live variable usage instances are equivalent. */ @@ -609,4 +655,26 @@ private static void verifyLiveVariableInstances(List } } } + + public static List createListOfObjects(T ... elements) { + ArrayList list = new ArrayList(elements.length); + for (T element : elements) { + list.add(element); + } + return list; + } + + public static Map createMapOfObjects(Listkeys, Listvalues) { + HashMap map = new HashMap(keys.size()); + if (keys.size() == values.size()) { + Iterator keysIterator = keys.iterator(); + Iterator valuesIterator = values.iterator(); + while (keysIterator.hasNext() && valuesIterator.hasNext()) { + K key = keysIterator.next(); + V value = valuesIterator.next(); + map.put(key, value); + } + } + return map; + } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java new file mode 100644 index 000000000..bc0dab271 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -0,0 +1,984 @@ +/** + * + * Copyright 2017, 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.config; + +import com.optimizely.ab.config.audience.AndCondition; +import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.config.audience.Condition; +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; + +public class ValidProjectConfigV4 { + + // simple properties + private static final String ACCOUNT_ID = "2360254204"; + private static final boolean ANONYMIZE_IP = true; + private static final String PROJECT_ID = "3918735994"; + private static final String REVISION = "1480511547"; + private static final String VERSION = "4"; + + // attributes + private static final String ATTRIBUTE_HOUSE_ID= "553339214"; + public static final String ATTRIBUTE_HOUSE_KEY = "house"; + private static final Attribute ATTRIBUTE_HOUSE = new Attribute(ATTRIBUTE_HOUSE_ID, ATTRIBUTE_HOUSE_KEY); + + private static final String ATTRIBUTE_NATIONALITY_ID = "58339410"; + public static final String ATTRIBUTE_NATIONALITY_KEY = "nationality"; + private static final Attribute ATTRIBUTE_NATIONALITY = new Attribute(ATTRIBUTE_NATIONALITY_ID, ATTRIBUTE_NATIONALITY_KEY); + + // audiences + private static final String CUSTOM_DIMENSION_TYPE = "custom_dimension"; + private static final String AUDIENCE_GRYFFINDOR_ID = "3468206642"; + private static final String AUDIENCE_GRYFFINDOR_KEY = "Gryffindors"; + public static final String AUDIENCE_GRYFFINDOR_VALUE = "Gryffindor"; + private static final Audience AUDIENCE_GRYFFINDOR = new Audience( + AUDIENCE_GRYFFINDOR_ID, + AUDIENCE_GRYFFINDOR_KEY, + new AndCondition(Collections.singletonList( + new OrCondition(Collections.singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_HOUSE_KEY, + CUSTOM_DIMENSION_TYPE, + AUDIENCE_GRYFFINDOR_VALUE))))))) + ); + private static final String AUDIENCE_SLYTHERIN_ID = "3988293898"; + private static final String AUDIENCE_SLYTHERIN_KEY = "Slytherins"; + public static final String AUDIENCE_SLYTHERIN_VALUE = "Slytherin"; + private static final Audience AUDIENCE_SLYTHERIN = new Audience( + AUDIENCE_SLYTHERIN_ID, + AUDIENCE_SLYTHERIN_KEY, + new AndCondition(Collections.singletonList( + new OrCondition(Collections.singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_HOUSE_KEY, + CUSTOM_DIMENSION_TYPE, + AUDIENCE_SLYTHERIN_VALUE))))))) + ); + + private static final String AUDIENCE_ENGLISH_CITIZENS_ID = "4194404272"; + private static final String AUDIENCE_ENGLISH_CITIZENS_KEY = "english_citizens"; + public static final String AUDIENCE_ENGLISH_CITIZENS_VALUE = "English"; + private static final Audience AUDIENCE_ENGLISH_CITIZENS = new Audience( + AUDIENCE_ENGLISH_CITIZENS_ID, + AUDIENCE_ENGLISH_CITIZENS_KEY, + new AndCondition(Collections.singletonList( + new OrCondition(Collections.singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_NATIONALITY_KEY, + CUSTOM_DIMENSION_TYPE, + AUDIENCE_ENGLISH_CITIZENS_VALUE))))))) + ); + + // features + private static final String FEATURE_BOOLEAN_FEATURE_ID = "4195505407"; + private static final String FEATURE_BOOLEAN_FEATURE_KEY = "boolean_feature"; + private static final FeatureFlag FEATURE_FLAG_BOOLEAN_FEATURE = new FeatureFlag( + FEATURE_BOOLEAN_FEATURE_ID, + FEATURE_BOOLEAN_FEATURE_KEY, + "", + Collections.emptyList(), + Collections.emptyList() + ); + private static final String FEATURE_SINGLE_VARIABLE_DOUBLE_ID = "3926744821"; + public static final String FEATURE_SINGLE_VARIABLE_DOUBLE_KEY = "double_single_variable_feature"; + private static final String VARIABLE_DOUBLE_VARIABLE_ID = "4111654444"; + public static final String VARIABLE_DOUBLE_VARIABLE_KEY = "double_variable"; + public static final String VARIABLE_DOUBLE_DEFAULT_VALUE = "14.99"; + private static final LiveVariable VARIABLE_DOUBLE_VARIABLE = new LiveVariable( + VARIABLE_DOUBLE_VARIABLE_ID, + VARIABLE_DOUBLE_VARIABLE_KEY, + VARIABLE_DOUBLE_DEFAULT_VALUE, + null, + LiveVariable.VariableType.DOUBLE + ); + private static final String FEATURE_SINGLE_VARIABLE_INTEGER_ID = "3281420120"; + private static final String FEATURE_SINGLE_VARIABLE_INTEGER_KEY = "integer_single_variable_feature"; + private static final String VARIABLE_INTEGER_VARIABLE_ID = "593964691"; + private static final String VARIABLE_INTEGER_VARIABLE_KEY = "integer_variable"; + private static final String VARIABLE_INTEGER_DEFAULT_VALUE = "7"; + private static final LiveVariable VARIABLE_INTEGER_VARIABLE = new LiveVariable( + VARIABLE_INTEGER_VARIABLE_ID, + VARIABLE_INTEGER_VARIABLE_KEY, + VARIABLE_INTEGER_DEFAULT_VALUE, + null, + LiveVariable.VariableType.INTEGER + ); + private static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_INTEGER = new FeatureFlag( + FEATURE_SINGLE_VARIABLE_INTEGER_ID, + FEATURE_SINGLE_VARIABLE_INTEGER_KEY, + "", + Collections.emptyList(), + Collections.singletonList( + VARIABLE_INTEGER_VARIABLE + ) + ); + private static final String FEATURE_SINGLE_VARIABLE_BOOLEAN_ID = "2591051011"; + public static final String FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY = "boolean_single_variable_feature"; + private static final String VARIABLE_BOOLEAN_VARIABLE_ID = "3974680341"; + public static final String VARIABLE_BOOLEAN_VARIABLE_KEY = "boolean_variable"; + public static final String VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE = "true"; + private static final LiveVariable VARIABLE_BOOLEAN_VARIABLE = new LiveVariable( + VARIABLE_BOOLEAN_VARIABLE_ID, + VARIABLE_BOOLEAN_VARIABLE_KEY, + VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE, + null, + LiveVariable.VariableType.BOOLEAN + ); + private static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_BOOLEAN = new FeatureFlag( + FEATURE_SINGLE_VARIABLE_BOOLEAN_ID, + FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY, + "", + Collections.emptyList(), + Collections.singletonList( + VARIABLE_BOOLEAN_VARIABLE + ) + ); + private static final String FEATURE_SINGLE_VARIABLE_STRING_ID = "2079378557"; + public static final String FEATURE_SINGLE_VARIABLE_STRING_KEY = "string_single_variable_feature"; + private static final String VARIABLE_STRING_VARIABLE_ID = "2077511132"; + public static final String VARIABLE_STRING_VARIABLE_KEY = "string_variable"; + public static final String VARIABLE_STRING_VARIABLE_DEFAULT_VALUE = "wingardium leviosa"; + private static final LiveVariable VARIABLE_STRING_VARIABLE = new LiveVariable( + VARIABLE_STRING_VARIABLE_ID, + VARIABLE_STRING_VARIABLE_KEY, + VARIABLE_STRING_VARIABLE_DEFAULT_VALUE, + null, + LiveVariable.VariableType.STRING + ); + private static final String ROLLOUT_1_ID = "1058508303"; + private static final String ROLLOUT_1_EVERYONE_ELSE_EXPERIMENT_ID = "1785077004"; + private static final String ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID = "1566407342"; + private static final String ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_STRING_VALUE = "lumos"; + private static final Variation ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION = new Variation( + ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, + ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, + Collections.singletonList( + new LiveVariableUsageInstance( + VARIABLE_STRING_VARIABLE_ID, + ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_STRING_VALUE + ) + ) + ); + private static final Experiment ROLLOUT_1_EVERYONE_ELSE_RULE = new Experiment( + ROLLOUT_1_EVERYONE_ELSE_EXPERIMENT_ID, + ROLLOUT_1_EVERYONE_ELSE_EXPERIMENT_ID, + Experiment.ExperimentStatus.RUNNING.toString(), + ROLLOUT_1_ID, + Collections.emptyList(), + Collections.singletonList( + ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION + ), + Collections.emptyMap(), + Collections.singletonList( + new TrafficAllocation( + ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, + 5000 + ) + ) + ); + public static final Rollout ROLLOUT_1 = new Rollout( + ROLLOUT_1_ID, + Collections.singletonList( + ROLLOUT_1_EVERYONE_ELSE_RULE + ) + ); + public static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_STRING = new FeatureFlag( + FEATURE_SINGLE_VARIABLE_STRING_ID, + FEATURE_SINGLE_VARIABLE_STRING_KEY, + ROLLOUT_1_ID, + Collections.emptyList(), + Collections.singletonList( + VARIABLE_STRING_VARIABLE + ) + ); + private static final String FEATURE_MULTI_VARIATE_FEATURE_ID = "3263342226"; + public static final String FEATURE_MULTI_VARIATE_FEATURE_KEY = "multi_variate_feature"; + private static final String VARIABLE_FIRST_LETTER_ID = "675244127"; + public static final String VARIABLE_FIRST_LETTER_KEY = "first_letter"; + public static final String VARIABLE_FIRST_LETTER_DEFAULT_VALUE = "H"; + private static final LiveVariable VARIABLE_FIRST_LETTER_VARIABLE = new LiveVariable( + VARIABLE_FIRST_LETTER_ID, + VARIABLE_FIRST_LETTER_KEY, + VARIABLE_FIRST_LETTER_DEFAULT_VALUE, + null, + LiveVariable.VariableType.STRING + ); + private static final String VARIABLE_REST_OF_NAME_ID = "4052219963"; + private static final String VARIABLE_REST_OF_NAME_KEY = "rest_of_name"; + private static final String VARIABLE_REST_OF_NAME_DEFAULT_VALUE = "arry"; + private static final LiveVariable VARIABLE_REST_OF_NAME_VARIABLE = new LiveVariable( + VARIABLE_REST_OF_NAME_ID, + VARIABLE_REST_OF_NAME_KEY, + VARIABLE_REST_OF_NAME_DEFAULT_VALUE, + null, + LiveVariable.VariableType.STRING + ); + private static final String FEATURE_MUTEX_GROUP_FEATURE_ID = "3263342226"; + public static final String FEATURE_MUTEX_GROUP_FEATURE_KEY = "mutex_group_feature"; + private static final String VARIABLE_CORRELATING_VARIATION_NAME_ID = "2059187672"; + private static final String VARIABLE_CORRELATING_VARIATION_NAME_KEY = "correlating_variation_name"; + private static final String VARIABLE_CORRELATING_VARIATION_NAME_DEFAULT_VALUE = "null"; + private static final LiveVariable VARIABLE_CORRELATING_VARIATION_NAME_VARIABLE = new LiveVariable( + VARIABLE_CORRELATING_VARIATION_NAME_ID, + VARIABLE_CORRELATING_VARIATION_NAME_KEY, + VARIABLE_CORRELATING_VARIATION_NAME_DEFAULT_VALUE, + null, + LiveVariable.VariableType.STRING + ); + + // group IDs + private static final String GROUP_1_ID = "1015968292"; + private static final String GROUP_2_ID = "2606208781"; + + // experiments + private static final String LAYER_BASIC_EXPERIMENT_ID = "1630555626"; + private static final String EXPERIMENT_BASIC_EXPERIMENT_ID = "1323241596"; + public static final String EXPERIMENT_BASIC_EXPERIMENT_KEY = "basic_experiment"; + private static final String VARIATION_BASIC_EXPERIMENT_VARIATION_A_ID = "1423767502"; + private static final String VARIATION_BASIC_EXPERIMENT_VARIATION_A_KEY = "A"; + private static final Variation VARIATION_BASIC_EXPERIMENT_VARIATION_A = new Variation( + VARIATION_BASIC_EXPERIMENT_VARIATION_A_ID, + VARIATION_BASIC_EXPERIMENT_VARIATION_A_KEY, + Collections.emptyList() + ); + private static final String VARIATION_BASIC_EXPERIMENT_VARIATION_B_ID = "3433458314"; + private static final String VARIATION_BASIC_EXPERIMENT_VARIATION_B_KEY = "B"; + private static final Variation VARIATION_BASIC_EXPERIMENT_VARIATION_B = new Variation( + VARIATION_BASIC_EXPERIMENT_VARIATION_B_ID, + VARIATION_BASIC_EXPERIMENT_VARIATION_B_KEY, + Collections.emptyList() + ); + private static final String BASIC_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A = "Harry Potter"; + private static final String BASIC_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B = "Tom Riddle"; + private static final Experiment EXPERIMENT_BASIC_EXPERIMENT = new Experiment( + EXPERIMENT_BASIC_EXPERIMENT_ID, + EXPERIMENT_BASIC_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_BASIC_EXPERIMENT_ID, + Collections.emptyList(), + ProjectConfigTestUtils.createListOfObjects( + VARIATION_BASIC_EXPERIMENT_VARIATION_A, + VARIATION_BASIC_EXPERIMENT_VARIATION_B + ), + ProjectConfigTestUtils.createMapOfObjects( + ProjectConfigTestUtils.createListOfObjects( + BASIC_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A, + BASIC_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B + ), + ProjectConfigTestUtils.createListOfObjects( + VARIATION_BASIC_EXPERIMENT_VARIATION_A_KEY, + VARIATION_BASIC_EXPERIMENT_VARIATION_B_KEY + ) + ), + ProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + VARIATION_BASIC_EXPERIMENT_VARIATION_A_ID, + 5000 + ), + new TrafficAllocation( + VARIATION_BASIC_EXPERIMENT_VARIATION_B_ID, + 10000 + ) + ) + ); + private static final String LAYER_FIRST_GROUPED_EXPERIMENT_ID = "3301900159"; + private static final String EXPERIMENT_FIRST_GROUPED_EXPERIMENT_ID = "2738374745"; + private static final String EXPERIMENT_FIRST_GROUPED_EXPERIMENT_KEY = "first_grouped_experiment"; + private static final String VARIATION_FIRST_GROUPED_EXPERIMENT_A_ID = "2377378132"; + private static final String VARIATION_FIRST_GROUPED_EXPERIMENT_A_KEY = "A"; + private static final Variation VARIATION_FIRST_GROUPED_EXPERIMENT_A = new Variation( + VARIATION_FIRST_GROUPED_EXPERIMENT_A_ID, + VARIATION_FIRST_GROUPED_EXPERIMENT_A_KEY, + Collections.emptyList() + ); + private static final String VARIATION_FIRST_GROUPED_EXPERIMENT_B_ID = "1179171250"; + private static final String VARIATION_FIRST_GROUPED_EXPERIMENT_B_KEY = "B"; + private static final Variation VARIATION_FIRST_GROUPED_EXPERIMENT_B = new Variation( + VARIATION_FIRST_GROUPED_EXPERIMENT_B_ID, + VARIATION_FIRST_GROUPED_EXPERIMENT_B_KEY, + Collections.emptyList() + ); + private static final String FIRST_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A = "Harry Potter"; + private static final String FIRST_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B = "Tom Riddle"; + private static final Experiment EXPERIMENT_FIRST_GROUPED_EXPERIMENT = new Experiment( + EXPERIMENT_FIRST_GROUPED_EXPERIMENT_ID, + EXPERIMENT_FIRST_GROUPED_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_FIRST_GROUPED_EXPERIMENT_ID, + Collections.singletonList(AUDIENCE_GRYFFINDOR_ID), + ProjectConfigTestUtils.createListOfObjects( + VARIATION_FIRST_GROUPED_EXPERIMENT_A, + VARIATION_FIRST_GROUPED_EXPERIMENT_B + ), + ProjectConfigTestUtils.createMapOfObjects( + ProjectConfigTestUtils.createListOfObjects( + FIRST_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A, + FIRST_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B + ), + ProjectConfigTestUtils.createListOfObjects( + VARIATION_FIRST_GROUPED_EXPERIMENT_A_KEY, + VARIATION_FIRST_GROUPED_EXPERIMENT_B_KEY + ) + ), + ProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + VARIATION_FIRST_GROUPED_EXPERIMENT_A_ID, + 5000 + ), + new TrafficAllocation( + VARIATION_FIRST_GROUPED_EXPERIMENT_B_ID, + 10000 + ) + ), + GROUP_1_ID + ); + private static final String LAYER_SECOND_GROUPED_EXPERIMENT_ID = "2625300442"; + private static final String EXPERIMENT_SECOND_GROUPED_EXPERIMENT_ID = "3042640549"; + private static final String EXPERIMENT_SECOND_GROUPED_EXPERIMENT_KEY = "second_grouped_experiment"; + private static final String VARIATION_SECOND_GROUPED_EXPERIMENT_A_ID = "1558539439"; + private static final String VARIATION_SECOND_GROUPED_EXPERIMENT_A_KEY = "A"; + private static final Variation VARIATION_SECOND_GROUPED_EXPERIMENT_A = new Variation( + VARIATION_SECOND_GROUPED_EXPERIMENT_A_ID, + VARIATION_SECOND_GROUPED_EXPERIMENT_A_KEY, + Collections.emptyList() + ); + private static final String VARIATION_SECOND_GROUPED_EXPERIMENT_B_ID = "2142748370"; + private static final String VARIATION_SECOND_GROUPED_EXPERIMENT_B_KEY = "B"; + private static final Variation VARIATION_SECOND_GROUPED_EXPERIMENT_B = new Variation( + VARIATION_SECOND_GROUPED_EXPERIMENT_B_ID, + VARIATION_SECOND_GROUPED_EXPERIMENT_B_KEY, + Collections.emptyList() + ); + private static final String SECOND_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A = "Hermione Granger"; + private static final String SECOND_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B = "Ronald Weasley"; + private static final Experiment EXPERIMENT_SECOND_GROUPED_EXPERIMENT = new Experiment( + EXPERIMENT_SECOND_GROUPED_EXPERIMENT_ID, + EXPERIMENT_SECOND_GROUPED_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_SECOND_GROUPED_EXPERIMENT_ID, + Collections.singletonList(AUDIENCE_GRYFFINDOR_ID), + ProjectConfigTestUtils.createListOfObjects( + VARIATION_SECOND_GROUPED_EXPERIMENT_A, + VARIATION_SECOND_GROUPED_EXPERIMENT_B + ), + ProjectConfigTestUtils.createMapOfObjects( + ProjectConfigTestUtils.createListOfObjects( + SECOND_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A, + SECOND_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B + ), + ProjectConfigTestUtils.createListOfObjects( + VARIATION_SECOND_GROUPED_EXPERIMENT_A_KEY, + VARIATION_SECOND_GROUPED_EXPERIMENT_B_KEY + ) + ), + ProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + VARIATION_SECOND_GROUPED_EXPERIMENT_A_ID, + 5000 + ), + new TrafficAllocation( + VARIATION_SECOND_GROUPED_EXPERIMENT_B_ID, + 10000 + ) + ), + GROUP_1_ID + ); + private static final String LAYER_MULTIVARIATE_EXPERIMENT_ID = "3780747876"; + private static final String EXPERIMENT_MULTIVARIATE_EXPERIMENT_ID = "3262035800"; + public static final String EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY = "multivariate_experiment"; + private static final String VARIATION_MULTIVARIATE_EXPERIMENT_FRED_ID = "1880281238"; + private static final String VARIATION_MULTIVARIATE_EXPERIMENT_FRED_KEY = "Fred"; + private static final Variation VARIATION_MULTIVARIATE_EXPERIMENT_FRED = new Variation( + VARIATION_MULTIVARIATE_EXPERIMENT_FRED_ID, + VARIATION_MULTIVARIATE_EXPERIMENT_FRED_KEY, + ProjectConfigTestUtils.createListOfObjects( + new LiveVariableUsageInstance( + VARIABLE_FIRST_LETTER_ID, + "F" + ), + new LiveVariableUsageInstance( + VARIABLE_REST_OF_NAME_ID, + "red" + ) + ) + ); + private static final String VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_ID = "3631049532"; + private static final String VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_KEY = "Feorge"; + private static final Variation VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE = new Variation( + VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_ID, + VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_KEY, + ProjectConfigTestUtils.createListOfObjects( + new LiveVariableUsageInstance( + VARIABLE_FIRST_LETTER_ID, + "F" + ), + new LiveVariableUsageInstance( + VARIABLE_REST_OF_NAME_ID, + "eorge" + ) + ) + ); + private static final String VARIATION_MULTIVARIATE_EXPERIMENT_GRED_ID = "4204375027"; + public static final String VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY = "Gred"; + public static final Variation VARIATION_MULTIVARIATE_EXPERIMENT_GRED = new Variation( + VARIATION_MULTIVARIATE_EXPERIMENT_GRED_ID, + VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY, + ProjectConfigTestUtils.createListOfObjects( + new LiveVariableUsageInstance( + VARIABLE_FIRST_LETTER_ID, + "G" + ), + new LiveVariableUsageInstance( + VARIABLE_REST_OF_NAME_ID, + "red" + ) + ) + ); + private static final String VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_ID = "2099211198"; + private static final String VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_KEY = "George"; + private static final Variation VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE = new Variation( + VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_ID, + VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_KEY, + ProjectConfigTestUtils.createListOfObjects( + new LiveVariableUsageInstance( + VARIABLE_FIRST_LETTER_ID, + "G" + ), + new LiveVariableUsageInstance( + VARIABLE_REST_OF_NAME_ID, + "eorge" + ) + ) + ); + private static final String MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_FRED = "Fred"; + private static final String MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_FEORGE = "Feorge"; + public static final String MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GRED = "Gred"; + private static final String MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GEORGE = "George"; + private static final Experiment EXPERIMENT_MULTIVARIATE_EXPERIMENT = new Experiment( + EXPERIMENT_MULTIVARIATE_EXPERIMENT_ID, + EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_MULTIVARIATE_EXPERIMENT_ID, + Collections.singletonList(AUDIENCE_GRYFFINDOR_ID), + ProjectConfigTestUtils.createListOfObjects( + VARIATION_MULTIVARIATE_EXPERIMENT_FRED, + VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE, + VARIATION_MULTIVARIATE_EXPERIMENT_GRED, + VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE + ), + ProjectConfigTestUtils.createMapOfObjects( + ProjectConfigTestUtils.createListOfObjects( + MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_FRED, + MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_FEORGE, + MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GRED, + MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GEORGE + ), + ProjectConfigTestUtils.createListOfObjects( + VARIATION_MULTIVARIATE_EXPERIMENT_FRED_KEY, + VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_KEY, + VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY, + VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_KEY + ) + ), + ProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + VARIATION_MULTIVARIATE_EXPERIMENT_FRED_ID, + 2500 + ), + new TrafficAllocation( + VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_ID, + 5000 + ), + new TrafficAllocation( + VARIATION_MULTIVARIATE_EXPERIMENT_GRED_ID, + 7500 + ), + new TrafficAllocation( + VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_ID, + 10000 + ) + ) + ); + + private static final String LAYER_DOUBLE_FEATURE_EXPERIMENT_ID = "1278722008"; + private static final String EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_ID = "2201520193"; + public static final String EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_KEY = "double_single_variable_feature_experiment"; + private static final String VARIATION_DOUBLE_FEATURE_PI_VARIATION_ID = "1505457580"; + private static final String VARIATION_DOUBLE_FEATURE_PI_VARIATION_KEY = "pi_variation"; + private static final Variation VARIATION_DOUBLE_FEATURE_PI_VARIATION = new Variation( + VARIATION_DOUBLE_FEATURE_PI_VARIATION_ID, + VARIATION_DOUBLE_FEATURE_PI_VARIATION_KEY, + Collections.singletonList( + new LiveVariableUsageInstance( + VARIABLE_DOUBLE_VARIABLE_ID, + "3.14" + ) + ) + ); + private static final String VARIATION_DOUBLE_FEATURE_EULER_VARIATION_ID = "119616179"; + private static final String VARIATION_DOUBLE_FEATURE_EULER_VARIATION_KEY = "euler_variation"; + private static final Variation VARIATION_DOUBLE_FEATURE_EULER_VARIATION = new Variation( + VARIATION_DOUBLE_FEATURE_EULER_VARIATION_ID, + VARIATION_DOUBLE_FEATURE_EULER_VARIATION_KEY, + Collections.singletonList( + new LiveVariableUsageInstance( + VARIABLE_DOUBLE_VARIABLE_ID, + "2.718" + ) + ) + ); + private static final Experiment EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT = new Experiment( + EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_ID, + EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_DOUBLE_FEATURE_EXPERIMENT_ID, + Collections.singletonList(AUDIENCE_SLYTHERIN_ID), + ProjectConfigTestUtils.createListOfObjects( + VARIATION_DOUBLE_FEATURE_PI_VARIATION, + VARIATION_DOUBLE_FEATURE_EULER_VARIATION + ), + Collections.emptyMap(), + ProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + VARIATION_DOUBLE_FEATURE_PI_VARIATION_ID, + 4000 + ), + new TrafficAllocation( + VARIATION_DOUBLE_FEATURE_EULER_VARIATION_ID, + 8000 + ) + ) + ); + + private static final String LAYER_PAUSED_EXPERIMENT_ID = "3949273892"; + private static final String EXPERIMENT_PAUSED_EXPERIMENT_ID = "2667098701"; + public static final String EXPERIMENT_PAUSED_EXPERIMENT_KEY = "paused_experiment"; + private static final String VARIATION_PAUSED_EXPERIMENT_CONTROL_ID = "391535909"; + private static final String VARIATION_PAUSED_EXPERIMENT_CONTROL_KEY = "Control"; + private static final Variation VARIATION_PAUSED_EXPERIMENT_CONTROL = new Variation( + VARIATION_PAUSED_EXPERIMENT_CONTROL_ID, + VARIATION_PAUSED_EXPERIMENT_CONTROL_KEY, + Collections.emptyList() + ); + public static final String PAUSED_EXPERIMENT_FORCED_VARIATION_USER_ID_CONTROL = "Harry Potter"; + private static final Experiment EXPERIMENT_PAUSED_EXPERIMENT = new Experiment( + EXPERIMENT_PAUSED_EXPERIMENT_ID, + EXPERIMENT_PAUSED_EXPERIMENT_KEY, + Experiment.ExperimentStatus.PAUSED.toString(), + LAYER_PAUSED_EXPERIMENT_ID, + Collections.emptyList(), + Collections.singletonList(VARIATION_PAUSED_EXPERIMENT_CONTROL), + Collections.singletonMap(PAUSED_EXPERIMENT_FORCED_VARIATION_USER_ID_CONTROL, + VARIATION_PAUSED_EXPERIMENT_CONTROL_KEY), + Collections.singletonList( + new TrafficAllocation( + VARIATION_PAUSED_EXPERIMENT_CONTROL_ID, + 10000 + ) + ) + ); + private static final String LAYER_LAUNCHED_EXPERIMENT_ID = "3587821424"; + private static final String EXPERIMENT_LAUNCHED_EXPERIMENT_ID = "3072915611"; + public static final String EXPERIMENT_LAUNCHED_EXPERIMENT_KEY = "launched_experiment"; + private static final String VARIATION_LAUNCHED_EXPERIMENT_CONTROL_ID = "1647582435"; + private static final String VARIATION_LAUNCHED_EXPERIMENT_CONTROL_KEY = "launch_control"; + private static final Variation VARIATION_LAUNCHED_EXPERIMENT_CONTROL = new Variation( + VARIATION_LAUNCHED_EXPERIMENT_CONTROL_ID, + VARIATION_LAUNCHED_EXPERIMENT_CONTROL_KEY, + Collections.emptyList() + ); + private static final Experiment EXPERIMENT_LAUNCHED_EXPERIMENT = new Experiment( + EXPERIMENT_LAUNCHED_EXPERIMENT_ID, + EXPERIMENT_LAUNCHED_EXPERIMENT_KEY, + Experiment.ExperimentStatus.LAUNCHED.toString(), + LAYER_LAUNCHED_EXPERIMENT_ID, + Collections.emptyList(), + Collections.singletonList(VARIATION_LAUNCHED_EXPERIMENT_CONTROL), + Collections.emptyMap(), + Collections.singletonList( + new TrafficAllocation( + VARIATION_LAUNCHED_EXPERIMENT_CONTROL_ID, + 8000 + ) + ) + ); + private static final String LAYER_MUTEX_GROUP_EXPERIMENT_1_LAYER_ID = "3755588495"; + private static final String EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_ID = "4138322202"; + private static final String EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_KEY = "mutex_group_2_experiment_1"; + private static final String VARIATION_MUTEX_GROUP_EXP_1_VAR_1_ID = "1394671166"; + private static final String VARIATION_MUTEX_GROUP_EXP_1_VAR_1_KEY = "mutex_group_2_experiment_1_variation_1"; + private static final Variation VARIATION_MUTEX_GROUP_EXP_1_VAR_1 = new Variation( + VARIATION_MUTEX_GROUP_EXP_1_VAR_1_ID, + VARIATION_MUTEX_GROUP_EXP_1_VAR_1_KEY, + Collections.singletonList( + new LiveVariableUsageInstance( + VARIABLE_CORRELATING_VARIATION_NAME_ID, + VARIATION_MUTEX_GROUP_EXP_1_VAR_1_KEY + ) + ) + ); + public static final Experiment EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1 = new Experiment( + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_ID, + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_MUTEX_GROUP_EXPERIMENT_1_LAYER_ID, + Collections.emptyList(), + Collections.singletonList(VARIATION_MUTEX_GROUP_EXP_1_VAR_1), + Collections.emptyMap(), + Collections.singletonList( + new TrafficAllocation( + VARIATION_MUTEX_GROUP_EXP_1_VAR_1_ID, + 10000 + ) + ), + GROUP_2_ID + ); + private static final String LAYER_MUTEX_GROUP_EXPERIMENT_2_LAYER_ID = "3818002538"; + private static final String EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_ID = "1786133852"; + private static final String EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_KEY = "mutex_group_2_experiment_2"; + private static final String VARIATION_MUTEX_GROUP_EXP_2_VAR_1_ID = "1619235542"; + private static final String VARIATION_MUTEX_GROUP_EXP_2_VAR_1_KEY = "mutex_group_2_experiment_2_variation_2"; + public static final Variation VARIATION_MUTEX_GROUP_EXP_2_VAR_1 = new Variation( + VARIATION_MUTEX_GROUP_EXP_2_VAR_1_ID, + VARIATION_MUTEX_GROUP_EXP_2_VAR_1_KEY, + Collections.singletonList( + new LiveVariableUsageInstance( + VARIABLE_CORRELATING_VARIATION_NAME_ID, + VARIATION_MUTEX_GROUP_EXP_2_VAR_1_KEY + ) + ) + ); + public static final Experiment EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2 = new Experiment( + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_ID, + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_MUTEX_GROUP_EXPERIMENT_2_LAYER_ID, + Collections.emptyList(), + Collections.singletonList(VARIATION_MUTEX_GROUP_EXP_2_VAR_1), + Collections.emptyMap(), + Collections.singletonList( + new TrafficAllocation( + VARIATION_MUTEX_GROUP_EXP_2_VAR_1_ID, + 10000 + ) + ), + GROUP_2_ID + ); + + // generate groups + private static final Group GROUP_1 = new Group( + GROUP_1_ID, + Group.RANDOM_POLICY, + ProjectConfigTestUtils.createListOfObjects( + EXPERIMENT_FIRST_GROUPED_EXPERIMENT, + EXPERIMENT_SECOND_GROUPED_EXPERIMENT + ), + ProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + EXPERIMENT_FIRST_GROUPED_EXPERIMENT_ID, + 4000 + ), + new TrafficAllocation( + EXPERIMENT_SECOND_GROUPED_EXPERIMENT_ID, + 8000 + ) + ) + ); + private static final Group GROUP_2 = new Group( + GROUP_2_ID, + Group.RANDOM_POLICY, + ProjectConfigTestUtils.createListOfObjects( + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1, + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2 + ), + ProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_ID, + 5000 + ), + new TrafficAllocation( + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_ID, + 10000 + ) + ) + ); + + // events + private static final String EVENT_BASIC_EVENT_ID = "3785620495"; + public static final String EVENT_BASIC_EVENT_KEY = "basic_event"; + private static final EventType EVENT_BASIC_EVENT = new EventType( + EVENT_BASIC_EVENT_ID, + EVENT_BASIC_EVENT_KEY, + ProjectConfigTestUtils.createListOfObjects( + EXPERIMENT_BASIC_EXPERIMENT_ID, + EXPERIMENT_FIRST_GROUPED_EXPERIMENT_ID, + EXPERIMENT_SECOND_GROUPED_EXPERIMENT_ID, + EXPERIMENT_MULTIVARIATE_EXPERIMENT_ID, + EXPERIMENT_LAUNCHED_EXPERIMENT_ID + ) + ); + private static final String EVENT_PAUSED_EXPERIMENT_ID = "3195631717"; + public static final String EVENT_PAUSED_EXPERIMENT_KEY = "event_with_paused_experiment"; + private static final EventType EVENT_PAUSED_EXPERIMENT = new EventType( + EVENT_PAUSED_EXPERIMENT_ID, + EVENT_PAUSED_EXPERIMENT_KEY, + Collections.singletonList( + EXPERIMENT_PAUSED_EXPERIMENT_ID + ) + ); + private static final String EVENT_LAUNCHED_EXPERIMENT_ONLY_ID = "1987018666"; + public static final String EVENT_LAUNCHED_EXPERIMENT_ONLY_KEY = "event_with_launched_experiments_only"; + private static final EventType EVENT_LAUNCHED_EXPERIMENT_ONLY = new EventType( + EVENT_LAUNCHED_EXPERIMENT_ONLY_ID, + EVENT_LAUNCHED_EXPERIMENT_ONLY_KEY, + Collections.singletonList( + EXPERIMENT_LAUNCHED_EXPERIMENT_ID + ) + ); + + // rollouts + private static final String ROLLOUT_2_ID = "813411034"; + private static final Experiment ROLLOUT_2_RULE_1 = new Experiment( + "3421010877", + "3421010877", + Experiment.ExperimentStatus.RUNNING.toString(), + ROLLOUT_2_ID, + Collections.singletonList(AUDIENCE_GRYFFINDOR_ID), + Collections.singletonList( + new Variation( + "521740985", + "521740985", + ProjectConfigTestUtils.createListOfObjects( + new LiveVariableUsageInstance( + "675244127", + "G" + ), + new LiveVariableUsageInstance( + "4052219963", + "odric" + ) + ) + ) + ), + Collections.emptyMap(), + Collections.singletonList( + new TrafficAllocation( + "521740985", + 5000 + ) + ) + ); + private static final Experiment ROLLOUT_2_RULE_2 = new Experiment( + "600050626", + "600050626", + Experiment.ExperimentStatus.RUNNING.toString(), + ROLLOUT_2_ID, + Collections.singletonList(AUDIENCE_SLYTHERIN_ID), + Collections.singletonList( + new Variation( + "180042646", + "180042646", + ProjectConfigTestUtils.createListOfObjects( + new LiveVariableUsageInstance( + "675244127", + "S" + ), + new LiveVariableUsageInstance( + "4052219963", + "alazar" + ) + ) + ) + ), + Collections.emptyMap(), + Collections.singletonList( + new TrafficAllocation( + "180042646", + 5000 + ) + ) + ); + private static final Experiment ROLLOUT_2_RULE_3 = new Experiment( + "2637642575", + "2637642575", + Experiment.ExperimentStatus.RUNNING.toString(), + ROLLOUT_2_ID, + Collections.singletonList(AUDIENCE_ENGLISH_CITIZENS_ID), + Collections.singletonList( + new Variation( + "2346257680", + "2346257680", + ProjectConfigTestUtils.createListOfObjects( + new LiveVariableUsageInstance( + "675244127", + "D" + ), + new LiveVariableUsageInstance( + "4052219963", + "udley" + ) + ) + ) + ), + Collections.emptyMap(), + Collections.singletonList( + new TrafficAllocation( + "2346257680", + 5000 + ) + ) + ); + private static final Experiment ROLLOUT_2_EVERYONE_ELSE_RULE = new Experiment( + "828245624", + "828245624", + Experiment.ExperimentStatus.RUNNING.toString(), + ROLLOUT_2_ID, + Collections.emptyList(), + Collections.singletonList( + new Variation( + "3137445031", + "3137445031", + ProjectConfigTestUtils.createListOfObjects( + new LiveVariableUsageInstance( + "675244127", + "M" + ), + new LiveVariableUsageInstance( + "4052219963", + "uggle" + ) + ) + ) + ), + Collections.emptyMap(), + Collections.singletonList( + new TrafficAllocation( + "3137445031", + 5000 + ) + ) + ); + public static final Rollout ROLLOUT_2 = new Rollout( + ROLLOUT_2_ID, + ProjectConfigTestUtils.createListOfObjects( + ROLLOUT_2_RULE_1, + ROLLOUT_2_RULE_2, + ROLLOUT_2_RULE_3, + ROLLOUT_2_EVERYONE_ELSE_RULE + ) + ); + + // finish features + public static final FeatureFlag FEATURE_FLAG_MULTI_VARIATE_FEATURE = new FeatureFlag( + FEATURE_MULTI_VARIATE_FEATURE_ID, + FEATURE_MULTI_VARIATE_FEATURE_KEY, + ROLLOUT_2_ID, + Collections.singletonList(EXPERIMENT_MULTIVARIATE_EXPERIMENT_ID), + ProjectConfigTestUtils.createListOfObjects( + VARIABLE_FIRST_LETTER_VARIABLE, + VARIABLE_REST_OF_NAME_VARIABLE + ) + ); + public static final FeatureFlag FEATURE_FLAG_MUTEX_GROUP_FEATURE = new FeatureFlag( + FEATURE_MUTEX_GROUP_FEATURE_ID, + FEATURE_MUTEX_GROUP_FEATURE_KEY, + "", + ProjectConfigTestUtils.createListOfObjects( + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_ID, + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_ID + ), + Collections.singletonList( + VARIABLE_CORRELATING_VARIATION_NAME_VARIABLE + ) + ); + public static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE = new FeatureFlag( + FEATURE_SINGLE_VARIABLE_DOUBLE_ID, + FEATURE_SINGLE_VARIABLE_DOUBLE_KEY, + "", + Collections.singletonList( + EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_ID + ), + Collections.singletonList( + VARIABLE_DOUBLE_VARIABLE + ) + ); + + + public static ProjectConfig generateValidProjectConfigV4() { + + // list attributes + List attributes = new ArrayList(); + attributes.add(ATTRIBUTE_HOUSE); + attributes.add(ATTRIBUTE_NATIONALITY); + + // list audiences + List audiences = new ArrayList(); + audiences.add(AUDIENCE_GRYFFINDOR); + audiences.add(AUDIENCE_SLYTHERIN); + audiences.add(AUDIENCE_ENGLISH_CITIZENS); + + // list events + List events = new ArrayList(); + events.add(EVENT_BASIC_EVENT); + events.add(EVENT_PAUSED_EXPERIMENT); + events.add(EVENT_LAUNCHED_EXPERIMENT_ONLY); + + // list experiments + List experiments = new ArrayList(); + experiments.add(EXPERIMENT_BASIC_EXPERIMENT); + experiments.add(EXPERIMENT_MULTIVARIATE_EXPERIMENT); + experiments.add(EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT); + experiments.add(EXPERIMENT_PAUSED_EXPERIMENT); + experiments.add(EXPERIMENT_LAUNCHED_EXPERIMENT); + + // list featureFlags + List featureFlags = new ArrayList(); + featureFlags.add(FEATURE_FLAG_BOOLEAN_FEATURE); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_INTEGER); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_BOOLEAN); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_STRING); + featureFlags.add(FEATURE_FLAG_MULTI_VARIATE_FEATURE); + featureFlags.add(FEATURE_FLAG_MUTEX_GROUP_FEATURE); + + List groups = new ArrayList(); + groups.add(GROUP_1); + groups.add(GROUP_2); + + // list rollouts + List rollouts = new ArrayList(); + rollouts.add(ROLLOUT_1); + rollouts.add(ROLLOUT_2); + + return new ProjectConfig( + ACCOUNT_ID, + ANONYMIZE_IP, + PROJECT_ID, + REVISION, + VERSION, + attributes, + audiences, + events, + experiments, + featureFlags, + groups, + Collections.emptyList(), + rollouts + ); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java index 4bd7da326..3c5cc947e 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java @@ -24,8 +24,10 @@ import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV2; import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV3; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV4; import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV3; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.ProjectConfigTestUtils.verifyProjectConfig; /** @@ -54,6 +56,15 @@ public void parseProjectConfigV3() throws Exception { verifyProjectConfig(actual, expected); } + @Test + public void parseProjectConfigV4() throws Exception { + GsonConfigParser parser = new GsonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + ProjectConfig expected = validProjectConfigV4(); + + verifyProjectConfig(actual, expected); + } + /** * Verify that invalid JSON results in a {@link ConfigParseException} being thrown. */ diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java index 9aba55c60..82e9c8d15 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java @@ -24,8 +24,10 @@ import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV2; import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV3; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV4; import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV3; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.ProjectConfigTestUtils.verifyProjectConfig; /** @@ -54,6 +56,15 @@ public void parseProjectConfigV3() throws Exception { verifyProjectConfig(actual, expected); } + @Test + public void parseProjectConfigV4() throws Exception { + JacksonConfigParser parser = new JacksonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + ProjectConfig expected = validProjectConfigV4(); + + verifyProjectConfig(actual, expected); + } + /** * Verify that invalid JSON results in a {@link ConfigParseException} being thrown. */ diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java index ba078278e..5acc758f9 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java @@ -24,9 +24,11 @@ import org.junit.rules.ExpectedException; import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV2; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV4; import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV3; import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV3; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.ProjectConfigTestUtils.verifyProjectConfig; /** @@ -55,6 +57,15 @@ public void parseProjectConfigV3() throws Exception { verifyProjectConfig(actual, expected); } + @Test + public void parseProjectConfigV4() throws Exception { + JsonConfigParser parser = new JsonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + ProjectConfig expected = validProjectConfigV4(); + + verifyProjectConfig(actual, expected); + } + /** * Verify that invalid JSON results in a {@link ConfigParseException} being thrown. */ diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java index c1bb4ad56..02064ab03 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java @@ -24,9 +24,11 @@ import org.junit.rules.ExpectedException; import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV2; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV4; import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV3; import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV3; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.ProjectConfigTestUtils.verifyProjectConfig; /** @@ -55,6 +57,15 @@ public void parseProjectConfigV3() throws Exception { verifyProjectConfig(actual, expected); } + @Test + public void parseProjectConfigV4() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + ProjectConfig expected = validProjectConfigV4(); + + verifyProjectConfig(actual, expected); + } + /** * Verify that invalid JSON results in a {@link ConfigParseException} being thrown. */ diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/EventBuilderV2Test.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventBuilderV2Test.java index de5a7a379..53719f720 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/EventBuilderV2Test.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/EventBuilderV2Test.java @@ -17,12 +17,17 @@ package com.optimizely.ab.event.internal; import com.google.gson.Gson; +import com.google.gson.internal.LazilyParsedNumber; import com.optimizely.ab.bucketing.Bucketer; +import com.optimizely.ab.bucketing.DecisionService; +import com.optimizely.ab.bucketing.UserProfileService; import com.optimizely.ab.config.Attribute; import com.optimizely.ab.config.EventType; import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.Variation; +import com.optimizely.ab.error.ErrorHandler; +import com.optimizely.ab.error.NoOpErrorHandler; import com.optimizely.ab.event.LogEvent; import com.optimizely.ab.event.internal.payload.Conversion; import com.optimizely.ab.event.internal.payload.Decision; @@ -31,46 +36,80 @@ import com.optimizely.ab.event.internal.payload.Feature; import com.optimizely.ab.event.internal.payload.Impression; import com.optimizely.ab.event.internal.payload.LayerState; -import com.optimizely.ab.internal.ExperimentUtils; import com.optimizely.ab.internal.ReservedEventKey; import org.junit.BeforeClass; import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; import javax.annotation.Nullable; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigJsonV2; +import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigV2; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV2; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV4; import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; +import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_GRYFFINDOR_VALUE; +import static com.optimizely.ab.config.ValidProjectConfigV4.EVENT_BASIC_EVENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.EVENT_PAUSED_EXPERIMENT_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GRED; +import static com.optimizely.ab.config.ValidProjectConfigV4.PAUSED_EXPERIMENT_FORCED_VARIATION_USER_ID_CONTROL; +import static junit.framework.TestCase.assertNotNull; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.Matchers.closeTo; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * Test for {@link EventBuilderV2} */ +@RunWith(Parameterized.class) public class EventBuilderV2Test { + @Parameters + public static Collection data() throws IOException { + return Arrays.asList(new Object[][] { + { + 2, + validProjectConfigV2() + }, + { + 4, + validProjectConfigV4() + } + }); + } + private Gson gson = new Gson(); private EventBuilderV2 builder = new EventBuilderV2(); private static String userId = "userId"; - private static ProjectConfig validProjectConfig; + private int datafileVersion; + private ProjectConfig validProjectConfig; - @BeforeClass - public static void setUp() throws IOException { - validProjectConfig = validProjectConfigV2(); + public EventBuilderV2Test(int datafileVersion, + ProjectConfig validProjectConfig) { + this.datafileVersion = datafileVersion; + this.validProjectConfig = validProjectConfig; } /** @@ -132,8 +171,8 @@ public void createImpressionEventIgnoresUnknownAttributes() throws Exception { // verify that no Feature is created for "unknownAtrribute" -> "blahValue" for (Feature feature : impression.getUserFeatures()) { - assertNotEquals(feature.getName(), "unknownAttribute"); - assertNotEquals(feature.getValue(), "blahValue"); + assertFalse(feature.getName() == "unknownAttribute"); + assertFalse(feature.getValue() == "blahValue"); } } @@ -201,16 +240,22 @@ public void createConversionEvent() throws Exception { // call the bucket function. for (Experiment experiment : allExperiments) { when(mockBucketAlgorithm.bucket(experiment, userId)) - .thenReturn(experiment.getVariations().get(0)); + .thenReturn(experiment.getVariations().get(0)); } + DecisionService decisionService = new DecisionService( + mockBucketAlgorithm, + mock(ErrorHandler.class), + validProjectConfig, + mock(UserProfileService.class) + ); - Map attributeMap = Collections.singletonMap(attribute.getKey(), "value"); + Map attributeMap = Collections.singletonMap(attribute.getKey(), AUDIENCE_GRYFFINDOR_VALUE); Map eventTagMap = new HashMap(); eventTagMap.put("boolean_param", false); eventTagMap.put("string_param", "123"); Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketAlgorithm, + decisionService, eventType.getKey(), userId, attributeMap); @@ -226,7 +271,7 @@ public void createConversionEvent() throws Exception { List expectedLayerStates = new ArrayList(); for (Experiment experiment : experimentsForEventKey) { - if (ExperimentUtils.isExperimentActive(experiment)) { + if (experiment.isRunning()) { LayerState layerState = new LayerState(experiment.getLayerId(), validProjectConfig.getRevision(), new Decision(experiment.getVariations().get(0).getId(), false, experiment.getId()), true); expectedLayerStates.add(layerState); @@ -240,12 +285,12 @@ public void createConversionEvent() throws Exception { // verify payload information assertThat(conversion.getVisitorId(), is(userId)); - assertThat((double)conversion.getTimestamp(), closeTo((double)System.currentTimeMillis(), 60.0)); + assertThat((double)conversion.getTimestamp(), closeTo((double)System.currentTimeMillis(), 120.0)); assertThat(conversion.getProjectId(), is(validProjectConfig.getProjectId())); assertThat(conversion.getAccountId(), is(validProjectConfig.getAccountId())); Feature feature = new Feature(attribute.getId(), attribute.getKey(), Feature.CUSTOM_ATTRIBUTE_FEATURE_TYPE, - "value", true); + AUDIENCE_GRYFFINDOR_VALUE, true); List expectedUserFeatures = Collections.singletonList(feature); // Event Features @@ -255,25 +300,27 @@ public void createConversionEvent() throws Exception { expectedEventFeatures.add(new Feature("", "string_param", Feature.EVENT_FEATURE_TYPE, "123", false)); - assertThat(conversion.getUserFeatures(), is(expectedUserFeatures)); - assertThat(conversion.getLayerStates(), is(expectedLayerStates)); - assertThat(conversion.getEventEntityId(), is(eventType.getId())); - assertThat(conversion.getEventName(), is(eventType.getKey())); - assertThat(conversion.getEventMetrics(), is(Collections.emptyList())); + assertEquals(conversion.getUserFeatures(), expectedUserFeatures); + assertThat(conversion.getLayerStates(), containsInAnyOrder(expectedLayerStates.toArray())); + assertEquals(conversion.getEventEntityId(), eventType.getId()); + assertEquals(conversion.getEventName(), eventType.getKey()); + assertEquals(conversion.getEventMetrics(), Collections.emptyList()); assertTrue(conversion.getEventFeatures().containsAll(expectedEventFeatures)); assertTrue(expectedEventFeatures.containsAll(conversion.getEventFeatures())); assertFalse(conversion.getIsGlobalHoldback()); - assertThat(conversion.getAnonymizeIP(), is(validProjectConfig.getAnonymizeIP())); - assertThat(conversion.getClientEngine(), is(ClientEngine.JAVA_SDK.getClientEngineValue())); - assertThat(conversion.getClientVersion(), is(BuildVersionInfo.VERSION)); + assertEquals(conversion.getAnonymizeIP(), validProjectConfig.getAnonymizeIP()); + assertEquals(conversion.getClientEngine(), ClientEngine.JAVA_SDK.getClientEngineValue()); + assertEquals(conversion.getClientVersion(), BuildVersionInfo.VERSION); } /** - * Verify that eventValue is properly recorded in a conversion request as an {@link EventMetric} + * Verify that "revenue" and "value" are properly recorded in a conversion request as {@link EventMetric} objects. + * "revenue" is fixed-point and "value" is floating-point. */ @Test - public void createConversionParamsWithRevenue() throws Exception { - long revenue = 1234L; + public void createConversionParamsWithEventMetrics() throws Exception { + Long revenue = 1234L; + Double value = 13.37; // use the "valid" project config and its associated experiment, variation, and attributes Attribute attribute = validProjectConfig.getAttributes().get(0); @@ -284,27 +331,35 @@ public void createConversionParamsWithRevenue() throws Exception { // Bucket to the first variation for all experiments. for (Experiment experiment : validProjectConfig.getExperiments()) { when(mockBucketAlgorithm.bucket(experiment, userId)) - .thenReturn(experiment.getVariations().get(0)); + .thenReturn(experiment.getVariations().get(0)); } + DecisionService decisionService = new DecisionService( + mockBucketAlgorithm, + mock(ErrorHandler.class), + validProjectConfig, + mock(UserProfileService.class) + ); Map attributeMap = Collections.singletonMap(attribute.getKey(), "value"); Map eventTagMap = new HashMap(); eventTagMap.put(ReservedEventKey.REVENUE.toString(), revenue); + eventTagMap.put(ReservedEventKey.VALUE.toString(), value); Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketAlgorithm, + decisionService, eventType.getKey(), userId, attributeMap); LogEvent conversionEvent = builder.createConversionEvent(validProjectConfig, experimentVariationMap, userId, - eventType.getId(), eventType.getKey(), attributeMap, - eventTagMap); + eventType.getId(), eventType.getKey(), attributeMap, + eventTagMap); Conversion conversion = gson.fromJson(conversionEvent.getBody(), Conversion.class); - - // we're not going to verify everything, only revenue - assertThat(conversion.getEventMetrics(), - is(Collections.singletonList(new EventMetric(EventMetric.REVENUE_METRIC_TYPE, revenue)))); + List eventMetrics = Arrays.asList( + new EventMetric(EventMetric.REVENUE_METRIC_TYPE, new LazilyParsedNumber(revenue.toString())), + new EventMetric(EventMetric.NUMERIC_METRIC_TYPE, new LazilyParsedNumber(value.toString()))); + // we're not going to verify everything, only the event metrics + assertThat(conversion.getEventMetrics(), is(eventMetrics)); } /** @@ -313,35 +368,52 @@ public void createConversionParamsWithRevenue() throws Exception { */ @Test public void createConversionEventForcedVariationBucketingPrecedesAudienceEval() { - EventType eventType = validProjectConfig.getEventTypes().get(0); - String userId = "testUser1"; - - Bucketer mockBucketAlgorithm = mock(Bucketer.class); - for (Experiment experiment : validProjectConfig.getExperiments()) { - when(mockBucketAlgorithm.bucket(experiment, userId)) - .thenReturn(experiment.getVariations().get(0)); + EventType eventType; + String whitelistedUserId; + if (datafileVersion == 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + whitelistedUserId = MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GRED; + } + else { + eventType = validProjectConfig.getEventTypes().get(0); + whitelistedUserId = "testUser1"; } + DecisionService decisionService = new DecisionService( + new Bucketer(validProjectConfig), + new NoOpErrorHandler(), + validProjectConfig, + mock(UserProfileService.class) + ); + // attributes are empty so user won't be in the audience for experiment using the event, but bucketing // will still take place Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketAlgorithm, + decisionService, eventType.getKey(), - userId, + whitelistedUserId, Collections.emptyMap()); LogEvent conversionEvent = builder.createConversionEvent( validProjectConfig, experimentVariationMap, - userId, + whitelistedUserId, eventType.getId(), eventType.getKey(), Collections.emptyMap(), Collections.emptyMap()); + assertNotNull(conversionEvent); Conversion conversion = gson.fromJson(conversionEvent.getBody(), Conversion.class); - // 1 experiment uses the event - assertThat(conversion.getLayerStates().size(), is(1)); + if (datafileVersion == 4) { + // 2 experiments use the event + // basic experiment has no audience + // user is whitelisted in to one audience + assertEquals(2, conversion.getLayerStates().size()); + } + else { + assertEquals(1, conversion.getLayerStates().size()); + } } /** @@ -350,32 +422,40 @@ public void createConversionEventForcedVariationBucketingPrecedesAudienceEval() */ @Test public void createConversionEventExperimentStatusPrecedesForcedVariation() { - EventType eventType = validProjectConfig.getEventTypes().get(3); - String userId = "userId"; - - Bucketer mockBucketAlgorithm = mock(Bucketer.class); - for (Experiment experiment : validProjectConfig.getExperiments()) { - when(mockBucketAlgorithm.bucket(experiment, userId)) - .thenReturn(experiment.getVariations().get(0)); + EventType eventType; + if (datafileVersion == 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_PAUSED_EXPERIMENT_KEY); } + else { + eventType = validProjectConfig.getEventTypes().get(3); + } + String whitelistedUserId = PAUSED_EXPERIMENT_FORCED_VARIATION_USER_ID_CONTROL; + + Bucketer bucketer = spy(new Bucketer(validProjectConfig)); + DecisionService decisionService = new DecisionService( + bucketer, + mock(ErrorHandler.class), + validProjectConfig, + mock(UserProfileService.class) + ); Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketAlgorithm, + decisionService, eventType.getKey(), - userId, + whitelistedUserId, Collections.emptyMap()); LogEvent conversionEvent = builder.createConversionEvent( validProjectConfig, experimentVariationMap, - userId, + whitelistedUserId, eventType.getId(), eventType.getKey(), Collections.emptyMap(), Collections.emptyMap()); for (Experiment experiment : validProjectConfig.getExperiments()) { - verify(mockBucketAlgorithm, never()).bucket(experiment, userId); + verify(bucketer, never()).bucket(experiment, whitelistedUserId); } assertNull(conversionEvent); @@ -396,11 +476,17 @@ public void createConversionEventAndroidClientEngineClientVersion() throws Excep when(mockBucketAlgorithm.bucket(experiment, userId)) .thenReturn(experiment.getVariations().get(0)); } + DecisionService decisionService = new DecisionService( + mockBucketAlgorithm, + mock(ErrorHandler.class), + validProjectConfig, + mock(UserProfileService.class) + ); Map attributeMap = Collections.singletonMap(attribute.getKey(), "value"); Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketAlgorithm, + decisionService, eventType.getKey(), userId, attributeMap); @@ -484,7 +570,7 @@ public void createConversionEventReturnsNullWhenExperimentVariationMapIsEmpty() //========== helper methods =========// public static Map createExperimentVariationMap(ProjectConfig projectConfig, - Bucketer bucketer, + DecisionService decisionService, String eventName, String userId, @Nullable Map attributes) { @@ -492,9 +578,8 @@ public static Map createExperimentVariationMap(ProjectCon List eventExperiments = projectConfig.getExperimentsForEventKey(eventName); Map experimentVariationMap = new HashMap(eventExperiments.size()); for (Experiment experiment : eventExperiments) { - if (ExperimentUtils.isExperimentActive(experiment) - && experiment.isRunning()) { - Variation variation = bucketer.bucket(experiment, userId); + if (experiment.isRunning()) { + Variation variation = decisionService.getVariation(experiment, userId, attributes); if (variation != null) { experimentVariationMap.put(experiment, variation); } diff --git a/core-api/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java b/core-api/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java index 58d2dd2f5..3ce4f39a7 100644 --- a/core-api/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java +++ b/core-api/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java @@ -11,12 +11,14 @@ import org.mockito.ArgumentMatcher; import org.mockito.Mock; import org.mockito.Mockito; +import org.mockito.verification.VerificationMode; import org.slf4j.LoggerFactory; import java.util.LinkedList; import java.util.List; import static org.mockito.Matchers.argThat; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; @@ -51,11 +53,22 @@ public void expectMessage(Level level) { } public void expectMessage(Level level, String msg) { - expectMessage(level, msg, null); + expectMessage(level, msg, (Class) null); } public void expectMessage(Level level, String msg, Class throwableClass) { - expectedEvents.add(new ExpectedLogEvent(level, msg, throwableClass)); + expectMessage(level, msg, null, times(1)); + } + + public void expectMessage(Level level, String msg, VerificationMode times) { + expectMessage(level, msg, null, times); + } + + public void expectMessage(Level level, + String msg, + Class throwableClass, + VerificationMode times) { + expectedEvents.add(new ExpectedLogEvent(level, msg, throwableClass, times)); } private void before() { @@ -66,7 +79,7 @@ private void before() { private void verify() throws Throwable { for (final ExpectedLogEvent expectedEvent : expectedEvents) { - Mockito.verify(appender).doAppend(argThat(new ArgumentMatcher() { + Mockito.verify(appender, expectedEvent.times).doAppend(argThat(new ArgumentMatcher() { @Override public boolean matches(final Object argument) { return expectedEvent.matches((ILoggingEvent) argument); @@ -83,11 +96,16 @@ private final static class ExpectedLogEvent { private final String message; private final Level level; private final Class throwableClass; + private final VerificationMode times; - private ExpectedLogEvent(Level level, String message, Class throwableClass) { + private ExpectedLogEvent(Level level, + String message, + Class throwableClass, + VerificationMode times) { this.message = message; this.level = level; this.throwableClass = throwableClass; + this.times = times; } private boolean matches(ILoggingEvent actual) { diff --git a/core-api/src/test/resources/config/valid-project-config-v4.json b/core-api/src/test/resources/config/valid-project-config-v4.json new file mode 100644 index 000000000..9ef2e682f --- /dev/null +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -0,0 +1,685 @@ +{ + "accountId": "2360254204", + "anonymizeIP": true, + "projectId": "3918735994", + "revision": "1480511547", + "version": "4", + "audiences": [ + { + "id": "3468206642", + "name": "Gryffindors", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"house\", \"type\": \"custom_dimension\", \"value\":\"Gryffindor\"}]]]" + }, + { + "id": "3988293898", + "name": "Slytherins", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"house\", \"type\": \"custom_dimension\", \"value\":\"Slytherin\"}]]]" + }, + { + "id": "4194404272", + "name": "english_citizens", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"nationality\", \"type\": \"custom_dimension\", \"value\":\"English\"}]]]" + } + ], + "attributes": [ + { + "id": "553339214", + "key": "house" + }, + { + "id": "58339410", + "key": "nationality" + } + ], + "events": [ + { + "id": "3785620495", + "key": "basic_event", + "experimentIds": [ + "1323241596", + "2738374745", + "3042640549", + "3262035800", + "3072915611" + ] + }, + { + "id": "3195631717", + "key": "event_with_paused_experiment", + "experimentIds": [ + "2667098701" + ] + }, + { + "id": "1987018666", + "key": "event_with_launched_experiments_only", + "experimentIds": [ + "3072915611" + ] + } + ], + "experiments": [ + { + "id": "1323241596", + "key": "basic_experiment", + "layerId": "1630555626", + "status": "Running", + "variations": [ + { + "id": "1423767502", + "key": "A", + "variables": [] + }, + { + "id": "3433458314", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767502", + "endOfRange": 5000 + }, + { + "entityId": "3433458314", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "forcedVariations": { + "Harry Potter": "A", + "Tom Riddle": "B" + } + }, + { + "id": "3262035800", + "key": "multivariate_experiment", + "layerId": "3262035800", + "status": "Running", + "variations": [ + { + "id": "1880281238", + "key": "Fred", + "variables": [ + { + "id": "675244127", + "value": "F" + }, + { + "id": "4052219963", + "value": "red" + } + ] + }, + { + "id": "3631049532", + "key": "Feorge", + "variables": [ + { + "id": "675244127", + "value": "F" + }, + { + "id": "4052219963", + "value": "eorge" + } + ] + }, + { + "id": "4204375027", + "key": "Gred", + "variables": [ + { + "id": "675244127", + "value": "G" + }, + { + "id": "4052219963", + "value": "red" + } + ] + }, + { + "id": "2099211198", + "key": "George", + "variables": [ + { + "id": "675244127", + "value": "G" + }, + { + "id": "4052219963", + "value": "eorge" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "1880281238", + "endOfRange": 2500 + }, + { + "entityId": "3631049532", + "endOfRange": 5000 + }, + { + "entityId": "4204375027", + "endOfRange": 7500 + }, + { + "entityId": "2099211198", + "endOfRange": 10000 + } + ], + "audienceIds": [ + "3468206642" + ], + "forcedVariations": { + "Fred": "Fred", + "Feorge": "Feorge", + "Gred": "Gred", + "George": "George" + } + }, + { + "id": "2201520193", + "key": "double_single_variable_feature_experiment", + "layerId": "1278722008", + "status": "Running", + "variations": [ + { + "id": "1505457580", + "key": "pi_variation", + "variables": [ + { + "id": "4111654444", + "value": "3.14" + } + ] + }, + { + "id": "119616179", + "key": "euler_variation", + "variables": [ + { + "id": "4111654444", + "value": "2.718" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "1505457580", + "endOfRange": 4000 + }, + { + "entityId": "119616179", + "endOfRange": 8000 + } + ], + "audienceIds": ["3988293898"], + "forcedVariations": {} + }, + { + "id": "2667098701", + "key": "paused_experiment", + "layerId": "3949273892", + "status": "Paused", + "variations": [ + { + "id": "391535909", + "key": "Control", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "391535909", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "forcedVariations": { + "Harry Potter": "Control" + } + }, + { + "id": "3072915611", + "key": "launched_experiment", + "layerId": "3587821424", + "status": "Launched", + "variations": [ + { + "id": "1647582435", + "key": "launch_control", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1647582435", + "endOfRange": 8000 + } + ], + "audienceIds": [], + "forcedVariations": {} + } + ], + "groups": [ + { + "id": "1015968292", + "policy": "random", + "experiments": [ + { + "id": "2738374745", + "key": "first_grouped_experiment", + "layerId": "3301900159", + "status": "Running", + "variations": [ + { + "id": "2377378132", + "key": "A", + "variables": [] + }, + { + "id": "1179171250", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "2377378132", + "endOfRange": 5000 + }, + { + "entityId": "1179171250", + "endOfRange": 10000 + } + ], + "audienceIds": [ + "3468206642" + ], + "forcedVariations": { + "Harry Potter": "A", + "Tom Riddle": "B" + } + }, + { + "id": "3042640549", + "key": "second_grouped_experiment", + "layerId": "2625300442", + "status": "Running", + "variations": [ + { + "id": "1558539439", + "key": "A", + "variables": [] + }, + { + "id": "2142748370", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1558539439", + "endOfRange": 5000 + }, + { + "entityId": "2142748370", + "endOfRange": 10000 + } + ], + "audienceIds": [ + "3468206642" + ], + "forcedVariations": { + "Hermione Granger": "A", + "Ronald Weasley": "B" + } + } + ], + "trafficAllocation": [ + { + "entityId": "2738374745", + "endOfRange": 4000 + }, + { + "entityId": "3042640549", + "endOfRange": 8000 + } + ] + }, + { + "id": "2606208781", + "policy": "random", + "experiments": [ + { + "id": "4138322202", + "key": "mutex_group_2_experiment_1", + "layerId": "3755588495", + "status": "Running", + "variations": [ + { + "id": "1394671166", + "key": "mutex_group_2_experiment_1_variation_1", + "variables": [ + { + "id": "2059187672", + "value": "mutex_group_2_experiment_1_variation_1" + } + ] + } + ], + "audienceIds": [], + "forcedVariations": {}, + "trafficAllocation": [ + { + "entityId": "1394671166", + "endOfRange": 10000 + } + ] + }, + { + "id": "1786133852", + "key": "mutex_group_2_experiment_2", + "layerId": "3818002538", + "status": "Running", + "variations": [ + { + "id": "1619235542", + "key": "mutex_group_2_experiment_2_variation_2", + "variables": [ + { + "id": "2059187672", + "value": "mutex_group_2_experiment_2_variation_2" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "1619235542", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "forcedVariations": {} + } + ], + "trafficAllocation": [ + { + "entityId": "4138322202", + "endOfRange": 5000 + }, + { + "entityId": "1786133852", + "endOfRange": 10000 + } + ] + } + ], + "featureFlags": [ + { + "id": "4195505407", + "key": "boolean_feature", + "rolloutId": "", + "experimentIds": [], + "variables": [] + }, + { + "id": "3926744821", + "key": "double_single_variable_feature", + "rolloutId": "", + "experimentIds": ["2201520193"], + "variables": [ + { + "id": "4111654444", + "key": "double_variable", + "type": "double", + "defaultValue": "14.99" + } + ] + }, + { + "id": "3281420120", + "key": "integer_single_variable_feature", + "rolloutId": "", + "experimentIds": [], + "variables": [ + { + "id": "593964691", + "key": "integer_variable", + "type": "integer", + "defaultValue": "7" + } + ] + }, + { + "id": "2591051011", + "key": "boolean_single_variable_feature", + "rolloutId": "", + "experimentIds": [], + "variables": [ + { + "id": "3974680341", + "key": "boolean_variable", + "type": "boolean", + "defaultValue": "true" + } + ] + }, + { + "id": "2079378557", + "key": "string_single_variable_feature", + "rolloutId": "1058508303", + "experimentIds": [], + "variables": [ + { + "id": "2077511132", + "key": "string_variable", + "type": "string", + "defaultValue": "wingardium leviosa" + } + ] + }, + { + "id": "3263342226", + "key": "multi_variate_feature", + "rolloutId": "813411034", + "experimentIds": ["3262035800"], + "variables": [ + { + "id": "675244127", + "key": "first_letter", + "type": "string", + "defaultValue": "H" + }, + { + "id": "4052219963", + "key": "rest_of_name", + "type": "string", + "defaultValue": "arry" + } + ] + }, + { + "id": "3263342226", + "key": "mutex_group_feature", + "rolloutId": "", + "experimentIds": ["4138322202", "1786133852"], + "variables": [ + { + "id": "2059187672", + "key": "correlating_variation_name", + "type": "string", + "defaultValue": "null" + } + ] + } + ], + "rollouts": [ + { + "id": "1058508303", + "experiments": [ + { + "id": "1785077004", + "key": "1785077004", + "status": "Running", + "layerId": "1058508303", + "audienceIds": [], + "forcedVariations": {}, + "variations": [ + { + "id": "1566407342", + "key": "1566407342", + "variables": [ + { + "id": "2077511132", + "value": "lumos" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "1566407342", + "endOfRange": 5000 + } + ] + } + ] + }, + { + "id": "813411034", + "experiments": [ + { + "id": "3421010877", + "key": "3421010877", + "status": "Running", + "layerId": "813411034", + "audienceIds": ["3468206642"], + "forcedVariations": {}, + "variations": [ + { + "id": "521740985", + "key": "521740985", + "variables": [ + { + "id": "675244127", + "value": "G" + }, + { + "id": "4052219963", + "value": "odric" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "521740985", + "endOfRange": 5000 + } + ] + }, + { + "id": "600050626", + "key": "600050626", + "status": "Running", + "layerId": "813411034", + "audienceIds": ["3988293898"], + "forcedVariations": {}, + "variations": [ + { + "id": "180042646", + "key": "180042646", + "variables": [ + { + "id": "675244127", + "value": "S" + }, + { + "id": "4052219963", + "value": "alazar" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "180042646", + "endOfRange": 5000 + } + ] + }, + { + "id": "2637642575", + "key": "2637642575", + "status": "Running", + "layerId": "813411034", + "audienceIds": ["4194404272"], + "forcedVariations": {}, + "variations": [ + { + "id": "2346257680", + "key": "2346257680", + "variables": [ + { + "id": "675244127", + "value": "D" + }, + { + "id": "4052219963", + "value": "udley" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "2346257680", + "endOfRange": 5000 + } + ] + }, + { + "id": "828245624", + "key": "828245624", + "status": "Running", + "layerId": "813411034", + "audienceIds": [], + "forcedVariations": {}, + "variations": [ + { + "id": "3137445031", + "key": "3137445031", + "variables": [ + { + "id": "675244127", + "value": "M" + }, + { + "id": "4052219963", + "value": "uggle" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "3137445031", + "endOfRange": 5000 + } + ] + } + ] + } + ], + "variables": [] +} diff --git a/core-httpclient-impl/build.gradle b/core-httpclient-impl/build.gradle index 9a2fa2ad7..7e452d36e 100644 --- a/core-httpclient-impl/build.gradle +++ b/core-httpclient-impl/build.gradle @@ -1,9 +1,7 @@ dependencies { compile project(':core-api') - provided group: 'com.google.code.findbugs', name: 'annotations', version: findbugsVersion - provided group: 'com.google.code.findbugs', name: 'jsr305', version: findbugsVersion - provided group: 'com.google.code.gson', name: 'gson', version: gsonVersion + compileOnly group: 'com.google.code.gson', name: 'gson', version: gsonVersion compile group: 'org.apache.httpcomponents', name: 'httpclient', version: httpClientVersion } diff --git a/gradle.properties b/gradle.properties index a713c6efd..f5ac305dc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,11 +1,10 @@ # Maven version -version = 2.0.0-ALPHA +version = 2.0.0-SNAPSHOT # Artifact paths mavenS3Bucket = optimizely-maven # Gradle Settings -gradleWrapperUrl = https://github.com/optimizely/gradle/releases/download/REL_2.4-20150402032942%2B0000/gradle-2.4-20150402032942.0000-bin.zip org.gradle.configureondemand = true org.gradle.daemon = true org.gradle.parallel = true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 13372aef5..6175a9eb4 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cbeee7a51..63c450a2c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Tue Feb 23 16:11:46 PST 2016 +#Mon Sep 25 16:03:41 PDT 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-2.10-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.0.1-all.zip diff --git a/gradlew b/gradlew index 9d82f7891..cccdd3d51 100755 --- a/gradlew +++ b/gradlew @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh ############################################################################## ## @@ -6,20 +6,38 @@ ## ############################################################################## -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" -warn ( ) { +warn () { echo "$*" } -die ( ) { +die () { echo echo "$*" echo @@ -30,6 +48,7 @@ die ( ) { cygwin=false msys=false darwin=false +nonstop=false case "`uname`" in CYGWIN* ) cygwin=true @@ -40,26 +59,11 @@ case "`uname`" in MINGW* ) msys=true ;; + NONSTOP* ) + nonstop=true + ;; esac -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -85,7 +89,7 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then @@ -150,11 +154,19 @@ if $cygwin ; then esac fi -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " } -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 8a0b282aa..e95643d6a 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,90 +1,84 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega