From 7a53454e84b9d2aaaf31c501da23973fbab49387 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Wed, 14 Jun 2017 10:57:28 -0700 Subject: [PATCH 01/34] change ALPHA back to SNAPSHOT (#107) --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index a713c6efd..c1420b9ba 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Maven version -version = 2.0.0-ALPHA +version = 2.0.0-SNAPSHOT # Artifact paths mavenS3Bucket = optimizely-maven From 9cb5f7149059d18484a87d17f7215b920295d0c9 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Wed, 14 Jun 2017 14:17:11 -0700 Subject: [PATCH 02/34] remove revenue APIs (#108) * remove deprecated revenue methods * remove tests for revenue as a parameter --- .../optimizely/ab/OptimizelyBenchmark.java | 30 ++------- .../java/com/optimizely/ab/Optimizely.java | 20 ------ .../com/optimizely/ab/OptimizelyTest.java | 61 ------------------- 3 files changed, 4 insertions(+), 107 deletions(-) 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..ea8cce60b 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -213,26 +213,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, 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..400a60cec 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -1140,67 +1140,6 @@ public void trackEventWithUnknownAttribute() throws Exception { verify(mockEventHandler).dispatchEvent(logEventToDispatch); } - /** - * Verify that {@link Optimizely#track(String, String, long)} passes through revenue. - */ - @Test - public void trackEventWithRevenue() throws Exception { - EventType eventType = validProjectConfig.getEventTypes().get(0); - long revenue = 1234L; - - // 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(ReservedEventKey.REVENUE.toString(), revenue); - 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()), - eq(Collections.emptyMap()), - eq(eventTags))) - .thenReturn(logEventToDispatch); - - // call track - optimizely.track(eventType.getKey(), genericUserId, revenue); - - // setup the event tag 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(mockEventBuilder).createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - eq(Collections.emptyMap()), - eventTagCaptor.capture()); - - Long actualValue = (Long)eventTagCaptor.getValue().get(ReservedEventKey.REVENUE.toString()); - assertThat(actualValue, is(revenue)); - - 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)} From 046b4c389eeee5582c38829e907c6cf34e7fa252 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Fri, 16 Jun 2017 15:35:02 -0700 Subject: [PATCH 03/34] remove v1 datafile support (#110) --- .../main/java/com/optimizely/ab/config/ProjectConfig.java | 1 - .../com/optimizely/ab/config/parser/JsonConfigParser.java | 6 +----- .../optimizely/ab/config/parser/JsonSimpleConfigParser.java | 6 +----- .../ab/config/parser/ProjectConfigGsonDeserializer.java | 6 +----- .../ab/config/parser/ProjectConfigJacksonDeserializer.java | 6 +----- 5 files changed, 4 insertions(+), 21 deletions(-) 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..c41738581 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 @@ -36,7 +36,6 @@ public class ProjectConfig { public enum Version { - V1 ("1"), V2 ("2"), V3 ("3"); 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..8a37865d8 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 @@ -64,11 +64,7 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse 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")); 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..2c05e0c25 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 @@ -64,11 +64,7 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse 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())); 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..cb0b172c2 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 @@ -60,11 +60,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); 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..e8722086a 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 @@ -57,11 +57,7 @@ public ProjectConfig deserialize(JsonParser parser, DeserializationContext conte 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>() {}); From a18e267b43af039a4892e6aadd1cd9755761393e Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Fri, 16 Jun 2017 16:12:14 -0700 Subject: [PATCH 04/34] Add Features to SDK (#112) --- .../com/optimizely/ab/config/Feature.java | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 core-api/src/main/java/com/optimizely/ab/config/Feature.java diff --git a/core-api/src/main/java/com/optimizely/ab/config/Feature.java b/core-api/src/main/java/com/optimizely/ab/config/Feature.java new file mode 100644 index 000000000..739c6cbce --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/Feature.java @@ -0,0 +1,80 @@ +/** + * + * 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; + +/** + * Represents a Feature definition at the project level + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class Feature implements IdKeyMapped{ + + private final String id; + private final String key; + private final String layerId; + private final List experimentIds; + private final List variables; + + @JsonCreator + public Feature(@JsonProperty("id") String id, + @JsonProperty("key") String key, + @JsonProperty("layerId") String layerId, + @JsonProperty("experimentIds") List experimentIds, + @JsonProperty("variables") List variables) { + this.id = id; + this.key = key; + this.layerId = layerId; + this.experimentIds = experimentIds; + this.variables = variables; + } + + public String getId() { + return id; + } + + public String getKey() { + return key; + } + + public String getLayerId() { + return layerId; + } + + public List getExperimentIds() { + return experimentIds; + } + + public List getVariables() { + return variables; + } + + @Override + public String toString() { + return "Feature{" + + "id='" + id + '\'' + + ", key='" + key + '\'' + + ", layerId='" + layerId + '\'' + + ", experimentIds=" + experimentIds + + ", variables=" + variables + + '}'; + } +} From 146d6c36e3596cfe9b9439fcd632870dc906a231 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Sat, 17 Jun 2017 14:18:24 -0700 Subject: [PATCH 05/34] deprecate live variable APIs (#113) --- core-api/src/main/java/com/optimizely/ab/Optimizely.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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 ea8cce60b..06b2d7dee 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -285,6 +285,7 @@ public void track(@Nonnull String eventName, //======== live variable getters ========// + @Deprecated public @Nullable String getVariableString(@Nonnull String variableKey, @Nonnull String userId, @@ -292,6 +293,7 @@ String getVariableString(@Nonnull String variableKey, return getVariableString(variableKey, userId, Collections.emptyMap(), activateExperiment); } + @Deprecated public @Nullable String getVariableString(@Nonnull String variableKey, @Nonnull String userId, @@ -332,6 +334,7 @@ String getVariableString(@Nonnull String variableKey, return variable.getDefaultValue(); } + @Deprecated public @Nullable Boolean getVariableBoolean(@Nonnull String variableKey, @Nonnull String userId, @@ -339,6 +342,7 @@ Boolean getVariableBoolean(@Nonnull String variableKey, return getVariableBoolean(variableKey, userId, Collections.emptyMap(), activateExperiment); } + @Deprecated public @Nullable Boolean getVariableBoolean(@Nonnull String variableKey, @Nonnull String userId, @@ -354,6 +358,7 @@ Boolean getVariableBoolean(@Nonnull String variableKey, return null; } + @Deprecated public @Nullable Integer getVariableInteger(@Nonnull String variableKey, @Nonnull String userId, @@ -361,6 +366,7 @@ Integer getVariableInteger(@Nonnull String variableKey, return getVariableInteger(variableKey, userId, Collections.emptyMap(), activateExperiment); } + @Deprecated public @Nullable Integer getVariableInteger(@Nonnull String variableKey, @Nonnull String userId, @@ -381,6 +387,7 @@ Integer getVariableInteger(@Nonnull String variableKey, return null; } + @Deprecated public @Nullable Double getVariableDouble(@Nonnull String variableKey, @Nonnull String userId, @@ -388,6 +395,7 @@ Double getVariableDouble(@Nonnull String variableKey, return getVariableDouble(variableKey, userId, Collections.emptyMap(), activateExperiment); } + @Deprecated public @Nullable Double getVariableDouble(@Nonnull String variableKey, @Nonnull String userId, @@ -588,6 +596,7 @@ private EventType getEventTypeOrThrow(ProjectConfig projectConfig, String eventN * @throws UnknownLiveVariableException if there are no event types in the current project config with the given * name */ + @Deprecated private LiveVariable getLiveVariableOrThrow(ProjectConfig projectConfig, String variableKey) throws UnknownLiveVariableException { From 8ecbcd104454cd174239d3d8ac93b7f6561051e6 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Mon, 19 Jun 2017 16:50:54 -0700 Subject: [PATCH 06/34] create v4 test datafile (#114) --- .../ab/config/ProjectConfigTestUtils.java | 9 + .../config/valid-project-config-v4.json | 342 ++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 core-api/src/test/resources/config/valid-project-config-v4.json 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..d73efeb23 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 @@ -377,6 +377,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 null; + } + private ProjectConfigTestUtils() { } public static String validConfigJsonV2() throws IOException { @@ -423,6 +428,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. */ 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..409841ab3 --- /dev/null +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -0,0 +1,342 @@ +{ + "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\"}]]]" + } + ], + "attributes": [ + { + "id": "334265546", + "key": "Gryffindor" + } + ], + "events": [], + "groups": [ + { + "id": "1015968292", + "policy": "random", + "trafficAllocation": [ + { + "entityId": "2738374745", + "endOfRange": 5000 + }, + { + "entityId": "3042640549", + "endOfRange": 10000 + } + ] + } + ], + "features": [ + { + "id": "4195505407", + "key": "boolean_feature", + "layerId": "", + "experimentIds": [], + "variables": [] + }, + { + "id": "3926744821", + "key": "double_single_variable_feature", + "layerId": "", + "experimentIds": [], + "variables": [ + { + "id": "4111654444", + "key": "double_variable", + "type": "double", + "defaultValue": "14.99" + } + ] + }, + { + "id": "3281420120", + "key": "integer_single_variable_feature", + "layerId": "", + "experimentIds": [], + "variables": [ + { + "id": "593964691", + "key": "integer_variable", + "type": "integer", + "defaultValue": "7" + } + ] + }, + { + "id": "2591051011", + "key": "boolean_single_variable_feature", + "layerId": "", + "experimentIds": [], + "variables": [ + { + "id": "3974680341", + "key": "boolean_variable", + "type": "boolean", + "defaultValue": "true" + } + ] + }, + { + "id": "2079378557", + "key": "string_single_variable_feature", + "layerId": "", + "experimentIds": [], + "variables": [ + { + "id": "2077511132", + "key": "string_variable", + "type": "string", + "defaultValue": "wingardium leviosa" + } + ] + }, + { + "id": "3263342226", + "key": "multi_variate_feature", + "layerId": "", + "experimentIds": [], + "variables": [ + { + "id": "675244127", + "key": "first_letter", + "type": "string", + "defaultValue": "H" + }, + { + "id": "4052219963", + "key": "rest_of_name", + "type": "string", + "defaultValue": "arry" + } + ] + } + ], + "layers": [ + { + "id": "1630555626", + "policy": "single_experiment", + "experiments": [ + { + "id": "1323241596", + "key": "basic_experiment", + "status": "Running", + "variations": [ + { + "id": "1423767502", + "key": "A", + "variables": [] + }, + { + "id": "3433458314", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767502", + "endOfRange": 5000 + }, + { + "entityId": "3433458314", + "endOfRange": 10000 + } + ], + "audienceIds": [ + "3468206642" + ], + "forcedVariations": { + "Harry Potter": "A", + "Tom Riddle": "B" + } + } + ] + }, + { + "id": "3301900159", + "policy": "single_experiment", + "experiments": [ + { + "id": "2738374745", + "key": "first_grouped_experiment", + "status": "Running", + "groupId": "1015968292", + "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": "2625300442", + "policy": "single_experiment", + "experiments": [ + { + "id": "3042640549", + "key": "second_grouped_experiment", + "status": "Running", + "groupId": "1015968292", + "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" + } + } + ] + }, + { + "id": "3780747876", + "policy": "single_experiment", + "experiments": [ + { + "id": "3262035800", + "key": "multivariate_experiment", + "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" + } + } + ] + } + ] +} \ No newline at end of file From a18812d3d597dec6005998934f7d8480d8def416 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Tue, 20 Jun 2017 14:32:49 -0700 Subject: [PATCH 07/34] add layers and rollouts (#115) * create layer model in SDK * create rollout model --- .../java/com/optimizely/ab/config/Layer.java | 71 +++++++++++++++++++ .../com/optimizely/ab/config/Rollout.java | 44 ++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 core-api/src/main/java/com/optimizely/ab/config/Layer.java create mode 100644 core-api/src/main/java/com/optimizely/ab/config/Rollout.java diff --git a/core-api/src/main/java/com/optimizely/ab/config/Layer.java b/core-api/src/main/java/com/optimizely/ab/config/Layer.java new file mode 100644 index 000000000..dc4c476b4 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/Layer.java @@ -0,0 +1,71 @@ +/** + * + * 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 javax.annotation.concurrent.Immutable; + +/** + * Represents a Optimizely Layer configuration + * + * @see Project JSON + */ +@Immutable +@JsonIgnoreProperties(ignoreUnknown = true) +public class Layer implements IdMapped { + + protected final String id; + protected final String policy; + protected final List experiments; + + public static final String SINGLE_EXPERIMENT_POLICY = "single_experiment"; + + @JsonCreator + public Layer(@JsonProperty("id") String id, + @JsonProperty("policy") String policy, + @JsonProperty("experiments") List experiments) { + this.id = id; + this.policy = policy; + this.experiments = experiments; + } + + public String getId() { + return id; + } + + public String getPolicy() { + return policy; + } + + public List getExperiments() { + return experiments; + } + + @Override + public String toString() { + return "Layer{" + + "id='" + id + '\'' + + ", policy='" + policy + '\'' + + ", experiments=" + experiments + + '}'; + } +} 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..06b8af1b3 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/Rollout.java @@ -0,0 +1,44 @@ +/** + * + * 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 javax.annotation.concurrent.Immutable; +import java.util.List; + +/** + * Represents a Optimizely Rollout configuration + * + * @see Project JSON + */ +@Immutable +public class Rollout extends Layer implements IdMapped { + + public Rollout(String id, + String policy, + List experiments) { + super(id, policy, experiments); + } + + @Override + public String toString() { + return "Rollout{" + + "id='" + id + '\'' + + ", policy='" + policy + '\'' + + ", experiments=" + experiments + + '}'; + } +} From d88e027ac08b0d0d8a3d88126bf8447f24b0fbfb Mon Sep 17 00:00:00 2001 From: Thomas Zurkan Date: Wed, 21 Jun 2017 17:04:02 -0700 Subject: [PATCH 08/34] updated BuildVersionInfo to try catch on generic exception --- .../optimizely/ab/event/internal/BuildVersionInfo.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 +} From 23b84fc20ceaeeb2bf6874f28df01b68895eb7fa Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Thu, 29 Jun 2017 10:11:41 -0700 Subject: [PATCH 09/34] revise v4 datafile (#119) * update v4 test datafile to conform to additive-only scheme * adjust attribute key and id * adjust group traffic allocation * remove audience from the most basic experiment * add paused experiment and event with only paused experiments * add launched experiment and events using launched experiment --- .../config/valid-project-config-v4.json | 489 ++++++++++-------- 1 file changed, 269 insertions(+), 220 deletions(-) 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 index 409841ab3..8da766ad0 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -13,162 +13,196 @@ ], "attributes": [ { - "id": "334265546", - "key": "Gryffindor" + "id": "553339214", + "key": "house" } ], - "events": [], - "groups": [ + "events": [ { - "id": "1015968292", - "policy": "random", - "trafficAllocation": [ - { - "entityId": "2738374745", - "endOfRange": 5000 - }, - { - "entityId": "3042640549", - "endOfRange": 10000 - } + "id": "3785620495", + "key": "basic_event", + "experimentIds": [ + "1323241596", + "2738374745", + "3042640549", + "3262035800", + "3072915611" ] - } - ], - "features": [ - { - "id": "4195505407", - "key": "boolean_feature", - "layerId": "", - "experimentIds": [], - "variables": [] }, { - "id": "3926744821", - "key": "double_single_variable_feature", - "layerId": "", - "experimentIds": [], - "variables": [ - { - "id": "4111654444", - "key": "double_variable", - "type": "double", - "defaultValue": "14.99" - } + "id": "3195631717", + "key": "event_with_paused_experiment", + "experimentIds": [ + "2667098701" ] }, { - "id": "3281420120", - "key": "integer_single_variable_feature", - "layerId": "", - "experimentIds": [], - "variables": [ - { - "id": "593964691", - "key": "integer_variable", - "type": "integer", - "defaultValue": "7" - } + "id": "1987018666", + "key": "event_with_launched_experiments_only", + "experimentIds": [ + "3072915611" ] - }, + } + ], + "experiments": [ { - "id": "2591051011", - "key": "boolean_single_variable_feature", - "layerId": "", - "experimentIds": [], - "variables": [ + "id": "1323241596", + "key": "basic_experiment", + "layerId": "1630555626", + "status": "Running", + "variations": [ { - "id": "3974680341", - "key": "boolean_variable", - "type": "boolean", - "defaultValue": "true" + "id": "1423767502", + "key": "A", + "variables": [] + }, + { + "id": "3433458314", + "key": "B", + "variables": [] } - ] - }, - { - "id": "2079378557", - "key": "string_single_variable_feature", - "layerId": "", - "experimentIds": [], - "variables": [ + ], + "trafficAllocation": [ { - "id": "2077511132", - "key": "string_variable", - "type": "string", - "defaultValue": "wingardium leviosa" + "entityId": "1423767502", + "endOfRange": 5000 + }, + { + "entityId": "3433458314", + "endOfRange": 10000 } - ] + ], + "audienceIds": [], + "forcedVariations": { + "Harry Potter": "A", + "Tom Riddle": "B" + } }, { - "id": "3263342226", - "key": "multi_variate_feature", - "layerId": "", - "experimentIds": [], - "variables": [ + "id": "3262035800", + "key": "multivariate_experiment", + "layerId": "3262035800", + "status": "Running", + "variations": [ { - "id": "675244127", - "key": "first_letter", - "type": "string", - "defaultValue": "H" + "id": "1880281238", + "key": "Fred", + "variables": [ + { + "id": "675244127", + "value": "F" + }, + { + "id": "4052219963", + "value": "red" + } + ] }, { - "id": "4052219963", - "key": "rest_of_name", - "type": "string", - "defaultValue": "arry" - } - ] - } - ], - "layers": [ - { - "id": "1630555626", - "policy": "single_experiment", - "experiments": [ + "id": "3631049532", + "key": "Feorge", + "variables": [ + { + "id": "675244127", + "value": "F" + }, + { + "id": "4052219963", + "value": "eorge" + } + ] + }, { - "id": "1323241596", - "key": "basic_experiment", - "status": "Running", - "variations": [ + "id": "4204375027", + "key": "Gred", + "variables": [ { - "id": "1423767502", - "key": "A", - "variables": [] + "id": "675244127", + "value": "G" }, { - "id": "3433458314", - "key": "B", - "variables": [] + "id": "4052219963", + "value": "red" } - ], - "trafficAllocation": [ + ] + }, + { + "id": "2099211198", + "key": "George", + "variables": [ { - "entityId": "1423767502", - "endOfRange": 5000 + "id": "675244127", + "value": "G" }, { - "entityId": "3433458314", - "endOfRange": 10000 + "id": "4052219963", + "value": "eorge" } - ], - "audienceIds": [ - "3468206642" - ], - "forcedVariations": { - "Harry Potter": "A", - "Tom Riddle": "B" - } + ] } - ] + ], + "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": "3301900159", - "policy": "single_experiment", + "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" + } + } + ], + "groups": [ + { + "id": "1015968292", + "policy": "random", "experiments": [ { "id": "2738374745", "key": "first_grouped_experiment", + "layerId": "3301900159", "status": "Running", - "groupId": "1015968292", "variations": [ { "id": "2377378132", @@ -198,18 +232,12 @@ "Harry Potter": "A", "Tom Riddle": "B" } - } - ] - }, - { - "id": "2625300442", - "policy": "single_experiment", - "experiments": [ + }, { "id": "3042640549", "key": "second_grouped_experiment", + "layerId": "2625300442", "status": "Running", - "groupId": "1015968292", "variations": [ { "id": "1558539439", @@ -240,103 +268,124 @@ "Ronald Weasley": "B" } } + ], + "trafficAllocation": [ + { + "entityId": "2738374745", + "endOfRange": 4000 + }, + { + "entityId": "3042640549", + "endOfRange": 8000 + } ] + } + ], + "features": [ + { + "id": "4195505407", + "key": "boolean_feature", + "layerId": "", + "experimentIds": [], + "variables": [] }, { - "id": "3780747876", - "policy": "single_experiment", - "experiments": [ + "id": "3926744821", + "key": "double_single_variable_feature", + "layerId": "", + "experimentIds": [], + "variables": [ { - "id": "3262035800", - "key": "multivariate_experiment", - "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": "4111654444", + "key": "double_variable", + "type": "double", + "defaultValue": "14.99" + } + ] + }, + { + "id": "3281420120", + "key": "integer_single_variable_feature", + "layerId": "", + "experimentIds": [], + "variables": [ + { + "id": "593964691", + "key": "integer_variable", + "type": "integer", + "defaultValue": "7" } ] + }, + { + "id": "2591051011", + "key": "boolean_single_variable_feature", + "layerId": "", + "experimentIds": [], + "variables": [ + { + "id": "3974680341", + "key": "boolean_variable", + "type": "boolean", + "defaultValue": "true" + } + ] + }, + { + "id": "2079378557", + "key": "string_single_variable_feature", + "layerId": "", + "experimentIds": [], + "variables": [ + { + "id": "2077511132", + "key": "string_variable", + "type": "string", + "defaultValue": "wingardium leviosa" + } + ] + }, + { + "id": "3263342226", + "key": "multi_variate_feature", + "layerId": "", + "experimentIds": [], + "variables": [ + { + "id": "675244127", + "key": "first_letter", + "type": "string", + "defaultValue": "H" + }, + { + "id": "4052219963", + "key": "rest_of_name", + "type": "string", + "defaultValue": "arry" + } + ] + }, + { + "id": "3072915611", + "key": "launched_experiment", + "layerId": "3587821424", + "status": "Launched", + "variations": [ + { + "id": "1647582435", + "key": "launch_control", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1647582435", + "endOfRange": 8000 + } + ], + "audienceIds": [], + "forcedVariations": {} } - ] -} \ No newline at end of file + ], + "liveVariables": [] +} From f232f4b5d2b9c5175f875956bfff7c18df3da9b6 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Thu, 29 Jun 2017 10:44:03 -0700 Subject: [PATCH 10/34] Feature Accessor APIs (#118) * create public Feature APIs --- .../java/com/optimizely/ab/Optimizely.java | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) 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 06b2d7dee..e048a6511 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -416,6 +416,162 @@ Double getVariableDouble(@Nonnull String variableKey, return null; } + //======== Feature APIs ========// + + /** + * 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. + * Will always return True if toggling the feature is disabled. + * Will return Null if the feature is not found. + */ + public @Nullable Boolean isFeatureEnabled(@Nonnull String featureKey, + @Nonnull String userId) { + return isFeatureEnabled(featureKey, userId, Collections.emptyMap()); + } + + /** + * 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. + * Will always return True if toggling the feature is disabled. + * Will return Null if the feature is not found. + */ + public @Nullable Boolean isFeatureEnabled(@Nonnull String featureKey, + @Nonnull String userId, + @Nonnull Map attributes) { + return getFeatureVariableBoolean(featureKey, "", userId, attributes); + } + + /** + * 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()); + } + + /** + * 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) { + return 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. + * @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()); + } + + /** + * 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) { + return 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. + * @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()); + } + + /** + * 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) { + 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 null; + } + //======== getVariation calls ========// public @Nullable From 5071797daacee2841397ef4827934d872fe26df9 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Mon, 3 Jul 2017 15:48:48 -0700 Subject: [PATCH 11/34] add features to project config (#120) * add version 4 to the Project Config. Add features to the Project Config * add helper methods to ProjectConfigTestUtils to create a list or map of elements * alphabetize mappings and parameters in ProjectConfig. add feature key mapping * update v4 test datafile with launched experiment * refactor feature to featureFlag --- .../java/com/optimizely/ab/Optimizely.java | 2 +- .../config/{Feature.java => FeatureFlag.java} | 16 ++--- .../optimizely/ab/config/ProjectConfig.java | 71 +++++++++++++++---- .../ab/event/internal/payload/Feature.java | 2 +- .../ab/config/ProjectConfigTestUtils.java | 23 ++++++ .../config/valid-project-config-v4.json | 23 +++++- 6 files changed, 114 insertions(+), 23 deletions(-) rename core-api/src/main/java/com/optimizely/ab/config/{Feature.java => FeatureFlag.java} (80%) 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 e048a6511..a329cdb98 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -416,7 +416,7 @@ Double getVariableDouble(@Nonnull String variableKey, return null; } - //======== Feature APIs ========// + //======== FeatureFlag APIs ========// /** * Determine whether a boolean feature is enabled. diff --git a/core-api/src/main/java/com/optimizely/ab/config/Feature.java b/core-api/src/main/java/com/optimizely/ab/config/FeatureFlag.java similarity index 80% rename from core-api/src/main/java/com/optimizely/ab/config/Feature.java rename to core-api/src/main/java/com/optimizely/ab/config/FeatureFlag.java index 739c6cbce..66c8b2675 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/Feature.java +++ b/core-api/src/main/java/com/optimizely/ab/config/FeatureFlag.java @@ -23,10 +23,10 @@ import java.util.List; /** - * Represents a Feature definition at the project level + * Represents a FeatureFlag definition at the project level */ @JsonIgnoreProperties(ignoreUnknown = true) -public class Feature implements IdKeyMapped{ +public class FeatureFlag implements IdKeyMapped{ private final String id; private final String key; @@ -35,11 +35,11 @@ public class Feature implements IdKeyMapped{ private final List variables; @JsonCreator - public Feature(@JsonProperty("id") String id, - @JsonProperty("key") String key, - @JsonProperty("layerId") String layerId, - @JsonProperty("experimentIds") List experimentIds, - @JsonProperty("variables") List variables) { + public FeatureFlag(@JsonProperty("id") String id, + @JsonProperty("key") String key, + @JsonProperty("layerId") String layerId, + @JsonProperty("experimentIds") List experimentIds, + @JsonProperty("variables") List variables) { this.id = id; this.key = key; this.layerId = layerId; @@ -69,7 +69,7 @@ public List getVariables() { @Override public String toString() { - return "Feature{" + + return "FeatureFlag{" + "id='" + id + '\'' + ", key='" + key + '\'' + ", layerId='" + layerId + '\'' + 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 c41738581..fac0bbdce 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 @@ -37,7 +37,8 @@ public class ProjectConfig { public enum Version { V2 ("2"), - V3 ("3"); + V3 ("3"), + V4 ("4"); private final String version; @@ -56,24 +57,31 @@ public String toString() { 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; - // 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; + + // other mappings private final Map> liveVariableIdToExperimentsMapping; private final Map> variationToLiveVariableUsageInstanceMapping; + // v2 constructor public ProjectConfig(String accountId, String projectId, String version, String revision, List groups, List experiments, List attributes, List eventType, List audiences) { @@ -81,9 +89,39 @@ 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 + ); + } + + // 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) { this.accountId = accountId; this.projectId = projectId; @@ -91,19 +129,28 @@ 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); + } + 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); // 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); 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/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java b/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java index d73efeb23..f91948464 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,6 +32,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; @@ -618,4 +619,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/resources/config/valid-project-config-v4.json b/core-api/src/test/resources/config/valid-project-config-v4.json index 8da766ad0..0c994623c 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -191,6 +191,27 @@ "forcedVariations": { "Harry Potter": "Control" } + }, + { + "id": "3072915611", + "key": "launched_experiment", + "layerId": "3587821424", + "status": "Launched", + "variations": [ + { + "id": "1647582435", + "key": "launch_control", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1647582435", + "endOfRang": 8000 + } + ], + "audienceIds": [], + "forcedVariations": {} } ], "groups": [ @@ -281,7 +302,7 @@ ] } ], - "features": [ + "featureFlags": [ { "id": "4195505407", "key": "boolean_feature", From 4ee6409c1886b00bebbf17c17062c4c6b658f875 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Wed, 5 Jul 2017 11:49:57 -0700 Subject: [PATCH 12/34] parse v4 additive json (#121) * create expected project config v4 from test datafile * revise v4 datafile. check jackson parsing works * test GsonParsing works * add test for json simple parsing * add test for json config parsing --- .../ab/config/ProjectConfigTestUtils.java | 11 +- .../ab/config/ValidProjectConfigV4.java | 614 ++++++++++++++++++ .../config/parser/GsonConfigParserTest.java | 11 + .../parser/JacksonConfigParserTest.java | 11 + .../config/parser/JsonConfigParserTest.java | 11 + .../parser/JsonSimpleConfigParserTest.java | 11 + .../config/valid-project-config-v4.json | 23 +- 7 files changed, 668 insertions(+), 24 deletions(-) create mode 100644 core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java 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 f91948464..fecc6b2d4 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 @@ -39,6 +39,7 @@ 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; @@ -380,7 +381,7 @@ private static ProjectConfig generateNoAudienceProjectConfigV3() { private static final ProjectConfig VALID_PROJECT_CONFIG_V4 = generateValidProjectConfigV4(); private static ProjectConfig generateValidProjectConfigV4() { - return null; + return ValidProjectConfigV4.generateValidProjectConfigV4(); } private ProjectConfigTestUtils() { } @@ -401,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()} ()} */ @@ -506,7 +511,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()); } } 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..ca7a76b62 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -0,0 +1,614 @@ +/** + * + * 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); + + // 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))))))) + ); + + // 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"; + private static final String FEATURE_SINGLE_VARIABLE_DOUBLE_KEY = "double_single_variable_feature"; + private static final String VARIABLE_DOUBLE_VARIABLE_ID = "4111654444"; + private static final String VARIABLE_DOUBLE_VARIABLE_KEY = "double_variable"; + private 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, + LiveVariable.VariableStatus.ACTIVE, + LiveVariable.VariableType.DOUBLE + ); + private static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE = new FeatureFlag( + FEATURE_SINGLE_VARIABLE_DOUBLE_ID, + FEATURE_SINGLE_VARIABLE_DOUBLE_KEY, + "", + Collections.emptyList(), + Collections.singletonList( + VARIABLE_DOUBLE_VARIABLE + ) + ); + 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, + LiveVariable.VariableStatus.ACTIVE, + 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"; + private static final String FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY = "boolean_single_variable_feature"; + private static final String VARIABLE_BOOLEAN_VARIABLE_ID = "3974680341"; + private static final String VARIABLE_BOOLEAN_VARIABLE_KEY = "boolean_variable"; + private 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, + LiveVariable.VariableStatus.ACTIVE, + 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"; + private static final String FEATURE_SINGLE_VARIABLE_STRING_KEY = "string_single_variable_feature"; + private static final String VARIABLE_STRING_VARIABLE_ID = "2077511132"; + private static final String VARIABLE_STRING_VARIABLE_KEY = "string_variable"; + private 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, + LiveVariable.VariableStatus.ACTIVE, + LiveVariable.VariableType.STRING + ); + private static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_STRING = new FeatureFlag( + FEATURE_SINGLE_VARIABLE_STRING_ID, + FEATURE_SINGLE_VARIABLE_STRING_KEY, + "", + Collections.emptyList(), + Collections.singletonList( + VARIABLE_STRING_VARIABLE + ) + ); + private static final String FEATURE_MULTI_VARIATE_FEATURE_ID = "3263342226"; + private static final String FEATURE_MULTI_VARIATE_FEATURE_KEY = "multi_variate_feature"; + private static final String VARIABLE_FIRST_LETTER_ID = "675244127"; + private static final String VARIABLE_FIRST_LETTER_KEY = "first_letter"; + private 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, + LiveVariable.VariableStatus.ACTIVE, + 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, + LiveVariable.VariableStatus.ACTIVE, + LiveVariable.VariableType.STRING + ); + private static final FeatureFlag FEATURE_FLAG_MULTI_VARIATE_FEATURE = new FeatureFlag( + FEATURE_MULTI_VARIATE_FEATURE_ID, + FEATURE_MULTI_VARIATE_FEATURE_KEY, + "", + Collections.emptyList(), + ProjectConfigTestUtils.createListOfObjects( + VARIABLE_FIRST_LETTER_VARIABLE, + VARIABLE_REST_OF_NAME_VARIABLE + ) + ); + + // group IDs + private static final String GROUP_1_ID = "1015968292"; + + // 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"; + private 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_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 + ) + ) + ); + + // 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 + ) + ) + ); + + // 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 + ) + ); + + + public static ProjectConfig generateValidProjectConfigV4() { + + // list attributes + List attributes = new ArrayList(); + attributes.add(ATTRIBUTE_HOUSE); + + // list audiences + List audiences = new ArrayList(); + audiences.add(AUDIENCE_GRYFFINDOR); + + // 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_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); + + List groups = new ArrayList(); + groups.add(GROUP_1); + + return new ProjectConfig( + ACCOUNT_ID, + ANONYMIZE_IP, + PROJECT_ID, + REVISION, + VERSION, + attributes, + audiences, + events, + experiments, + featureFlags, + groups, + null + ); + } +} 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..c170befb9 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/resources/config/valid-project-config-v4.json b/core-api/src/test/resources/config/valid-project-config-v4.json index 0c994623c..0a74c6eb8 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -207,7 +207,7 @@ "trafficAllocation": [ { "entityId": "1647582435", - "endOfRang": 8000 + "endOfRange": 8000 } ], "audienceIds": [], @@ -385,27 +385,6 @@ "defaultValue": "arry" } ] - }, - { - "id": "3072915611", - "key": "launched_experiment", - "layerId": "3587821424", - "status": "Launched", - "variations": [ - { - "id": "1647582435", - "key": "launch_control", - "variables": [] - } - ], - "trafficAllocation": [ - { - "entityId": "1647582435", - "endOfRange": 8000 - } - ], - "audienceIds": [], - "forcedVariations": {} } ], "liveVariables": [] From 7a5724363f8ad35e061208a8b3e3e2dd4456e81a Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Thu, 6 Jul 2017 10:01:16 -0700 Subject: [PATCH 13/34] remove live variable APIs (#122) Remove live variable accessor APIs and tests. --- .../java/com/optimizely/ab/Optimizely.java | 167 ------------- .../com/optimizely/ab/OptimizelyTest.java | 231 +----------------- 2 files changed, 1 insertion(+), 397 deletions(-) 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 a329cdb98..347c27d64 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -22,8 +22,6 @@ import com.optimizely.ab.config.Attribute; import com.optimizely.ab.config.EventType; import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.LiveVariable; -import com.optimizely.ab.config.LiveVariableUsageInstance; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.Variation; import com.optimizely.ab.config.parser.ConfigParseException; @@ -38,7 +36,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; @@ -283,139 +280,6 @@ public void track(@Nonnull String eventName, conversionEvent); } - //======== live variable getters ========// - - @Deprecated - public @Nullable - String getVariableString(@Nonnull String variableKey, - @Nonnull String userId, - boolean activateExperiment) throws UnknownLiveVariableException { - return getVariableString(variableKey, userId, Collections.emptyMap(), activateExperiment); - } - - @Deprecated - 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; - } - - 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(); - } - - for (Experiment experiment : experimentsUsingLiveVariable) { - Variation variation; - if (activateExperiment) { - variation = activate(experiment, userId, attributes); - } else { - variation = getVariation(experiment, userId, attributes); - } - - if (variation != null) { - LiveVariableUsageInstance usageInstance = - variationToLiveVariableUsageInstanceMapping.get(variation.getId()).get(variable.getId()); - return usageInstance.getValue(); - } - } - - return variable.getDefaultValue(); - } - - @Deprecated - public @Nullable - Boolean getVariableBoolean(@Nonnull String variableKey, - @Nonnull String userId, - boolean activateExperiment) throws UnknownLiveVariableException { - return getVariableBoolean(variableKey, userId, Collections.emptyMap(), activateExperiment); - } - - @Deprecated - 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); - } - - return null; - } - - @Deprecated - public @Nullable - Integer getVariableInteger(@Nonnull String variableKey, - @Nonnull String userId, - boolean activateExperiment) throws UnknownLiveVariableException { - return getVariableInteger(variableKey, userId, Collections.emptyMap(), activateExperiment); - } - - @Deprecated - 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) { - try { - return Integer.parseInt(variableValueString); - } catch (NumberFormatException e) { - logger.error("Variable value \"{}\" for live variable \"{}\" is not an integer.", variableValueString, - variableKey); - } - } - - return null; - } - - @Deprecated - public @Nullable - Double getVariableDouble(@Nonnull String variableKey, - @Nonnull String userId, - boolean activateExperiment) throws UnknownLiveVariableException { - return getVariableDouble(variableKey, userId, Collections.emptyMap(), activateExperiment); - } - - @Deprecated - 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) { - try { - return Double.parseDouble(variableValueString); - } catch (NumberFormatException e) { - logger.error("Variable value \"{}\" for live variable \"{}\" is not a double.", variableValueString, - variableKey); - } - } - - return null; - } - //======== FeatureFlag APIs ========// /** @@ -739,37 +603,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 - */ - @Deprecated - 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/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index 400a60cec..d557cc7dd 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -35,7 +35,6 @@ 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; @@ -75,8 +74,6 @@ import static org.hamcrest.Matchers.hasKey; 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; @@ -1439,217 +1436,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 ========// /** @@ -1938,17 +1724,8 @@ public void addNotificationListener() throws Exception { // Check if listener is notified when experiment is activated Variation actualVariation = optimizely.activate(activatedExperiment, genericUserId, attributes); - 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 { + if (datafileVersion == 3 || datafileVersion == 2) { verify(listener, times(1)) .onExperimentActivated(activatedExperiment, genericUserId, attributes, actualVariation); } @@ -2022,9 +1799,6 @@ public void removeNotificationListener() throws Exception { 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); @@ -2096,9 +1870,6 @@ public void clearNotificationListeners() throws Exception { 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); From b068572fed132118750aac2c24aea5900c0d4c90 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Fri, 7 Jul 2017 11:33:49 -0700 Subject: [PATCH 14/34] parameterize optimizely unit tests with v4 datafile (#123) * update OptimizelyTest.java tests to incorporate v4 parameters * update eventbuilder v2 tests and experiment mapping generation helper --- .../com/optimizely/ab/OptimizelyTest.java | 467 +++++++++++++----- .../ab/event/internal/EventBuilderV2Test.java | 185 +++++-- 2 files changed, 489 insertions(+), 163 deletions(-) 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 d557cc7dd..4ad647457 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -19,6 +19,7 @@ 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; @@ -33,7 +34,6 @@ 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.notification.NotificationListener; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @@ -62,25 +62,43 @@ 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.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.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.array; import static org.hamcrest.Matchers.hasEntry; import static org.hamcrest.Matchers.hasKey; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyMap; import static org.mockito.Matchers.anyMapOf; import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.eq; 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; @@ -107,6 +125,13 @@ public static Collection data() throws IOException { noAudienceProjectConfigJsonV3(), validProjectConfigV3(), noAudienceProjectConfigV3() + }, + { + 4, + validConfigJsonV4(), + validConfigJsonV4(), + validProjectConfigV4(), + validProjectConfigV4() } }); } @@ -123,6 +148,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"; @@ -152,7 +178,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); @@ -163,9 +198,6 @@ 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, ""); @@ -176,7 +208,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 \"\""); @@ -211,7 +244,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); @@ -250,8 +284,9 @@ 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 + * correctly builds an endpoint url and request params + * and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. */ @Test public void activateWithExperimentKey() throws Exception { @@ -267,7 +302,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"); @@ -383,8 +423,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. */ @@ -405,7 +445,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(); @@ -418,7 +463,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 \"\""); @@ -552,16 +598,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); @@ -591,7 +645,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) @@ -602,8 +662,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); @@ -630,14 +692,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")); } @@ -672,6 +741,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) @@ -679,7 +756,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)); } @@ -714,16 +791,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)); } /** @@ -732,16 +822,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)); } /** @@ -768,7 +869,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) @@ -799,13 +906,29 @@ public void activateLaunchedExperimentDoesNotDispatchEvent() throws Exception { */ @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 = validProjectConfig; + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + datafile = validDatafile; + } + else { + config = noAudienceProjectConfig; + eventType = noAudienceProjectConfig.getEventTypes().get(0); + datafile = noAudienceDatafile; + } + List allExperiments = config.getExperiments(); EventBuilder eventBuilderV2 = new EventBuilderV2(); + DecisionService spyDecisionService = spy(new DecisionService(mockBucketer, + mockErrorHandler, + validProjectConfig, + null)); - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withBucketing(mockBucketer) + Optimizely optimizely = Optimizely.builder(datafile, mockEventHandler) + .withDecisionService(spyDecisionService) .withEventBuilder(eventBuilderV2) .withConfig(noAudienceProjectConfig) .withErrorHandler(mockErrorHandler) @@ -822,13 +945,14 @@ 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()); } else { - verify(mockBucketer, never()).bucket(experiment, "userId"); + verify(spyDecisionService, never()).getVariation(experiment, "userId", + Collections.emptyMap()); } } @@ -883,7 +1007,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); @@ -900,7 +1030,7 @@ public void trackEventWithAttributes() throws Exception { Map attributes = ImmutableMap.of(attribute.getKey(), "attributeValue"); Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketer, + mockDecisionService, eventType.getKey(), genericUserId, attributes); @@ -915,7 +1045,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 \"\""); @@ -949,29 +1080,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()), @@ -980,7 +1117,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 \"\""); @@ -995,7 +1133,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()), @@ -1014,29 +1152,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()), @@ -1045,7 +1189,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 \"\""); @@ -1059,7 +1204,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()), @@ -1079,7 +1224,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); @@ -1095,7 +1246,7 @@ public void trackEventWithUnknownAttribute() throws Exception { testParams.put("test", "params"); Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketer, + mockDecisionService, eventType.getKey(), genericUserId, Collections.emptyMap()); @@ -1110,7 +1261,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 \"\""); @@ -1143,7 +1295,13 @@ public void trackEventWithUnknownAttribute() throws Exception { */ @Test public void trackEventWithEventTags() 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); @@ -1165,7 +1323,7 @@ public void trackEventWithEventTags() throws Exception { eventTags.put("float_param", 12.3f); Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketer, + mockDecisionService, eventType.getKey(), genericUserId, Collections.emptyMap()); @@ -1181,7 +1339,8 @@ public void trackEventWithEventTags() throws Exception { eq(eventTags))) .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 \"\""); @@ -1219,7 +1378,13 @@ public void trackEventWithEventTags() throws Exception { 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); @@ -1235,7 +1400,7 @@ public void trackEventWithNullEventTags() throws Exception { testParams.put("test", "params"); Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketer, + mockDecisionService, eventType.getKey(), genericUserId, Collections.emptyMap()); @@ -1250,7 +1415,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 \"\""); @@ -1276,16 +1442,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)); } @@ -1314,7 +1492,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); @@ -1365,14 +1549,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) @@ -1382,14 +1572,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()), @@ -1412,6 +1606,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); } /** @@ -1551,14 +1749,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); @@ -1635,6 +1839,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) @@ -1642,7 +1854,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)); } @@ -1671,13 +1883,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")); } @@ -1691,18 +1909,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"); @@ -1711,32 +1938,27 @@ 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); // Check if listener is notified when experiment is activated Variation actualVariation = optimizely.activate(activatedExperiment, genericUserId, attributes); - - if (datafileVersion == 3 || datafileVersion == 2) { - verify(listener, times(1)) - .onExperimentActivated(activatedExperiment, genericUserId, attributes, actualVariation); - } + 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(); Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketer, + mockDecisionService, eventType.getKey(), genericUserId, attributes); @@ -1773,7 +1995,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"); @@ -1799,6 +2022,8 @@ public void removeNotificationListener() throws Exception { verify(listener, never()) .onExperimentActivated(activatedExperiment, genericUserId, attributes, actualVariation); + // Check if listener is notified after a live variable is accessed + boolean activateExperiment = true; verify(listener, never()) .onExperimentActivated(activatedExperiment, genericUserId, attributes, actualVariation); @@ -1808,7 +2033,7 @@ public void removeNotificationListener() throws Exception { Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketer, + mockDecisionService, eventType.getKey(), genericUserId, attributes); @@ -1834,7 +2059,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); @@ -1845,8 +2079,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, ""); @@ -1857,9 +2089,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); @@ -1867,9 +2106,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; verify(listener, never()) .onExperimentActivated(activatedExperiment, genericUserId, attributes, actualVariation); @@ -1879,7 +2124,7 @@ public void clearNotificationListeners() throws Exception { Map experimentVariationMap = createExperimentVariationMap( validProjectConfig, - mockBucketer, + mockDecisionService, eventType.getKey(), OptimizelyTest.genericUserId, attributes); 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..446eed4cb 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 @@ -18,11 +18,15 @@ import com.google.gson.Gson; 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,22 +35,39 @@ 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; @@ -54,23 +75,41 @@ 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; } /** @@ -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,17 +300,17 @@ 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); } /** @@ -284,27 +329,33 @@ 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); 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)))); + is(Collections.singletonList(new EventMetric(EventMetric.REVENUE_METRIC_TYPE, revenue)))); } /** @@ -313,35 +364,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 +418,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 +472,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 +566,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 +574,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); } From 32c4d15c76f8b24bb8a196f13fee6e77b30d7a13 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Tue, 11 Jul 2017 15:47:40 -0700 Subject: [PATCH 15/34] make status optional in the creation of live variables (#126) --- .../java/com/optimizely/ab/config/LiveVariable.java | 7 ++++++- .../optimizely/ab/config/ValidProjectConfigV4.java | 12 ++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) 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..74f656b6c 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 @@ -111,7 +111,12 @@ public LiveVariable(@JsonProperty("id") String id, this.id = id; this.key = key; this.defaultValue = defaultValue; - this.status = status; + if (status == null) { + this.status = VariableStatus.ACTIVE; + } + else { + this.status = status; + } this.type = type; } 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 index ca7a76b62..1607e3d99 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -74,7 +74,7 @@ public class ValidProjectConfigV4 { VARIABLE_DOUBLE_VARIABLE_ID, VARIABLE_DOUBLE_VARIABLE_KEY, VARIABLE_DOUBLE_DEFAULT_VALUE, - LiveVariable.VariableStatus.ACTIVE, + null, LiveVariable.VariableType.DOUBLE ); private static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE = new FeatureFlag( @@ -95,7 +95,7 @@ public class ValidProjectConfigV4 { VARIABLE_INTEGER_VARIABLE_ID, VARIABLE_INTEGER_VARIABLE_KEY, VARIABLE_INTEGER_DEFAULT_VALUE, - LiveVariable.VariableStatus.ACTIVE, + null, LiveVariable.VariableType.INTEGER ); private static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_INTEGER = new FeatureFlag( @@ -116,7 +116,7 @@ public class ValidProjectConfigV4 { VARIABLE_BOOLEAN_VARIABLE_ID, VARIABLE_BOOLEAN_VARIABLE_KEY, VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE, - LiveVariable.VariableStatus.ACTIVE, + null, LiveVariable.VariableType.BOOLEAN ); private static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_BOOLEAN = new FeatureFlag( @@ -137,7 +137,7 @@ public class ValidProjectConfigV4 { VARIABLE_STRING_VARIABLE_ID, VARIABLE_STRING_VARIABLE_KEY, VARIABLE_STRING_VARIABLE_DEFAULT_VALUE, - LiveVariable.VariableStatus.ACTIVE, + null, LiveVariable.VariableType.STRING ); private static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_STRING = new FeatureFlag( @@ -158,7 +158,7 @@ public class ValidProjectConfigV4 { VARIABLE_FIRST_LETTER_ID, VARIABLE_FIRST_LETTER_KEY, VARIABLE_FIRST_LETTER_DEFAULT_VALUE, - LiveVariable.VariableStatus.ACTIVE, + null, LiveVariable.VariableType.STRING ); private static final String VARIABLE_REST_OF_NAME_ID = "4052219963"; @@ -168,7 +168,7 @@ public class ValidProjectConfigV4 { VARIABLE_REST_OF_NAME_ID, VARIABLE_REST_OF_NAME_KEY, VARIABLE_REST_OF_NAME_DEFAULT_VALUE, - LiveVariable.VariableStatus.ACTIVE, + null, LiveVariable.VariableType.STRING ); private static final FeatureFlag FEATURE_FLAG_MULTI_VARIATE_FEATURE = new FeatureFlag( From f76fb9600c6f8c0fa508d395f61c8eda05e9c955 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Thu, 13 Jul 2017 11:41:15 -0700 Subject: [PATCH 16/34] =?UTF-8?q?update=20change=20log=20for=20new=20break?= =?UTF-8?q?ing=20change=20version=201.7.0=20for=20support=20o=E2=80=A6=20(?= =?UTF-8?q?#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update change log for new breaking change version 1.7.0 for support of android sdk 1.4.0 breaking changes. * update changelog to mention other commits since 2.0.0-alpha * remove live variable change. it won't be included on release branch --- CHANGELOG.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64cdfd53f..4502b179b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,20 @@ -## 2.0.0 +# Optimizely Java X SDK Changelog +## 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 +126,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 From ad49a4a473396e7c273ac0a0574975243e215a6d Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Thu, 27 Jul 2017 14:44:11 -0700 Subject: [PATCH 17/34] feature flag parsing (#130) * add variableIdToLiveVariableUsageInstanceMap to Varaitions. * Add equality methods to LiveVariable and FeatureFlag models to improve ProjectConfigParsing evaluation. *Update the v4 datafile with an empty top level "variables" array * Added empty array passed in to ProjectConfig constructor in ValidProjectConfigV4.java * ProjectConfigTestUtils now compare feature flags more effectively * Add GsonParser for FeatureFlags * Add org.Json FeatureFlag parsing * Add JsonSimple FeatureFlag parsing * add variableKeyToLiveVariableMap to FeatureFlag --- .../com/optimizely/ab/config/FeatureFlag.java | 34 +++++++ .../optimizely/ab/config/LiveVariable.java | 37 ++++++-- .../ab/config/LiveVariableUsageInstance.java | 2 +- .../optimizely/ab/config/ProjectConfig.java | 4 + .../com/optimizely/ab/config/Variation.java | 15 +++- .../parser/FeatureFlagGsonDeserializer.java | 36 ++++++++ .../ab/config/parser/GsonConfigParser.java | 15 ++-- .../ab/config/parser/GsonHelpers.java | 42 ++++++++- .../ab/config/parser/JsonConfigParser.java | 90 ++++++++++++++----- .../config/parser/JsonSimpleConfigParser.java | 67 ++++++++++++-- .../parser/ProjectConfigGsonDeserializer.java | 26 +++++- .../ProjectConfigJacksonDeserializer.java | 29 ++++-- .../ab/config/ProjectConfigTestUtils.java | 17 +++- .../ab/config/ValidProjectConfigV4.java | 4 +- .../config/parser/GsonConfigParserTest.java | 2 +- .../ab/internal/LogbackVerifier.java | 26 +++++- .../config/valid-project-config-v4.json | 2 +- 17 files changed, 382 insertions(+), 66 deletions(-) create mode 100644 core-api/src/main/java/com/optimizely/ab/config/parser/FeatureFlagGsonDeserializer.java 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 index 66c8b2675..915da05c5 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/FeatureFlag.java +++ b/core-api/src/main/java/com/optimizely/ab/config/FeatureFlag.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; +import java.util.Map; /** * Represents a FeatureFlag definition at the project level @@ -33,6 +34,7 @@ public class FeatureFlag implements IdKeyMapped{ private final String layerId; private final List experimentIds; private final List variables; + private final Map variableKeyToLiveVariableMap; @JsonCreator public FeatureFlag(@JsonProperty("id") String id, @@ -45,6 +47,7 @@ public FeatureFlag(@JsonProperty("id") String id, this.layerId = layerId; this.experimentIds = experimentIds; this.variables = variables; + this.variableKeyToLiveVariableMap = ProjectConfigUtils.generateNameMapping(variables); } public String getId() { @@ -67,6 +70,10 @@ public List getVariables() { return variables; } + public Map getVariableKeyToLiveVariableMap() { + return variableKeyToLiveVariableMap; + } + @Override public String toString() { return "FeatureFlag{" + @@ -75,6 +82,33 @@ public String toString() { ", layerId='" + layerId + '\'' + ", 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 (!layerId.equals(that.layerId)) 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 + layerId.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 74f656b6c..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, @@ -111,16 +113,11 @@ public LiveVariable(@JsonProperty("id") String id, this.id = id; this.key = key; this.defaultValue = defaultValue; - if (status == null) { - this.status = VariableStatus.ACTIVE; - } - else { - this.status = status; - } + this.status = status; this.type = type; } - public VariableStatus getStatus() { + public @Nullable VariableStatus getStatus() { return status; } @@ -150,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 fac0bbdce..4e9ad423b 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 @@ -224,6 +224,10 @@ public List getExperimentsForEventKey(String eventKey) { return Collections.emptyList(); } + public List getFeatureFlags() { + return featureFlags; + } + public List getAttributes() { return attributes; } 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..fc75a6437 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("layerId").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 8a37865d8..697b500dc 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,34 @@ 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.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,6 +59,7 @@ 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")); @@ -72,14 +72,31 @@ 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; + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { + featureFlags = parseFeatureFlags(rootObject.getJSONArray("featureFlags")); + } + + return new ProjectConfig( + accountId, + anonymizeIP, + projectId, + revision, + version, + attributes, + audiences, + events, + experiments, + featureFlags, + groups, + liveVariables + ); } catch (Exception e) { throw new ConfigParseException("Unable to parse datafile: " + json, e); } @@ -123,6 +140,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("layerId"); + + 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()); @@ -187,12 +239,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"); @@ -273,7 +320,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)); } 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 2c05e0c25..2c37e9abb 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,6 +20,7 @@ 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; @@ -60,6 +61,7 @@ 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")); @@ -72,14 +74,31 @@ 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; + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { + featureFlags = parseFeatureFlags((JSONArray) rootObject.get("featureFlags")); + } + + return new ProjectConfig( + accountId, + anonymizeIP, + projectId, + revision, + version, + attributes, + audiences, + events, + experiments, + featureFlags, + groups, + liveVariables + ); } catch (Exception e) { throw new ConfigParseException("Unable to parse datafile: " + json, e); } @@ -125,6 +144,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("layerId"); + + 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()); @@ -189,11 +244,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"); 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 cb0b172c2..3f4df5210 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,6 +25,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.Group; import com.optimizely.ab.config.LiveVariable; import com.optimizely.ab.config.ProjectConfig; @@ -47,6 +48,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(); @@ -70,14 +72,32 @@ 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; + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { + Type featureFlagsType = new TypeToken>() {}.getType(); + featureFlags = context.deserialize(jsonObject.getAsJsonArray("featureFlags"), featureFlagsType); + } + + return new ProjectConfig( + accountId, + anonymizeIP, + projectId, + revision, + version, + attributes, + audiences, + events, + experiments, + featureFlags, + groups, + liveVariables + ); } } 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 e8722086a..04503c150 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,14 @@ 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.audience.Audience; import java.io.IOException; import java.util.List; @@ -51,6 +51,7 @@ 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(), @@ -66,13 +67,31 @@ 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; + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { + featureFlags = mapper.readValue(node.get("featureFlags").toString(), + new TypeReference>() {}); + } + + return new ProjectConfig( + accountId, + anonymizeIP, + projectId, + revision, + version, + attributes, + audiences, + events, + experiments, + featureFlags, + groups, + liveVariables + ); } } \ 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 fecc6b2d4..fa4a43a25 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 @@ -450,12 +450,13 @@ 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()); } /** @@ -482,6 +483,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. */ 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 index 1607e3d99..e6693cdc3 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -150,7 +150,7 @@ public class ValidProjectConfigV4 { ) ); private static final String FEATURE_MULTI_VARIATE_FEATURE_ID = "3263342226"; - private static final String FEATURE_MULTI_VARIATE_FEATURE_KEY = "multi_variate_feature"; + public static final String FEATURE_MULTI_VARIATE_FEATURE_KEY = "multi_variate_feature"; private static final String VARIABLE_FIRST_LETTER_ID = "675244127"; private static final String VARIABLE_FIRST_LETTER_KEY = "first_letter"; private static final String VARIABLE_FIRST_LETTER_DEFAULT_VALUE = "H"; @@ -608,7 +608,7 @@ public static ProjectConfig generateValidProjectConfigV4() { experiments, featureFlags, groups, - null + Collections.emptyList() ); } } 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 c170befb9..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 @@ -57,7 +57,7 @@ public void parseProjectConfigV3() throws Exception { } @Test - public void parseProjectCOnfigV4() throws Exception { + public void parseProjectConfigV4() throws Exception { GsonConfigParser parser = new GsonConfigParser(); ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); ProjectConfig expected = validProjectConfigV4(); 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 index 0a74c6eb8..624d8a538 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -387,5 +387,5 @@ ] } ], - "liveVariables": [] + "variables": [] } From 40ead9ab74f2f21ec193bfbaf802bc6a5ee4e85d Mon Sep 17 00:00:00 2001 From: Vignesh Raja Date: Mon, 31 Jul 2017 14:03:02 -0700 Subject: [PATCH 18/34] Add support for numeric metrics (#129) --- .../ab/event/internal/EventBuilderV2.java | 14 +++++++--- .../event/internal/payload/EventMetric.java | 26 +++++++++---------- .../optimizely/ab/internal/EventTagUtils.java | 18 +++++++++++++ .../ab/internal/ReservedEventKey.java | 3 ++- .../ab/event/internal/EventBuilderV2Test.java | 19 +++++++++----- 5 files changed, 55 insertions(+), 25 deletions(-) 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/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/event/internal/EventBuilderV2Test.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventBuilderV2Test.java index 446eed4cb..841315924 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,6 +17,7 @@ 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; @@ -314,11 +315,13 @@ public void createConversionEvent() throws Exception { } /** - * 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); @@ -341,6 +344,7 @@ public void createConversionParamsWithRevenue() throws Exception { 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, decisionService, @@ -352,10 +356,11 @@ public void createConversionParamsWithRevenue() throws Exception { 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)); } /** From 9acfab2264f1781dbe7c0347191bc33d09d12661 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Tue, 8 Aug 2017 10:47:52 -0700 Subject: [PATCH 19/34] internal feature variable accessor support (#131) Internal Methods * Bucketing method for getting the variation a user is bucketed into for a feature * Get the value of a feature variable of a specific type with type checking Unit test improvements * add test to make sure null is returned when getVariationForFeature is called with a feature without attached experiments * add test to make sure getVariationForFeature returns null when user is not bucketed into any experiments * add test data for a new feature flag with mutex group stuff * add test for getVariationForFeature returns the variation a user is bucketed into when there are multiple experiments * update unit tests to test the getFeatureValueForVariableType method instead of the string method. test all cases --- .../java/com/optimizely/ab/Optimizely.java | 55 ++++- .../ab/bucketing/DecisionService.java | 28 +++ .../optimizely/ab/config/ProjectConfig.java | 4 + .../com/optimizely/ab/OptimizelyTest.java | 232 +++++++++++++++++- .../ab/bucketing/DecisionServiceTest.java | 110 +++++++++ .../ab/config/ValidProjectConfigV4.java | 139 +++++++++-- .../config/valid-project-config-v4.json | 84 ++++++- 7 files changed, 633 insertions(+), 19 deletions(-) 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 347c27d64..0362c4be3 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,9 @@ 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.Variation; import com.optimizely.ab.config.parser.ConfigParseException; @@ -433,7 +436,57 @@ public void track(@Nonnull String eventName, @Nonnull String variableKey, @Nonnull String userId, @Nonnull Map attributes) { - return null; + 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 is being returned." + ); + } + + return variableValue; } //======== getVariation calls ========// 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..8d6137321 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,6 +18,7 @@ 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.Variation; import com.optimizely.ab.error.ErrorHandler; @@ -134,6 +135,33 @@ 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."); + } + + return null; + } + /** * 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/ProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java index 4e9ad423b..256472461 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 @@ -286,6 +286,10 @@ public Map> getVariationToLiveVar return variationToLiveVariableUsageInstanceMapping; } + public Map getFeatureKeyMapping() { + return featureKeyMapping; + } + @Override public String toString() { return "ProjectConfig{" + 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 4ad647457..e981704e4 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -23,10 +23,12 @@ import com.optimizely.ab.config.Attribute; import com.optimizely.ab.config.EventType; import com.optimizely.ab.config.Experiment; +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; @@ -74,8 +76,16 @@ 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_MULTI_VARIATE_FEATURE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_SINGLE_VARIABLE_STRING_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_FIRST_LETTER_DEFAULT_VALUE; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_FIRST_LETTER_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_STRING_VARIABLE_DEFAULT_VALUE; +import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_STRING_VARIABLE_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; @@ -84,17 +94,17 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.array; import static org.hamcrest.Matchers.hasEntry; import static org.hamcrest.Matchers.hasKey; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assume.assumeTrue; import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyMap; 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; @@ -2143,6 +2153,224 @@ 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. + * @throws ConfigParseException + */ + @Test + public void getFeatureVariableValueForTypeReturnsDefaultValueWhenFeatureIsNotAttached() throws ConfigParseException { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + String validFeatureKey = FEATURE_SINGLE_VARIABLE_STRING_KEY; + String validVariableKey = VARIABLE_STRING_VARIABLE_KEY; + String defaultValue = VARIABLE_STRING_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.STRING); + assertEquals(defaultValue, value); + + logbackVerifier.expectMessage( + Level.INFO, + "The feature flag \"" + validFeatureKey + "\" is not used in any experiments." + ); + } + + /** + * 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, 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_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_FIRST_LETTER_KEY; + String expectedValue = VARIABLE_FIRST_LETTER_DEFAULT_VALUE; + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withConfig(validProjectConfig) + .build(); + + String valueWithImproperAttributes = optimizely.getFeatureVariableValueForType( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, "Slytherin"), + LiveVariable.VariableType.STRING + ); + assertEquals(expectedValue, valueWithImproperAttributes); + + logbackVerifier.expectMessage( + Level.INFO, + "User \"" + genericUserId + + "\" was not bucketed into any variation for feature flag \"" + validFeatureKey + + "\". The default value 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); + } + //======== 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..d5f731877 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,10 @@ 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.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 +40,22 @@ 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 org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyMap; 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; @@ -136,6 +143,109 @@ public void getVariationEvaluatesUserProfileBeforeAudienceTargeting() throws Exc decisionService.getVariation(experiment, userProfileId, 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. + */ + @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); + + DecisionService decisionService = new DecisionService( + mock(Bucketer.class), + mockErrorHandler, + validProjectConfig, + null); + + logbackVerifier.expectMessage(Level.INFO, + "The feature flag \"" + featureKey + "\" is not used in any experiments"); + + assertNull(decisionService.getVariationForFeature( + emptyFeatureFlag, + genericUserId, + Collections.emptyMap())); + + verify(emptyFeatureFlag, times(1)).getExperimentIds(); + verify(emptyFeatureFlag, times(1)).getKey(); + } + + /** + * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map)} + * returns null when the user is not bucketed into any experiments for the {@link FeatureFlag}. + */ + @Test + @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT") + public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiments() { + FeatureFlag spyFeatureFlag = spy(ValidProjectConfigV4.FEATURE_FLAG_MULTI_VARIATE_FEATURE); + + DecisionService spyDecisionService = spy(new DecisionService( + mock(Bucketer.class), + mockErrorHandler, + validProjectConfig, + null) + ); + + doReturn(null).when(spyDecisionService).getVariation( + any(Experiment.class), + anyString(), + anyMapOf(String.class, String.class) + ); + + assertNull(spyDecisionService.getVariationForFeature( + spyFeatureFlag, + genericUserId, + Collections.emptyMap() + )); + + verify(spyFeatureFlag, times(2)).getExperimentIds(); + verify(spyFeatureFlag, never()).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 getVariationForFeatureReturnsVariationReturnedFromGetVarition() { + 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(); + } + //========= white list tests ==========/ /** 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 index e6693cdc3..e163abd52 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -129,10 +129,10 @@ public class ValidProjectConfigV4 { ) ); private static final String FEATURE_SINGLE_VARIABLE_STRING_ID = "2079378557"; - private static final String FEATURE_SINGLE_VARIABLE_STRING_KEY = "string_single_variable_feature"; + public static final String FEATURE_SINGLE_VARIABLE_STRING_KEY = "string_single_variable_feature"; private static final String VARIABLE_STRING_VARIABLE_ID = "2077511132"; - private static final String VARIABLE_STRING_VARIABLE_KEY = "string_variable"; - private static final String VARIABLE_STRING_VARIABLE_DEFAULT_VALUE = "wingardium leviosa"; + 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, @@ -152,8 +152,8 @@ public class ValidProjectConfigV4 { 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"; - private static final String VARIABLE_FIRST_LETTER_KEY = "first_letter"; - private static final String VARIABLE_FIRST_LETTER_DEFAULT_VALUE = "H"; + 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, @@ -171,19 +171,22 @@ public class ValidProjectConfigV4 { null, LiveVariable.VariableType.STRING ); - private static final FeatureFlag FEATURE_FLAG_MULTI_VARIATE_FEATURE = new FeatureFlag( - FEATURE_MULTI_VARIATE_FEATURE_ID, - FEATURE_MULTI_VARIATE_FEATURE_KEY, - "", - Collections.emptyList(), - ProjectConfigTestUtils.createListOfObjects( - VARIABLE_FIRST_LETTER_VARIABLE, - VARIABLE_REST_OF_NAME_VARIABLE - ) + 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"; @@ -375,7 +378,7 @@ public class ValidProjectConfigV4 { ); private static final String VARIATION_MULTIVARIATE_EXPERIMENT_GRED_ID = "4204375027"; public static final String VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY = "Gred"; - private static final Variation VARIATION_MULTIVARIATE_EXPERIMENT_GRED = new Variation( + public static final Variation VARIATION_MULTIVARIATE_EXPERIMENT_GRED = new Variation( VARIATION_MULTIVARIATE_EXPERIMENT_GRED_ID, VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY, ProjectConfigTestUtils.createListOfObjects( @@ -506,6 +509,68 @@ public class ValidProjectConfigV4 { ) ) ); + 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( @@ -526,6 +591,24 @@ public class ValidProjectConfigV4 { ) ) ); + 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"; @@ -560,6 +643,30 @@ public class ValidProjectConfigV4 { ) ); + // finish features + public static final FeatureFlag FEATURE_FLAG_MULTI_VARIATE_FEATURE = new FeatureFlag( + FEATURE_MULTI_VARIATE_FEATURE_ID, + FEATURE_MULTI_VARIATE_FEATURE_KEY, + "", + 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 ProjectConfig generateValidProjectConfigV4() { @@ -592,9 +699,11 @@ public static ProjectConfig generateValidProjectConfigV4() { 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); return new ProjectConfig( ACCOUNT_ID, 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 index 624d8a538..75a91d422 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -300,6 +300,74 @@ "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": [ @@ -370,7 +438,7 @@ "id": "3263342226", "key": "multi_variate_feature", "layerId": "", - "experimentIds": [], + "experimentIds": ["3262035800"], "variables": [ { "id": "675244127", @@ -385,6 +453,20 @@ "defaultValue": "arry" } ] + }, + { + "id": "3263342226", + "key": "mutex_group_feature", + "layerId": "", + "experimentIds": ["4138322202", "1786133852"], + "variables": [ + { + "id": "2059187672", + "key": "correlating_variation_name", + "type": "string", + "defaultValue": "null" + } + ] } ], "variables": [] From e41b2a0ead0641530bb7b3a6d168c5d563a819e2 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Mon, 14 Aug 2017 20:09:23 -0700 Subject: [PATCH 20/34] Implement `isFeatureEnabled` API (#132) * support isFeatureEnabled API. add variationIdToExperiment mapping in project config * abstract sending the impression event * change behavior of isFeatureEnabled APIs to return false when feature flag is not found * unit test to make sure isFeatureEnabled returns false when feature flag key is invalid * unit test to ensure isFeatureEnabled returns false when the user is not bucketed into any variation for the feature * unit test for isFeatureEnabled to verify that no event is sent when the user is bucketed into a variation that is not part of an experiment for a feature * add integration-ish test for isFeatureEnabled when bucketed into an experiment and make sure we send an impression event --- .../java/com/optimizely/ab/Optimizely.java | 55 +++++- .../optimizely/ab/config/ProjectConfig.java | 15 ++ .../com/optimizely/ab/OptimizelyTest.java | 163 ++++++++++++++++++ 3 files changed, 224 insertions(+), 9 deletions(-) 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 0362c4be3..36d91f77d 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -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 ========// @@ -293,10 +301,9 @@ public void track(@Nonnull String eventName, * @param userId The ID of the user. * @return True if the feature is enabled. * False if the feature is disabled. - * Will always return True if toggling the feature is disabled. - * Will return Null if the feature is not found. + * False if the feature is not found. */ - public @Nullable Boolean isFeatureEnabled(@Nonnull String featureKey, + public @Nonnull Boolean isFeatureEnabled(@Nonnull String featureKey, @Nonnull String userId) { return isFeatureEnabled(featureKey, userId, Collections.emptyMap()); } @@ -310,13 +317,43 @@ public void track(@Nonnull String eventName, * @param attributes The user's attributes. * @return True if the feature is enabled. * False if the feature is disabled. - * Will always return True if toggling the feature is disabled. - * Will return Null if the feature is not found. + * False if the feature is not found. */ - public @Nullable Boolean isFeatureEnabled(@Nonnull String featureKey, + public @Nonnull Boolean isFeatureEnabled(@Nonnull String featureKey, @Nonnull String userId, @Nonnull Map attributes) { - return getFeatureVariableBoolean(featureKey, "", userId, attributes); + FeatureFlag featureFlag = projectConfig.getFeatureKeyMapping().get(featureKey); + if (featureFlag == null) { + logger.info("No feature flag was found for key \"" + featureKey + "\"."); + return false; + } + + Map filteredAttributes = filterAttributes(projectConfig, 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); + } + 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; + } } /** 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 256472461..ffb891e29 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 @@ -20,9 +20,11 @@ import com.optimizely.ab.config.audience.Audience; import com.optimizely.ab.config.audience.Condition; +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; @@ -80,6 +82,7 @@ public String toString() { // other mappings private final Map> liveVariableIdToExperimentsMapping; private final Map> variationToLiveVariableUsageInstanceMapping; + private final Map variationIdToExperimentMapping; // v2 constructor public ProjectConfig(String accountId, String projectId, String version, String revision, List groups, @@ -146,6 +149,14 @@ public ProjectConfig(String accountId, allExperiments.addAll(aggregateGroupExperiments(groups)); this.experiments = Collections.unmodifiableList(allExperiments); + 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.attributeKeyMapping = ProjectConfigUtils.generateNameMapping(attributes); this.eventNameMapping = ProjectConfigUtils.generateNameMapping(this.events); @@ -172,6 +183,10 @@ public ProjectConfig(String accountId, } } + public @Nullable Experiment getExperimentForVariationId(String variationId) { + return this.variationIdToExperimentMapping.get(variationId); + } + private List aggregateGroupExperiments(List groups) { List groupExperiments = new ArrayList(); for (Group group : groups) { 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 e981704e4..92ffcddcc 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -23,6 +23,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; @@ -36,6 +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.event.internal.payload.Feature; import com.optimizely.ab.internal.LogbackVerifier; import com.optimizely.ab.notification.NotificationListener; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @@ -97,6 +99,7 @@ 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.Assume.assumeTrue; @@ -2371,6 +2374,166 @@ public void getFeatureVariableValueReturnsVariationValueWhenUserGetsBucketedToVa 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)); + } + //======== Helper methods ========// private Experiment createUnknownExperiment() { From 1f0e341a87403af852e7d7473039306f8514bcc7 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Wed, 16 Aug 2017 10:54:53 -0700 Subject: [PATCH 21/34] feature variable accessor apis (#133) * link feature variable accessors to feature variable by value and type * add unit tests for getFeatureVariable calling through to internals * add unit tests to catch parsing error --- .../java/com/optimizely/ab/Optimizely.java | 42 ++ .../com/optimizely/ab/OptimizelyTest.java | 458 ++++++++++++++++++ 2 files changed, 500 insertions(+) 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 36d91f77d..fa1b824c7 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -383,6 +383,16 @@ public void track(@Nonnull String eventName, @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; } @@ -413,6 +423,22 @@ public void track(@Nonnull String eventName, @Nonnull String variableKey, @Nonnull String userId, @Nonnull Map attributes) { + String variableValue = getFeatureVariableValueForType( + featureKey, + variableKey, + userId, + attributes, + LiveVariable.VariableType.DOUBLE + ); + if (variableValue != null) { + try { + return Double.parseDouble(variableValue); + } + catch (NumberFormatException exception) { + logger.error("NumberFormatException while trying to parse \"" + variableValue + + "\" as Double. " + exception); + } + } return null; } @@ -443,6 +469,22 @@ public void track(@Nonnull String eventName, @Nonnull String variableKey, @Nonnull String userId, @Nonnull Map attributes) { + String variableValue = getFeatureVariableValueForType( + featureKey, + variableKey, + userId, + attributes, + LiveVariable.VariableType.INTEGER + ); + if (variableValue != null) { + try { + return Integer.parseInt(variableValue); + } + catch (NumberFormatException exception) { + logger.error("NumberFormatException while trying to parse \"" + variableValue + + "\" as Integer. " + exception.toString()); + } + } return null; } 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 92ffcddcc..5765fb036 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -2534,6 +2534,464 @@ public void isFeatureEnabledReturnsTrueAndDispatchesEventWhenUserIsBucketedIntoA 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() { From 9e9fd9f10ef96834f2eb98b684c36ab855e55b91 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Mon, 21 Aug 2017 08:37:05 -0700 Subject: [PATCH 22/34] refactor layerId property of FeatureFlag class to rolloutId (#134) * change JSON parsing to look for "rolloutId" instead of "layerId" --- .../com/optimizely/ab/config/FeatureFlag.java | 16 ++++++++-------- .../optimizely/ab/config/parser/GsonHelpers.java | 2 +- .../ab/config/parser/JsonConfigParser.java | 2 +- .../ab/config/parser/JsonSimpleConfigParser.java | 2 +- .../config/valid-project-config-v4.json | 14 +++++++------- 5 files changed, 18 insertions(+), 18 deletions(-) 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 index 915da05c5..bbe7a88ba 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/FeatureFlag.java +++ b/core-api/src/main/java/com/optimizely/ab/config/FeatureFlag.java @@ -31,7 +31,7 @@ public class FeatureFlag implements IdKeyMapped{ private final String id; private final String key; - private final String layerId; + private final String rolloutId; private final List experimentIds; private final List variables; private final Map variableKeyToLiveVariableMap; @@ -39,12 +39,12 @@ public class FeatureFlag implements IdKeyMapped{ @JsonCreator public FeatureFlag(@JsonProperty("id") String id, @JsonProperty("key") String key, - @JsonProperty("layerId") String layerId, + @JsonProperty("rolloutId") String rolloutId, @JsonProperty("experimentIds") List experimentIds, @JsonProperty("variables") List variables) { this.id = id; this.key = key; - this.layerId = layerId; + this.rolloutId = rolloutId; this.experimentIds = experimentIds; this.variables = variables; this.variableKeyToLiveVariableMap = ProjectConfigUtils.generateNameMapping(variables); @@ -58,8 +58,8 @@ public String getKey() { return key; } - public String getLayerId() { - return layerId; + public String getRolloutId() { + return rolloutId; } public List getExperimentIds() { @@ -79,7 +79,7 @@ public String toString() { return "FeatureFlag{" + "id='" + id + '\'' + ", key='" + key + '\'' + - ", layerId='" + layerId + '\'' + + ", rolloutId='" + rolloutId + '\'' + ", experimentIds=" + experimentIds + ", variables=" + variables + ", variableKeyToLiveVariableMap=" + variableKeyToLiveVariableMap + @@ -95,7 +95,7 @@ public boolean equals(Object o) { if (!id.equals(that.id)) return false; if (!key.equals(that.key)) return false; - if (!layerId.equals(that.layerId)) 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); @@ -105,7 +105,7 @@ public boolean equals(Object o) { public int hashCode() { int result = id.hashCode(); result = 31 * result + key.hashCode(); - result = 31 * result + layerId.hashCode(); + result = 31 * result + rolloutId.hashCode(); result = 31 * result + experimentIds.hashCode(); result = 31 * result + variables.hashCode(); result = 31 * result + variableKeyToLiveVariableMap.hashCode(); 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 fc75a6437..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 @@ -125,7 +125,7 @@ static Experiment parseExperiment(JsonObject experimentJson, JsonDeserialization static FeatureFlag parseFeatureFlag(JsonObject featureFlagJson, JsonDeserializationContext context) { String id = featureFlagJson.get("id").getAsString(); String key = featureFlagJson.get("key").getAsString(); - String layerId = featureFlagJson.get("layerId").getAsString(); + String layerId = featureFlagJson.get("rolloutId").getAsString(); JsonArray experimentIdsJson = featureFlagJson.getAsJsonArray("experimentIds"); List experimentIds = new ArrayList(); 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 697b500dc..79d486f09 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 @@ -157,7 +157,7 @@ private List parseFeatureFlags(JSONArray featureFlagJson) { JSONObject featureFlagObject = (JSONObject) obj; String id = featureFlagObject.getString("id"); String key = featureFlagObject.getString("key"); - String layerId = featureFlagObject.getString("layerId"); + String layerId = featureFlagObject.getString("rolloutId"); List experimentIds = parseExperimentIds(featureFlagObject.getJSONArray("experimentIds")); 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 2c37e9abb..736ab80ad 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 @@ -161,7 +161,7 @@ private List parseFeatureFlags(JSONArray featureFlagJson) { JSONObject featureFlagObject = (JSONObject)obj; String id = (String)featureFlagObject.get("id"); String key = (String)featureFlagObject.get("key"); - String layerId = (String)featureFlagObject.get("layerId"); + String layerId = (String)featureFlagObject.get("rolloutId"); JSONArray experimentIdsJsonArray = (JSONArray)featureFlagObject.get("experimentIds"); List experimentIds = parseExperimentIds(experimentIdsJsonArray); 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 index 75a91d422..165704d20 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -374,14 +374,14 @@ { "id": "4195505407", "key": "boolean_feature", - "layerId": "", + "rolloutId": "", "experimentIds": [], "variables": [] }, { "id": "3926744821", "key": "double_single_variable_feature", - "layerId": "", + "rolloutId": "", "experimentIds": [], "variables": [ { @@ -395,7 +395,7 @@ { "id": "3281420120", "key": "integer_single_variable_feature", - "layerId": "", + "rolloutId": "", "experimentIds": [], "variables": [ { @@ -409,7 +409,7 @@ { "id": "2591051011", "key": "boolean_single_variable_feature", - "layerId": "", + "rolloutId": "", "experimentIds": [], "variables": [ { @@ -423,7 +423,7 @@ { "id": "2079378557", "key": "string_single_variable_feature", - "layerId": "", + "rolloutId": "", "experimentIds": [], "variables": [ { @@ -437,7 +437,7 @@ { "id": "3263342226", "key": "multi_variate_feature", - "layerId": "", + "rolloutId": "", "experimentIds": ["3262035800"], "variables": [ { @@ -457,7 +457,7 @@ { "id": "3263342226", "key": "mutex_group_feature", - "layerId": "", + "rolloutId": "", "experimentIds": ["4138322202", "1786133852"], "variables": [ { From 4a2f0fa0b0e755cbd487428afd1fa4a37d4214b6 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Tue, 22 Aug 2017 09:56:24 -0700 Subject: [PATCH 23/34] parse rollouts (#135) * add list of rollouts to ProjectConfig and constructor. Add to all usages of ProjectConfig v4 constructor to allow compilation * add basic rollout to v4 json and V4 project config * enable Jackson to parse rollouts * enable GSON parsing for rollouts * enable parsing for org.JSON * enable json simple parsing * remove Layer.java class and remove 'policy' property of a Rollout * rollout experiment and variation keys will be the same as id --- .../java/com/optimizely/ab/config/Layer.java | 71 ------------------- .../optimizely/ab/config/ProjectConfig.java | 33 ++++++--- .../com/optimizely/ab/config/Rollout.java | 29 ++++++-- .../ab/config/parser/JsonConfigParser.java | 20 +++++- .../config/parser/JsonSimpleConfigParser.java | 20 +++++- .../parser/ProjectConfigGsonDeserializer.java | 7 +- .../ProjectConfigJacksonDeserializer.java | 7 +- .../ab/config/ProjectConfigTestUtils.java | 18 +++++ .../ab/config/ValidProjectConfigV4.java | 46 +++++++++++- .../config/valid-project-config-v4.json | 35 ++++++++- 10 files changed, 194 insertions(+), 92 deletions(-) delete mode 100644 core-api/src/main/java/com/optimizely/ab/config/Layer.java diff --git a/core-api/src/main/java/com/optimizely/ab/config/Layer.java b/core-api/src/main/java/com/optimizely/ab/config/Layer.java deleted file mode 100644 index dc4c476b4..000000000 --- a/core-api/src/main/java/com/optimizely/ab/config/Layer.java +++ /dev/null @@ -1,71 +0,0 @@ -/** - * - * 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 javax.annotation.concurrent.Immutable; - -/** - * Represents a Optimizely Layer configuration - * - * @see Project JSON - */ -@Immutable -@JsonIgnoreProperties(ignoreUnknown = true) -public class Layer implements IdMapped { - - protected final String id; - protected final String policy; - protected final List experiments; - - public static final String SINGLE_EXPERIMENT_POLICY = "single_experiment"; - - @JsonCreator - public Layer(@JsonProperty("id") String id, - @JsonProperty("policy") String policy, - @JsonProperty("experiments") List experiments) { - this.id = id; - this.policy = policy; - this.experiments = experiments; - } - - public String getId() { - return id; - } - - public String getPolicy() { - return policy; - } - - public List getExperiments() { - return experiments; - } - - @Override - public String toString() { - return "Layer{" + - "id='" + id + '\'' + - ", policy='" + policy + '\'' + - ", experiments=" + experiments + - '}'; - } -} 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 ffb891e29..77e69ad2e 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 @@ -66,6 +66,7 @@ public String toString() { private final List featureFlags; private final List groups; private final List liveVariables; + private final List rollouts; // key to entity mappings private final Map attributeKeyMapping; @@ -108,7 +109,8 @@ public ProjectConfig(String accountId, String projectId, String version, String experiments, null, groups, - liveVariables + liveVariables, + null ); } @@ -124,7 +126,8 @@ public ProjectConfig(String accountId, List experiments, List featureFlags, List groups, - List liveVariables) { + List liveVariables, + List rollouts) { this.accountId = accountId; this.projectId = projectId; @@ -141,6 +144,12 @@ public ProjectConfig(String accountId, else { this.featureFlags = Collections.unmodifiableList(featureFlags); } + if (rollouts == null) { + this.rollouts = Collections.emptyList(); + } + else { + this.rollouts = Collections.unmodifiableList(rollouts); + } this.groups = Collections.unmodifiableList(groups); @@ -243,6 +252,10 @@ public List getFeatureFlags() { return featureFlags; } + public List getRollouts() { + return rollouts; + } + public List getAttributes() { return attributes; } @@ -312,22 +325,26 @@ 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 + ", liveVariableIdToExperimentsMapping=" + liveVariableIdToExperimentsMapping + ", variationToLiveVariableUsageInstanceMapping=" + variationToLiveVariableUsageInstanceMapping + + ", 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 index 06b8af1b3..b36f33838 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/Rollout.java +++ b/core-api/src/main/java/com/optimizely/ab/config/Rollout.java @@ -16,6 +16,10 @@ */ 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; @@ -25,19 +29,32 @@ * @see Project JSON */ @Immutable -public class Rollout extends Layer implements IdMapped { +@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 Rollout(String id, - String policy, - List experiments) { - super(id, policy, experiments); + public List getExperiments() { + return experiments; } @Override public String toString() { return "Rollout{" + "id='" + id + '\'' + - ", policy='" + policy + '\'' + ", experiments=" + experiments + '}'; } 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 79d486f09..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 @@ -27,6 +27,7 @@ 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; @@ -79,8 +80,10 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse } 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( @@ -95,7 +98,8 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse experiments, featureFlags, groups, - liveVariables + liveVariables, + rollouts ); } catch (Exception e) { throw new ConfigParseException("Unable to parse datafile: " + json, e); @@ -344,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 736ab80ad..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 @@ -27,6 +27,7 @@ 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; @@ -81,8 +82,10 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse } 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( @@ -97,7 +100,8 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse experiments, featureFlags, groups, - liveVariables + liveVariables, + rollouts ); } catch (Exception e) { throw new ConfigParseException("Unable to parse datafile: " + json, e); @@ -348,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 3f4df5210..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 @@ -29,6 +29,7 @@ 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; @@ -80,9 +81,12 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa } 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( @@ -97,7 +101,8 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa experiments, featureFlags, groups, - liveVariables + 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 04503c150..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 @@ -30,6 +30,7 @@ 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; @@ -74,9 +75,12 @@ public ProjectConfig deserialize(JsonParser parser, DeserializationContext conte } 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( @@ -91,7 +95,8 @@ public ProjectConfig deserialize(JsonParser parser, DeserializationContext conte experiments, featureFlags, groups, - liveVariables + liveVariables, + rollouts ); } } \ 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 fa4a43a25..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 @@ -457,6 +457,7 @@ public static void verifyProjectConfig(@CheckForNull ProjectConfig actual, @Nonn verifyFeatureFlags(actual.getFeatureFlags(), expected.getFeatureFlags()); verifyLiveVariables(actual.getLiveVariables(), expected.getLiveVariables()); verifyGroups(actual.getGroups(), expected.getGroups()); + verifyRollouts(actual.getRollouts(), expected.getRollouts()); } /** @@ -617,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. */ 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 index e163abd52..b073b04d6 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -143,7 +143,7 @@ public class ValidProjectConfigV4 { private static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_STRING = new FeatureFlag( FEATURE_SINGLE_VARIABLE_STRING_ID, FEATURE_SINGLE_VARIABLE_STRING_KEY, - "", + "1058508303", Collections.emptyList(), Collections.singletonList( VARIABLE_STRING_VARIABLE @@ -667,6 +667,43 @@ public class ValidProjectConfigV4 { ) ); + 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 + ) + ) + ); + private static final Rollout ROLLOUT_1 = new Rollout( + ROLLOUT_1_ID, + Collections.singletonList( + ROLLOUT_1_EVERYONE_ELSE_RULE + ) + ); public static ProjectConfig generateValidProjectConfigV4() { @@ -705,6 +742,10 @@ public static ProjectConfig generateValidProjectConfigV4() { groups.add(GROUP_1); groups.add(GROUP_2); + // list rollouts + List rollouts = new ArrayList(); + rollouts.add(ROLLOUT_1); + return new ProjectConfig( ACCOUNT_ID, ANONYMIZE_IP, @@ -717,7 +758,8 @@ public static ProjectConfig generateValidProjectConfigV4() { experiments, featureFlags, groups, - Collections.emptyList() + Collections.emptyList(), + rollouts ); } } 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 index 165704d20..e56b804ed 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -423,7 +423,7 @@ { "id": "2079378557", "key": "string_single_variable_feature", - "rolloutId": "", + "rolloutId": "1058508303", "experimentIds": [], "variables": [ { @@ -469,5 +469,38 @@ ] } ], + "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 + } + ] + } + ] + } + ], "variables": [] } From 38b2fb8927ed3fb071378e54c28ffce63dc499f2 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Tue, 22 Aug 2017 15:17:52 -0700 Subject: [PATCH 24/34] Feature/force bucketing (#136) * added force bucketing * get test working * unit test forced variations * fix EventBuilderV2Test using AssertNotEqual * methods and map in project config. decision logic in decision service * use variation and experiment ids instead of key for storage. use putIfAbsent * added logging for projectConfig --- .../java/com/optimizely/ab/Optimizely.java | 37 +++ .../ab/bucketing/DecisionService.java | 8 +- .../optimizely/ab/config/ProjectConfig.java | 147 +++++++++ .../com/optimizely/ab/OptimizelyTest.java | 278 ++++++++++++++++-- .../ab/bucketing/DecisionServiceTest.java | 150 +++++++++- .../ab/config/ProjectConfigTest.java | 150 ++++++++++ .../ab/event/internal/EventBuilderV2Test.java | 5 +- 7 files changed, 741 insertions(+), 34 deletions(-) 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 fa1b824c7..090fa209a 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -53,6 +53,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * Top-level container class for Optimizely functionality. @@ -615,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. */ 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 8d6137321..50b9241b8 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 @@ -82,8 +82,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; } 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 77e69ad2e..cbbc35fd9 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,7 +19,10 @@ 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; @@ -27,6 +30,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * Represents the Optimizely Project configuration. @@ -54,6 +58,10 @@ 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; @@ -85,6 +93,14 @@ public String toString() { 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, @@ -318,6 +334,136 @@ 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 \"%s\" has been removed for user \"%s\"", 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 \"%s\" for experiment \"%s\" and user \"%s\" 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 \"%s\" mapped to user \"%s\" 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 \"%s\" is mapped to experiment \"%s\" and user \"%s\" in the forced variation map", + variation.getKey(), experimentKey, userId); + return variation; + } + } + else { + logger.debug("No variation for experiment \"%s\" mapped to user \"%s\" in the forced variation map ", experimentKey, userId); + } + } + return null; + } + @Override public String toString() { return "ProjectConfig{" + @@ -344,6 +490,7 @@ public String toString() { ", groupIdMapping=" + groupIdMapping + ", liveVariableIdToExperimentsMapping=" + liveVariableIdToExperimentsMapping + ", variationToLiveVariableUsageInstanceMapping=" + variationToLiveVariableUsageInstanceMapping + + ", forcedVariationMapping=" + forcedVariationMapping + ", variationIdToExperimentMapping=" + variationIdToExperimentMapping + '}'; } 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 5765fb036..82fbbf8de 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -55,10 +55,11 @@ import java.io.IOException; import java.util.Arrays; import java.util.Collection; -import java.util.Collections; +import java.util.Map; import java.util.HashMap; import java.util.List; -import java.util.Map; +import java.util.Collections; +import java.util.ArrayList; import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigJsonV2; import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigJsonV3; @@ -193,7 +194,7 @@ public OptimizelyTest(int datafileVersion, public void activateEndToEnd() throws Exception { Experiment activatedExperiment; Map testUserAttributes = new HashMap(); - if(datafileVersion == 4) { + if(datafileVersion >= 4) { activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); } @@ -212,6 +213,7 @@ public void activateEndToEnd() throws Exception { .build(); Map testParams = new HashMap(); + testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(validProjectConfig, activatedExperiment, bucketedVariation, "userId", @@ -296,6 +298,161 @@ public void activateWhenExperimentIsNotInProject() throws Exception { optimizely.activate(unknownExperiment, "userId"); } + /** + * 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 @@ -315,7 +472,7 @@ public void activateWithExperimentKey() throws Exception { .build(); Map testUserAttributes = new HashMap(); - if (datafileVersion == 4) { + if (datafileVersion >= 4) { testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); } else { @@ -458,7 +615,7 @@ public void activateWithUnknownAttribute() throws Exception { .build(); Map testUserAttributes = new HashMap(); - if (datafileVersion == 4) { + if (datafileVersion >= 4) { testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); } else { @@ -913,6 +1070,80 @@ 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)}. @@ -922,13 +1153,13 @@ public void trackEventEndToEnd() throws Exception { EventType eventType; String datafile; ProjectConfig config; - if (datafileVersion == 4) { - config = validProjectConfig; + if (datafileVersion >= 4) { + config = spy(validProjectConfig); eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); datafile = validDatafile; } else { - config = noAudienceProjectConfig; + config = spy(noAudienceProjectConfig); eventType = noAudienceProjectConfig.getEventTypes().get(0); datafile = noAudienceDatafile; } @@ -937,7 +1168,7 @@ public void trackEventEndToEnd() throws Exception { EventBuilder eventBuilderV2 = new EventBuilderV2(); DecisionService spyDecisionService = spy(new DecisionService(mockBucketer, mockErrorHandler, - validProjectConfig, + config, null)); Optimizely optimizely = Optimizely.builder(datafile, mockEventHandler) @@ -963,6 +1194,7 @@ public void trackEventEndToEnd() throws Exception { 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()); @@ -1021,7 +1253,7 @@ public void trackEventWithUnknownEventKeyAndRaiseExceptionErrorHandler() throws public void trackEventWithAttributes() throws Exception { Attribute attribute = validProjectConfig.getAttributes().get(0); EventType eventType; - if (datafileVersion == 4) { + if (datafileVersion >= 4) { eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); } else { @@ -1094,7 +1326,7 @@ public void trackEventWithAttributes() throws Exception { justification="testing nullness contract violation") public void trackEventWithNullAttributes() throws Exception { EventType eventType; - if (datafileVersion == 4) { + if (datafileVersion >= 4) { eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); } else { @@ -1166,7 +1398,7 @@ public void trackEventWithNullAttributes() throws Exception { @Test public void trackEventWithNullAttributeValues() throws Exception { EventType eventType; - if (datafileVersion == 4) { + if (datafileVersion >= 4) { eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); } else { @@ -1238,7 +1470,7 @@ public void trackEventWithNullAttributeValues() throws Exception { @SuppressWarnings("unchecked") public void trackEventWithUnknownAttribute() throws Exception { EventType eventType; - if (datafileVersion == 4) { + if (datafileVersion >= 4) { eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); } else { @@ -1309,7 +1541,7 @@ public void trackEventWithUnknownAttribute() throws Exception { @Test public void trackEventWithEventTags() throws Exception { EventType eventType; - if (datafileVersion == 4) { + if (datafileVersion >= 4) { eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); } else { @@ -1392,7 +1624,7 @@ public void trackEventWithEventTags() throws Exception { justification="testing nullness contract violation") public void trackEventWithNullEventTags() throws Exception { EventType eventType; - if (datafileVersion == 4) { + if (datafileVersion >= 4) { eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); } else { @@ -1456,7 +1688,7 @@ public void trackEventWithNullEventTags() throws Exception { @Test public void trackEventWithNoValidExperiments() throws Exception { EventType eventType; - if (datafileVersion == 4) { + if (datafileVersion >= 4) { eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); } else { @@ -1506,7 +1738,7 @@ public void trackDispatchEventThrowsException() throws Exception { @Test public void trackDoesNotSendEventWhenExperimentsAreLaunchedOnly() throws Exception { EventType eventType; - if (datafileVersion == 4) { + if (datafileVersion >= 4) { eventType = validProjectConfig.getEventNameMapping().get(EVENT_LAUNCHED_EXPERIMENT_ONLY_KEY); } else { @@ -1563,7 +1795,7 @@ public void trackDoesNotSendEventWhenExperimentsAreLaunchedOnly() throws Excepti public void trackDispatchesWhenEventHasLaunchedAndRunningExperiments() throws Exception { EventBuilder mockEventBuilder = mock(EventBuilder.class); EventType eventType; - if (datafileVersion == 4) { + if (datafileVersion >= 4) { eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); } else { @@ -1763,7 +1995,7 @@ public void getVariationWithAudiences() throws Exception { @Test public void getVariationWithAudiencesNoAttributes() throws Exception { Experiment experiment; - if (datafileVersion == 4) { + if (datafileVersion >= 4) { experiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); } else { @@ -1853,7 +2085,7 @@ public void getVariationForGroupExperimentWithMatchingAttributes() throws Except Variation variation = experiment.getVariations().get(0); Map attributes = new HashMap(); - if (datafileVersion == 4) { + if (datafileVersion >= 4) { attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); } else { @@ -1897,7 +2129,7 @@ public void getVariationForGroupExperimentWithNonMatchingAttributes() throws Exc @Test public void getVariationExperimentStatusPrecedesForcedVariation() throws Exception { Experiment experiment; - if (datafileVersion == 4) { + if (datafileVersion >= 4) { experiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_PAUSED_EXPERIMENT_KEY); } else { @@ -1924,7 +2156,7 @@ public void getVariationExperimentStatusPrecedesForcedVariation() throws Excepti public void addNotificationListener() throws Exception { Experiment activatedExperiment; EventType eventType; - if (datafileVersion == 4) { + if (datafileVersion >= 4) { activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_BASIC_EXPERIMENT_KEY); eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); } @@ -2074,7 +2306,7 @@ public void removeNotificationListener() throws Exception { public void clearNotificationListeners() throws Exception { Experiment activatedExperiment; Map attributes = new HashMap(); - if (datafileVersion == 4) { + if (datafileVersion >= 4) { activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); } 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 d5f731877..eef46cc5a 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 @@ -39,14 +39,16 @@ import java.util.Map; import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigV3; +import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV2; import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV3; import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyMap; import static org.mockito.Matchers.anyMapOf; import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.eq; @@ -58,6 +60,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; public class DecisionServiceTest { @@ -90,11 +93,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); @@ -112,6 +115,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)} @@ -141,6 +238,45 @@ 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 ==========// @@ -252,7 +388,7 @@ public void getVariationForFeatureReturnsVariationReturnedFromGetVarition() { * 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); @@ -266,7 +402,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"; @@ -297,7 +433,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/event/internal/EventBuilderV2Test.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventBuilderV2Test.java index 841315924..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 @@ -70,7 +70,6 @@ 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; @@ -172,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"); } } From 4eceb97fe0e7e02b3225faf7356e6fe877c9497e Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Fri, 25 Aug 2017 10:41:39 -0700 Subject: [PATCH 25/34] fix logging replacing %s with {} (#137) --- .../java/com/optimizely/ab/config/ProjectConfig.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 cbbc35fd9..dc68400c8 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 @@ -391,7 +391,7 @@ public boolean setForcedVariation(@Nonnull String experimentKey, if (removedVariationId != null) { Variation removedVariation = experiment.getVariationIdToVariationMap().get(removedVariationId); if (removedVariation != null) { - logger.debug("Variation mapped to experiment \"%s\" has been removed for user \"%s\"", experiment.getKey(), userId); + 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"); @@ -404,7 +404,7 @@ public boolean setForcedVariation(@Nonnull String experimentKey, } else { String previous = experimentToVariation.put(experiment.getId(), variation.getId()); - logger.debug("Set variation \"%s\" for experiment \"%s\" and user \"%s\" in the forced variation map.", + 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); @@ -445,20 +445,20 @@ public boolean setForcedVariation(@Nonnull String experimentKey, if (experimentToVariation != null) { Experiment experiment = getExperimentKeyMapping().get(experimentKey); if (experiment == null) { - logger.debug("No experiment \"%s\" mapped to user \"%s\" in the forced variation map ", experimentKey, userId); + 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 \"%s\" is mapped to experiment \"%s\" and user \"%s\" in the forced variation map", + 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 \"%s\" mapped to user \"%s\" in the forced variation map ", experimentKey, userId); + logger.debug("No variation for experiment \"{}\" mapped to user \"{}\" in the forced variation map ", experimentKey, userId); } } return null; From 7154aa33ce544d3deeaa49c5d36812ab5b2aa61b Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Tue, 29 Aug 2017 15:12:13 -0700 Subject: [PATCH 26/34] Feature/changelog update (#138) * update changelog on master and cherry-pick into 1.8.x --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4502b179b..007da4538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,19 @@ # Optimizely Java X SDK Changelog +## 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 From 9085e0aea2c452e72607e38d21201bc2e6aa774a Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Fri, 1 Sep 2017 16:02:38 -0700 Subject: [PATCH 27/34] use precise distribution instead of trusty (#141) --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 5478b88cb..dc0f0fca5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,5 @@ language: java +dist: precise jdk: - openjdk7 - oraclejdk7 From b3789241fbda254d1dcd0d27f835f536eceb7f02 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Fri, 1 Sep 2017 17:55:52 -0700 Subject: [PATCH 28/34] rollout bucketing (#140) * rollout bucketing using fallback strategy --- .../java/com/optimizely/ab/Optimizely.java | 4 +- .../ab/bucketing/DecisionService.java | 77 +++- .../optimizely/ab/config/ProjectConfig.java | 7 + .../com/optimizely/ab/OptimizelyTest.java | 67 ++- .../ab/bucketing/DecisionServiceTest.java | 426 +++++++++++++++++- .../ab/config/ValidProjectConfigV4.java | 321 ++++++++++--- .../config/valid-project-config-v4.json | 183 +++++++- 7 files changed, 996 insertions(+), 89 deletions(-) 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 090fa209a..1ee6d498e 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -53,7 +53,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; /** * Top-level container class for Optimizely functionality. @@ -562,7 +561,8 @@ else if (!variable.getType().equals(variableType)) { else { logger.info("User \"" + userId + "\" was not bucketed into any variation for feature flag \"" + featureKey + - "\". The default value is being returned." + "\". The default value \"" + variableValue + + "\" for \"" + variableKey + "\" is being returned." ); } 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 50b9241b8..76c7d4f7a 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 @@ -20,7 +20,9 @@ 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; @@ -165,7 +167,80 @@ public DecisionService(@Nonnull Bucketer bucketer, logger.info("The feature flag \"" + featureFlag.getKey() + "\" is not used in any experiments."); } - return null; + 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 (!rolloutRule.isActive()) { + logger.debug("Did not attempt to bucket user into rollout rule for audience \"" + + audience.getName() + "\" since the rule is not active."); + } + else 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; } /** 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 dc68400c8..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 @@ -87,6 +87,7 @@ public String toString() { private final Map audienceIdMapping; private final Map experimentIdMapping; private final Map groupIdMapping; + private final Map rolloutIdMapping; // other mappings private final Map> liveVariableIdToExperimentsMapping; @@ -192,6 +193,7 @@ public ProjectConfig(String accountId, 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; @@ -318,6 +320,10 @@ public Map getGroupIdMapping() { return groupIdMapping; } + public Map getRolloutIdMapping() { + return rolloutIdMapping; + } + public Map getLiveVariableKeyMapping() { return liveVariableKeyMapping; } @@ -488,6 +494,7 @@ public String toString() { ", audienceIdMapping=" + audienceIdMapping + ", experimentIdMapping=" + experimentIdMapping + ", groupIdMapping=" + groupIdMapping + + ", rolloutIdMapping=" + rolloutIdMapping + ", liveVariableIdToExperimentsMapping=" + liveVariableIdToExperimentsMapping + ", variationToLiveVariableUsageInstanceMapping=" + variationToLiveVariableUsageInstanceMapping + ", forcedVariationMapping=" + forcedVariationMapping + 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 82fbbf8de..e40e5eb1c 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -37,7 +37,6 @@ import com.optimizely.ab.event.LogEvent; import com.optimizely.ab.event.internal.EventBuilder; import com.optimizely.ab.event.internal.EventBuilderV2; -import com.optimizely.ab.event.internal.payload.Feature; import com.optimizely.ab.internal.LogbackVerifier; import com.optimizely.ab.notification.NotificationListener; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @@ -53,13 +52,13 @@ import org.mockito.junit.MockitoRule; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; -import java.util.Map; +import java.util.Collections; import java.util.HashMap; import java.util.List; -import java.util.Collections; -import java.util.ArrayList; +import java.util.Map; import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigJsonV2; import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigJsonV3; @@ -80,14 +79,17 @@ 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_STRING_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_FIRST_LETTER_DEFAULT_VALUE; +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.VARIABLE_STRING_VARIABLE_DEFAULT_VALUE; -import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_STRING_VARIABLE_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; @@ -2504,16 +2506,16 @@ public void getFeatureVariableValueReturnsNullWhenVariableTypeDoesNotMatch() thr /** * 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. + * when the feature is not attached to an experiment or a rollout. * @throws ConfigParseException */ @Test - public void getFeatureVariableValueForTypeReturnsDefaultValueWhenFeatureIsNotAttached() throws ConfigParseException { + public void getFeatureVariableValueForTypeReturnsDefaultValueWhenFeatureIsNotAttachedToExperimentOrRollout() throws ConfigParseException { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); - String validFeatureKey = FEATURE_SINGLE_VARIABLE_STRING_KEY; - String validVariableKey = VARIABLE_STRING_VARIABLE_KEY; - String defaultValue = VARIABLE_STRING_VARIABLE_DEFAULT_VALUE; + 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) @@ -2525,28 +2527,41 @@ public void getFeatureVariableValueForTypeReturnsDefaultValueWhenFeatureIsNotAtt validVariableKey, genericUserId, attributes, - LiveVariable.VariableType.STRING); + 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, but the user is excluded from the experiment. + * 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_MULTI_VARIATE_FEATURE_KEY; - String validVariableKey = VARIABLE_FIRST_LETTER_KEY; - String expectedValue = VARIABLE_FIRST_LETTER_DEFAULT_VALUE; + 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) @@ -2556,16 +2571,26 @@ public void getFeatureVariableValueReturnsDefaultValueWhenFeatureIsAttachedToOne validFeatureKey, validVariableKey, genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, "Slytherin"), - LiveVariable.VariableType.STRING + 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 is being returned." + "\". The default value \"" + expectedValue + + "\" for \"" + validVariableKey + "\" is being returned." ); } 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 eef46cc5a..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 @@ -20,6 +20,8 @@ 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; @@ -39,19 +41,27 @@ import java.util.Map; import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigV3; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV2; 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.junit.Assert.assertFalse; 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; @@ -60,7 +70,6 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.mockito.Mockito.times; public class DecisionServiceTest { @@ -75,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; @@ -82,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"); @@ -283,7 +294,7 @@ public void getVariationOnNonRunningExperimentWithForcedVariation() { /** * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map)} - * returns null when the {@link FeatureFlag} is not used in an experiments. + * returns null when the {@link FeatureFlag} is not used in an experiments or rollouts. */ @Test @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT") @@ -292,6 +303,7 @@ public void getVariationForFeatureReturnsNullWhenFeatureFlagExperimentIdsIsEmpty 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), @@ -300,7 +312,12 @@ public void getVariationForFeatureReturnsNullWhenFeatureFlagExperimentIdsIsEmpty null); logbackVerifier.expectMessage(Level.INFO, - "The feature flag \"" + featureKey + "\" is not used in any experiments"); + "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, @@ -308,17 +325,18 @@ public void getVariationForFeatureReturnsNullWhenFeatureFlagExperimentIdsIsEmpty Collections.emptyMap())); verify(emptyFeatureFlag, times(1)).getExperimentIds(); - verify(emptyFeatureFlag, times(1)).getKey(); + 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 for the {@link FeatureFlag}. + * 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 getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiments() { - FeatureFlag spyFeatureFlag = spy(ValidProjectConfigV4.FEATURE_FLAG_MULTI_VARIATE_FEATURE); + public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperimentsAndRollouts() { + FeatureFlag spyFeatureFlag = spy(FEATURE_FLAG_MULTI_VARIATE_FEATURE); DecisionService spyDecisionService = spy(new DecisionService( mock(Bucketer.class), @@ -327,20 +345,32 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment 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, never()).getKey(); + verify(spyFeatureFlag, times(1)).getKey(); } /** @@ -349,7 +379,7 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment */ @Test @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT") - public void getVariationForFeatureReturnsVariationReturnedFromGetVarition() { + public void getVariationForFeatureReturnsVariationReturnedFromGetVariation() { FeatureFlag spyFeatureFlag = spy(ValidProjectConfigV4.FEATURE_FLAG_MUTEX_GROUP_FEATURE); DecisionService spyDecisionService = spy(new DecisionService( @@ -382,7 +412,379 @@ public void getVariationForFeatureReturnsVariationReturnedFromGetVarition() { verify(spyFeatureFlag, never()).getKey(); } - //========= white list tests ==========/ + /** + * 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 ==========/ /** * Test {@link DecisionService#getWhitelistedVariation(Experiment, String)} correctly returns a whitelisted variation. 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 index b073b04d6..bc0dab271 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -40,6 +40,10 @@ public class ValidProjectConfigV4 { 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"; @@ -54,6 +58,31 @@ public class ValidProjectConfigV4 { 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"; @@ -66,10 +95,10 @@ public class ValidProjectConfigV4 { Collections.emptyList() ); private static final String FEATURE_SINGLE_VARIABLE_DOUBLE_ID = "3926744821"; - private static final String FEATURE_SINGLE_VARIABLE_DOUBLE_KEY = "double_single_variable_feature"; + public static final String FEATURE_SINGLE_VARIABLE_DOUBLE_KEY = "double_single_variable_feature"; private static final String VARIABLE_DOUBLE_VARIABLE_ID = "4111654444"; - private static final String VARIABLE_DOUBLE_VARIABLE_KEY = "double_variable"; - private static final String VARIABLE_DOUBLE_DEFAULT_VALUE = "14.99"; + 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, @@ -77,15 +106,6 @@ public class ValidProjectConfigV4 { null, LiveVariable.VariableType.DOUBLE ); - private static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE = new FeatureFlag( - FEATURE_SINGLE_VARIABLE_DOUBLE_ID, - FEATURE_SINGLE_VARIABLE_DOUBLE_KEY, - "", - Collections.emptyList(), - Collections.singletonList( - VARIABLE_DOUBLE_VARIABLE - ) - ); 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"; @@ -108,10 +128,10 @@ public class ValidProjectConfigV4 { ) ); private static final String FEATURE_SINGLE_VARIABLE_BOOLEAN_ID = "2591051011"; - private static final String FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY = "boolean_single_variable_feature"; + public static final String FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY = "boolean_single_variable_feature"; private static final String VARIABLE_BOOLEAN_VARIABLE_ID = "3974680341"; - private static final String VARIABLE_BOOLEAN_VARIABLE_KEY = "boolean_variable"; - private static final String VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE = "true"; + 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, @@ -140,10 +160,47 @@ public class ValidProjectConfigV4 { null, LiveVariable.VariableType.STRING ); - private static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_STRING = new FeatureFlag( + 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, - "1058508303", + ROLLOUT_1_ID, Collections.emptyList(), Collections.singletonList( VARIABLE_STRING_VARIABLE @@ -457,6 +514,57 @@ public class ValidProjectConfigV4 { ) ) ); + + 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"; @@ -643,11 +751,143 @@ public class ValidProjectConfigV4 { ) ); + // 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, @@ -666,54 +906,31 @@ public class ValidProjectConfigV4 { VARIABLE_CORRELATING_VARIATION_NAME_VARIABLE ) ); - - 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(), + public static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE = new FeatureFlag( + FEATURE_SINGLE_VARIABLE_DOUBLE_ID, + FEATURE_SINGLE_VARIABLE_DOUBLE_KEY, + "", Collections.singletonList( - ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION + EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_ID ), - Collections.emptyMap(), Collections.singletonList( - new TrafficAllocation( - ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, - 5000 - ) - ) - ); - private static final Rollout ROLLOUT_1 = new Rollout( - ROLLOUT_1_ID, - Collections.singletonList( - ROLLOUT_1_EVERYONE_ELSE_RULE + 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(); @@ -725,6 +942,7 @@ public static ProjectConfig generateValidProjectConfigV4() { 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); @@ -745,6 +963,7 @@ public static ProjectConfig generateValidProjectConfigV4() { // list rollouts List rollouts = new ArrayList(); rollouts.add(ROLLOUT_1); + rollouts.add(ROLLOUT_2); return new ProjectConfig( ACCOUNT_ID, 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 index e56b804ed..9ef2e682f 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -9,12 +9,26 @@ "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": [ @@ -169,6 +183,46 @@ "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", @@ -382,7 +436,7 @@ "id": "3926744821", "key": "double_single_variable_feature", "rolloutId": "", - "experimentIds": [], + "experimentIds": ["2201520193"], "variables": [ { "id": "4111654444", @@ -437,7 +491,7 @@ { "id": "3263342226", "key": "multi_variate_feature", - "rolloutId": "", + "rolloutId": "813411034", "experimentIds": ["3262035800"], "variables": [ { @@ -500,6 +554,131 @@ ] } ] + }, + { + "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": [] From 6baf16fae7818284ed84cf6905196369a2979ca1 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Tue, 12 Sep 2017 15:05:04 -0700 Subject: [PATCH 29/34] ignore experiment status when bucketing rollout rule experiments (#142) --- .../java/com/optimizely/ab/bucketing/DecisionService.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 76c7d4f7a..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 @@ -210,11 +210,7 @@ public DecisionService(@Nonnull Bucketer bucketer, for (int i = 0; i < rolloutRulesLength - 1; i++) { Experiment rolloutRule= rollout.getExperiments().get(i); Audience audience = projectConfig.getAudienceIdMapping().get(rolloutRule.getAudienceIds().get(0)); - if (!rolloutRule.isActive()) { - logger.debug("Did not attempt to bucket user into rollout rule for audience \"" + - audience.getName() + "\" since the rule is not active."); - } - else if (ExperimentUtils.isUserInExperiment(projectConfig, rolloutRule, filteredAttributes)) { + if (ExperimentUtils.isUserInExperiment(projectConfig, rolloutRule, filteredAttributes)) { logger.debug("Attempting to bucket user \"" + userId + "\" into rollout rule for audience \"" + audience.getName() + "\"."); From 77ed8393cd802bd989afb9be63ac56dc3bcafd68 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Thu, 28 Sep 2017 15:14:39 -0700 Subject: [PATCH 30/34] use trusty distribution (#145) Upgrade travis-ci travis.yml to use trusty distribution since that is their new standard. The gradle version of their linux distribution is gradle 4.0.1 so I upgraded our gradle wrapper to that. That required we upgrade our nebula dependency to 3.2.0. Drop support for JDK 7 until travis-ci can fix that. --- .travis.yml | 10 +- build.gradle | 12 +- core-api/build.gradle | 12 +- core-httpclient-impl/build.gradle | 4 +- gradle.properties | 1 - gradle/wrapper/gradle-wrapper.jar | Bin 53636 -> 54706 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew | 72 ++++++---- gradlew.bat | 174 +++++++++++------------ 9 files changed, 146 insertions(+), 143 deletions(-) diff --git a/.travis.yml b/.travis.yml index dc0f0fca5..9f22e144d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,13 @@ language: java -dist: precise +dist: trusty jdk: - - openjdk7 - - oraclejdk7 + - openjdk8 - oraclejdk8 + - oraclejdk9 install: true script: - - "./gradlew clean" - - "./gradlew exhaustiveTest" + - "gradle clean" + - "gradle exhaustiveTest" - "if [[ -n $TRAVIS_TAG ]]; then ./gradlew ship; else 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-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 c1420b9ba..f5ac305dc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,6 @@ version = 2.0.0-SNAPSHOT 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 13372aef5e24af05341d49695ee84e5f9b594659..6175a9eb412c404798d3f79473ff17aa35cb8fb1 100644 GIT binary patch delta 28636 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 E06Ns>SO5S3 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 From ea5ccc926364d003139524ee9756c1bf5c616abe Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Thu, 28 Sep 2017 15:31:33 -0700 Subject: [PATCH 31/34] allow travis to ship betas (#143) --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 9f22e144d..56e09de91 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,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. From 004b9e3d9eccd198c151dd2462007b46bafb4d7d Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Thu, 28 Sep 2017 20:55:15 -0700 Subject: [PATCH 32/34] Prepare 2.0.0 Beta (#146) * use gradle wrapper from repo * update CHANGELOG.md --- .travis.yml | 4 ++-- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 56e09de91..417ca7e6f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,8 +6,8 @@ jdk: - oraclejdk9 install: true script: - - "gradle clean" - - "gradle exhaustiveTest" + - "./gradlew clean" + - "./gradlew exhaustiveTest" - "if [[ -n $TRAVIS_TAG ]]; then ./gradlew ship; else diff --git a/CHANGELOG.md b/CHANGELOG.md index 007da4538..98df54966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,30 @@ # 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)` + ## 1.8.0 August 29, 2017 From db8c758bd238804b8e754ec905d2a83746a75507 Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Fri, 29 Sep 2017 10:28:07 -0700 Subject: [PATCH 33/34] Use generic badge for Apache License (#144) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From f4228f369433829ad33462bd25c9e1154ce6a44a Mon Sep 17 00:00:00 2001 From: Joshua Wang Date: Fri, 29 Sep 2017 14:03:21 -0700 Subject: [PATCH 34/34] update CHANGELOG.md (#148) * update CHANGELOG.md * add some more detail to explain we will stop testing in travis-ci on java 7 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98df54966..2bd9c8d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ You can now use feature flags in the Java SDK. You can experiment on features an - 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