diff --git a/.gitignore b/.gitignore index aefc53cb6..dcf3ee891 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ classes .vagrant .DS_Store .venv + +.vscode/mcp.json \ No newline at end of file diff --git a/build.gradle b/build.gradle index 54426f6e7..845830761 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ plugins { id 'com.github.kt3k.coveralls' version '2.12.2' id 'jacoco' id 'me.champeau.gradle.jmh' version '0.5.3' - id 'nebula.optional-base' version '3.2.0' + id 'nebula.optional-base' version '3.1.0' id 'com.github.hierynomus.license' version '0.16.1' id 'com.github.spotbugs' version "6.0.14" id 'maven-publish' @@ -116,6 +116,7 @@ configure(publishedProjects) { configurations.all { resolutionStrategy { force "junit:junit:${junitVersion}" + force 'com.netflix.nebula:nebula-gradle-interop:2.2.2' } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java index 28ad519a5..969eb8fb6 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java @@ -95,6 +95,8 @@ public class DatafileProjectConfig implements ProjectConfig { // other mappings private final Map variationIdToExperimentMapping; + private final HoldoutConfig holdoutConfig; + private String datafile; // v2 constructor @@ -124,6 +126,7 @@ public DatafileProjectConfig(String accountId, String projectId, String version, eventType, experiments, null, + null, groups, null, null @@ -145,6 +148,7 @@ public DatafileProjectConfig(String accountId, List typedAudiences, List events, List experiments, + List holdouts, List featureFlags, List groups, List rollouts, @@ -187,6 +191,12 @@ public DatafileProjectConfig(String accountId, allExperiments.addAll(aggregateGroupExperiments(groups)); this.experiments = Collections.unmodifiableList(allExperiments); + if (holdouts == null) { + this.holdoutConfig = new HoldoutConfig(); + } else { + this.holdoutConfig = new HoldoutConfig(holdouts); + } + String publicKeyForODP = ""; String hostForODP = ""; if (integrations == null) { @@ -434,6 +444,21 @@ public List getExperiments() { return experiments; } + @Override + public List getHoldouts() { + return holdoutConfig.getAllHoldouts(); + } + + @Override + public List getHoldoutForFlag(@Nonnull String id) { + return holdoutConfig.getHoldoutForFlag(id); + } + + @Override + public Holdout getHoldout(@Nonnull String id) { + return holdoutConfig.getHoldout(id); + } + @Override public Set getAllSegments() { return this.allSegments; diff --git a/core-api/src/main/java/com/optimizely/ab/config/Experiment.java b/core-api/src/main/java/com/optimizely/ab/config/Experiment.java index 11530735c..4201d7db7 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/Experiment.java +++ b/core-api/src/main/java/com/optimizely/ab/config/Experiment.java @@ -34,7 +34,7 @@ */ @Immutable @JsonIgnoreProperties(ignoreUnknown = true) -public class Experiment implements IdKeyMapped { +public class Experiment implements ExperimentCore { private final String id; private final String key; @@ -42,10 +42,6 @@ public class Experiment implements IdKeyMapped { private final String layerId; private final String groupId; - private final String AND = "AND"; - private final String OR = "OR"; - private final String NOT = "NOT"; - private final List audienceIds; private final Condition audienceConditions; private final List variations; @@ -176,98 +172,6 @@ public boolean isLaunched() { return status.equals(ExperimentStatus.LAUNCHED.toString()); } - public String serializeConditions(Map audiencesMap) { - Condition condition = this.audienceConditions; - return condition instanceof EmptyCondition ? "" : this.serialize(condition, audiencesMap); - } - - private String getNameFromAudienceId(String audienceId, Map audiencesMap) { - StringBuilder audienceName = new StringBuilder(); - if (audiencesMap != null && audiencesMap.get(audienceId) != null) { - audienceName.append("\"" + audiencesMap.get(audienceId) + "\""); - } else { - audienceName.append("\"" + audienceId + "\""); - } - return audienceName.toString(); - } - - private String getOperandOrAudienceId(Condition condition, Map audiencesMap) { - if (condition != null) { - if (condition instanceof AudienceIdCondition) { - return this.getNameFromAudienceId(condition.getOperandOrId(), audiencesMap); - } else { - return condition.getOperandOrId(); - } - } else { - return ""; - } - } - - public String serialize(Condition condition, Map audiencesMap) { - StringBuilder stringBuilder = new StringBuilder(); - List conditions; - - String operand = this.getOperandOrAudienceId(condition, audiencesMap); - switch (operand){ - case (AND): - conditions = ((AndCondition) condition).getConditions(); - stringBuilder.append(this.getNameOrNextCondition(operand, conditions, audiencesMap)); - break; - case (OR): - conditions = ((OrCondition) condition).getConditions(); - stringBuilder.append(this.getNameOrNextCondition(operand, conditions, audiencesMap)); - break; - case (NOT): - stringBuilder.append(operand + " "); - Condition notCondition = ((NotCondition) condition).getCondition(); - if (notCondition instanceof AudienceIdCondition) { - stringBuilder.append(serialize(notCondition, audiencesMap)); - } else { - stringBuilder.append("(" + serialize(notCondition, audiencesMap) + ")"); - } - break; - default: - stringBuilder.append(operand); - break; - } - - return stringBuilder.toString(); - } - - public String getNameOrNextCondition(String operand, List conditions, Map audiencesMap) { - StringBuilder stringBuilder = new StringBuilder(); - int index = 0; - if (conditions.isEmpty()) { - return ""; - } else if (conditions.size() == 1) { - return serialize(conditions.get(0), audiencesMap); - } else { - for (Condition con : conditions) { - index++; - if (index + 1 <= conditions.size()) { - if (con instanceof AudienceIdCondition) { - String audienceName = this.getNameFromAudienceId(((AudienceIdCondition) con).getAudienceId(), - audiencesMap); - stringBuilder.append( audienceName + " "); - } else { - stringBuilder.append("(" + serialize(con, audiencesMap) + ") "); - } - stringBuilder.append(operand); - stringBuilder.append(" "); - } else { - if (con instanceof AudienceIdCondition) { - String audienceName = this.getNameFromAudienceId(((AudienceIdCondition) con).getAudienceId(), - audiencesMap); - stringBuilder.append(audienceName); - } else { - stringBuilder.append("(" + serialize(con, audiencesMap) + ")"); - } - } - } - } - return stringBuilder.toString(); - } - @Override public String toString() { return "Experiment{" + diff --git a/core-api/src/main/java/com/optimizely/ab/config/ExperimentCore.java b/core-api/src/main/java/com/optimizely/ab/config/ExperimentCore.java new file mode 100644 index 000000000..9c67c942b --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/ExperimentCore.java @@ -0,0 +1,134 @@ +/** + * + * Copyright 2016-2019, 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import com.optimizely.ab.config.audience.AndCondition; +import com.optimizely.ab.config.audience.AudienceIdCondition; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.EmptyCondition; +import com.optimizely.ab.config.audience.NotCondition; +import com.optimizely.ab.config.audience.OrCondition; + +import java.util.List; +import java.util.Map; + +public interface ExperimentCore extends IdKeyMapped { + String AND = "AND"; + String OR = "OR"; + String NOT = "NOT"; + + String getLayerId(); + String getGroupId(); + List getAudienceIds(); + Condition getAudienceConditions(); + List getVariations(); + List getTrafficAllocation(); + Map getVariationKeyToVariationMap(); + Map getVariationIdToVariationMap(); + + default String serializeConditions(Map audiencesMap) { + Condition condition = this.getAudienceConditions(); + return condition instanceof EmptyCondition ? "" : this.serialize(condition, audiencesMap); + } + + default String getNameFromAudienceId(String audienceId, Map audiencesMap) { + StringBuilder audienceName = new StringBuilder(); + if (audiencesMap != null && audiencesMap.get(audienceId) != null) { + audienceName.append("\"" + audiencesMap.get(audienceId) + "\""); + } else { + audienceName.append("\"" + audienceId + "\""); + } + return audienceName.toString(); + } + + default String getOperandOrAudienceId(Condition condition, Map audiencesMap) { + if (condition != null) { + if (condition instanceof AudienceIdCondition) { + return this.getNameFromAudienceId(condition.getOperandOrId(), audiencesMap); + } else { + return condition.getOperandOrId(); + } + } else { + return ""; + } + } + + default String serialize(Condition condition, Map audiencesMap) { + StringBuilder stringBuilder = new StringBuilder(); + List conditions; + + String operand = this.getOperandOrAudienceId(condition, audiencesMap); + switch (operand){ + case (AND): + conditions = ((AndCondition) condition).getConditions(); + stringBuilder.append(this.getNameOrNextCondition(operand, conditions, audiencesMap)); + break; + case (OR): + conditions = ((OrCondition) condition).getConditions(); + stringBuilder.append(this.getNameOrNextCondition(operand, conditions, audiencesMap)); + break; + case (NOT): + stringBuilder.append(operand + " "); + Condition notCondition = ((NotCondition) condition).getCondition(); + if (notCondition instanceof AudienceIdCondition) { + stringBuilder.append(serialize(notCondition, audiencesMap)); + } else { + stringBuilder.append("(" + serialize(notCondition, audiencesMap) + ")"); + } + break; + default: + stringBuilder.append(operand); + break; + } + + return stringBuilder.toString(); + } + + default String getNameOrNextCondition(String operand, List conditions, Map audiencesMap) { + StringBuilder stringBuilder = new StringBuilder(); + int index = 0; + if (conditions.isEmpty()) { + return ""; + } else if (conditions.size() == 1) { + return serialize(conditions.get(0), audiencesMap); + } else { + for (Condition con : conditions) { + index++; + if (index + 1 <= conditions.size()) { + if (con instanceof AudienceIdCondition) { + String audienceName = this.getNameFromAudienceId(((AudienceIdCondition) con).getAudienceId(), + audiencesMap); + stringBuilder.append( audienceName + " "); + } else { + stringBuilder.append("(" + serialize(con, audiencesMap) + ") "); + } + stringBuilder.append(operand); + stringBuilder.append(" "); + } else { + if (con instanceof AudienceIdCondition) { + String audienceName = this.getNameFromAudienceId(((AudienceIdCondition) con).getAudienceId(), + audiencesMap); + stringBuilder.append(audienceName); + } else { + stringBuilder.append("(" + serialize(con, audiencesMap) + ")"); + } + } + } + } + return stringBuilder.toString(); + } +} \ No newline at end of file diff --git a/core-api/src/main/java/com/optimizely/ab/config/Holdout.java b/core-api/src/main/java/com/optimizely/ab/config/Holdout.java new file mode 100644 index 000000000..c757c072c --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/Holdout.java @@ -0,0 +1,173 @@ +/** + * + * Copyright 2016-2019, 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.config.audience.AudienceIdCondition; +import com.optimizely.ab.config.audience.Condition; + +@Immutable +@JsonIgnoreProperties(ignoreUnknown = true) +public class Holdout implements ExperimentCore { + + private final String id; + private final String key; + private final String status; + + private final List audienceIds; + private final Condition audienceConditions; + private final List variations; + private final List trafficAllocation; + private final List includedFlags; + private final List excludedFlags; + + private final Map variationKeyToVariationMap; + private final Map variationIdToVariationMap; + // Not necessary for HO + private final String layerId = ""; + + public enum HoldoutStatus { + RUNNING("Running"), + DRAFT("Draft"), + CONCLUDED("Concluded"), + ARCHIVED("Archived"); + + private final String holdoutStatus; + + HoldoutStatus(String holdoutStatus) { + this.holdoutStatus = holdoutStatus; + } + + public String toString() { + return holdoutStatus; + } + } + + @VisibleForTesting + public Holdout(String id, String key) { + this(id, key, "Running", Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList(), null, null); + } + + // Keep only this constructor and add @JsonCreator to it + @JsonCreator + public Holdout(@JsonProperty("id") @Nonnull String id, + @JsonProperty("key") @Nonnull String key, + @JsonProperty("status") @Nonnull String status, + @JsonProperty("audienceIds") @Nonnull List audienceIds, + @JsonProperty("audienceConditions") @Nullable Condition audienceConditions, + @JsonProperty("variations") @Nonnull List variations, + @JsonProperty("trafficAllocation") @Nonnull List trafficAllocation, + @JsonProperty("includedFlags") @Nullable List includedFlags, + @JsonProperty("excludedFlags") @Nullable List excludedFlags) { + this.id = id; + this.key = key; + this.status = status; + this.audienceIds = audienceIds; + this.audienceConditions = audienceConditions; + this.variations = variations; + this.trafficAllocation = trafficAllocation; + this.includedFlags = includedFlags == null ? Collections.emptyList() : Collections.unmodifiableList(includedFlags); + this.excludedFlags = excludedFlags == null ? Collections.emptyList() : Collections.unmodifiableList(excludedFlags); + this.variationKeyToVariationMap = ProjectConfigUtils.generateNameMapping(this.variations); + this.variationIdToVariationMap = ProjectConfigUtils.generateIdMapping(this.variations); + } + + public String getId() { + return id; + } + + public String getKey() { + return key; + } + + public String getStatus() { + return status; + } + + public String getLayerId() { + return layerId; + } + + public List getAudienceIds() { + return audienceIds; + } + + public Condition getAudienceConditions() { + return audienceConditions; + } + + public List getVariations() { + return variations; + } + + public Map getVariationKeyToVariationMap() { + return variationKeyToVariationMap; + } + + public Map getVariationIdToVariationMap() { + return variationIdToVariationMap; + } + + public List getTrafficAllocation() { + return trafficAllocation; + } + + public String getGroupId() { + return ""; + } + + public List getIncludedFlags() { + return includedFlags; + } + + public List getExcludedFlags() { + return excludedFlags; + } + + public boolean isActive() { + return status.equals(Holdout.HoldoutStatus.RUNNING.toString()); + } + + public boolean isRunning() { + return status.equals(Holdout.HoldoutStatus.RUNNING.toString()); + } + + @Override + public String toString() { + return "Holdout {" + + "id='" + id + '\'' + + ", key='" + key + '\'' + + ", status='" + status + '\'' + + ", audienceIds=" + audienceIds + + ", audienceConditions=" + audienceConditions + + ", variations=" + variations + + ", variationKeyToVariationMap=" + variationKeyToVariationMap + + ", trafficAllocation=" + trafficAllocation + + '}'; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/HoldoutConfig.java b/core-api/src/main/java/com/optimizely/ab/config/HoldoutConfig.java new file mode 100644 index 000000000..69635b1ae --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/HoldoutConfig.java @@ -0,0 +1,164 @@ +/** + * + * Copyright 2016-2019, 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.optimizely.ab.config; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * HoldoutConfig manages collections of Holdout objects and their relationships to flags. + */ +public class HoldoutConfig { + private List allHoldouts; + private List global; + private Map holdoutIdMap; + private Map> flagHoldoutsMap; + private Map> includedHoldouts; + private Map> excludedHoldouts; + + /** + * Initializes a new HoldoutConfig with an empty list of holdouts. + */ + public HoldoutConfig() { + this(Collections.emptyList()); + } + + /** + * Initializes a new HoldoutConfig with the specified holdouts. + * + * @param allHoldouts The list of holdouts to manage + */ + public HoldoutConfig(@Nonnull List allHoldouts) { + this.allHoldouts = new ArrayList<>(allHoldouts); + this.global = new ArrayList<>(); + this.holdoutIdMap = new HashMap<>(); + this.flagHoldoutsMap = new ConcurrentHashMap<>(); + this.includedHoldouts = new HashMap<>(); + this.excludedHoldouts = new HashMap<>(); + updateHoldoutMapping(); + } + + /** + * Updates internal mappings of holdouts including the id map, global list, + * and per-flag inclusion/exclusion maps. + */ + private void updateHoldoutMapping() { + holdoutIdMap.clear(); + for (Holdout holdout : allHoldouts) { + holdoutIdMap.put(holdout.getId(), holdout); + } + + flagHoldoutsMap.clear(); + global.clear(); + includedHoldouts.clear(); + excludedHoldouts.clear(); + + for (Holdout holdout : allHoldouts) { + boolean hasIncludedFlags = !holdout.getIncludedFlags().isEmpty(); + boolean hasExcludedFlags = !holdout.getExcludedFlags().isEmpty(); + + if (!hasIncludedFlags && !hasExcludedFlags) { + // Global holdout (applies to all flags) + global.add(holdout); + } else if (hasIncludedFlags) { + // Holdout only applies to specific included flags + for (String flagId : holdout.getIncludedFlags()) { + includedHoldouts.computeIfAbsent(flagId, k -> new ArrayList<>()).add(holdout); + } + } else { + // Global holdout with specific exclusions + global.add(holdout); + + for (String flagId : holdout.getExcludedFlags()) { + excludedHoldouts.computeIfAbsent(flagId, k -> new HashSet<>()).add(holdout); + } + } + } + } + + /** + * Returns the applicable holdouts for the given flag ID by combining global holdouts + * (excluding any specified) and included holdouts, in that order. + * Caches the result for future calls. + * + * @param id The flag identifier + * @return A list of Holdout objects relevant to the given flag + */ + public List getHoldoutForFlag(@Nonnull String id) { + if (allHoldouts.isEmpty()) { + return Collections.emptyList(); + } + + // Check cache and return persistent holdouts + if (flagHoldoutsMap.containsKey(id)) { + return flagHoldoutsMap.get(id); + } + + // Prioritize global holdouts first + List activeHoldouts = new ArrayList<>(); + Set excluded = excludedHoldouts.getOrDefault(id, Collections.emptySet()); + + if (!excluded.isEmpty()) { + for (Holdout holdout : global) { + if (!excluded.contains(holdout)) { + activeHoldouts.add(holdout); + } + } + } else { + activeHoldouts.addAll(global); + } + + // Add included holdouts + activeHoldouts.addAll(includedHoldouts.getOrDefault(id, Collections.emptyList())); + + // Cache the result + flagHoldoutsMap.put(id, activeHoldouts); + + return activeHoldouts; + } + + /** + * Get a Holdout object for an Id. + * + * @param id The holdout identifier + * @return The Holdout object if found, null otherwise + */ + @Nullable + public Holdout getHoldout(@Nonnull String id) { + return holdoutIdMap.get(id); + } + + /** + * Returns all holdouts managed by this config. + * + * @return An unmodifiable list of all holdouts + */ + public List getAllHoldouts() { + return Collections.unmodifiableList(allHoldouts); + } +} 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 2073be9ef..96a0c6488 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 @@ -16,15 +16,16 @@ */ package com.optimizely.ab.config; -import com.optimizely.ab.config.audience.Audience; -import com.optimizely.ab.error.ErrorHandler; +import java.util.List; +import java.util.Map; +import java.util.Set; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.util.List; -import java.util.Map; -import java.util.Set; + +import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.error.ErrorHandler; /** * ProjectConfig is an interface capturing the experiment, variation and feature definitions. @@ -70,6 +71,12 @@ Experiment getExperimentForKey(@Nonnull String experimentKey, List getExperiments(); + List getHoldouts(); + + List getHoldoutForFlag(@Nonnull String id); + + Holdout getHoldout(@Nonnull String id); + Set getAllSegments(); List getExperimentsForEventKey(String eventKey); 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 0bb1765c2..db1e3e7c8 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 @@ -42,7 +42,7 @@ public class Variation implements IdKeyMapped { private final Map variableIdToFeatureVariableUsageInstanceMap; public Variation(String id, String key) { - this(id, key, null); + this(id, key, false, null); } public Variation(String id, @@ -51,6 +51,13 @@ public Variation(String id, this(id, key, false, featureVariableUsageInstances); } + public Variation(String id, + String key, + Boolean featureEnabled) { + this(id, key, featureEnabled, null); + } + + @JsonCreator public Variation(@JsonProperty("id") String id, @JsonProperty("key") String key, diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java index f349805fa..499a5fc5c 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java @@ -51,6 +51,8 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa }.getType(); Type experimentsType = new TypeToken>() { }.getType(); + Type holdoutsType = new TypeToken>() { + }.getType(); Type attributesType = new TypeToken>() { }.getType(); Type eventsType = new TypeToken>() { @@ -64,6 +66,13 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa List experiments = context.deserialize(jsonObject.get("experiments").getAsJsonArray(), experimentsType); + List holdouts; + if (jsonObject.has("holdouts")) { + holdouts = context.deserialize(jsonObject.get("holdouts").getAsJsonArray(), holdoutsType); + } else { + holdouts = Collections.emptyList(); + } + List attributes; attributes = context.deserialize(jsonObject.get("attributes"), attributesType); @@ -127,6 +136,7 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa typedAudiences, events, experiments, + holdouts, featureFlags, groups, rollouts, diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java index 4ef104428..e38425cf4 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java @@ -46,6 +46,13 @@ public DatafileProjectConfig deserialize(JsonParser parser, DeserializationConte List attributes = JacksonHelpers.arrayNodeToList(node.get("attributes"), Attribute.class, codec); List events = JacksonHelpers.arrayNodeToList(node.get("events"), EventType.class, codec); + List holdouts; + if (node.has("holdouts")) { + holdouts = JacksonHelpers.arrayNodeToList(node.get("holdouts"), Holdout.class, codec); + } else { + holdouts = Collections.emptyList(); + } + List audiences = Collections.emptyList(); if (node.has("audiences")) { audiences = JacksonHelpers.arrayNodeToList(node.get("audiences"), Audience.class, codec); @@ -103,6 +110,7 @@ public DatafileProjectConfig deserialize(JsonParser parser, DeserializationConte (List) (List) typedAudiences, events, experiments, + holdouts, featureFlags, groups, rollouts, 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 972d76431..314f2dd23 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 @@ -16,14 +16,19 @@ */ package com.optimizely.ab.config.parser; +import javax.annotation.Nonnull; + import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.optimizely.ab.config.*; +import com.optimizely.ab.config.DatafileProjectConfig; +import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.Group; +import com.optimizely.ab.config.Holdout; +import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.audience.Audience; import com.optimizely.ab.config.audience.TypedAudience; -import javax.annotation.Nonnull; - /** * {@link Gson}-based config parser implementation. */ @@ -35,6 +40,7 @@ public GsonConfigParser() { .registerTypeAdapter(Audience.class, new AudienceGsonDeserializer()) .registerTypeAdapter(TypedAudience.class, new AudienceGsonDeserializer()) .registerTypeAdapter(Experiment.class, new ExperimentGsonDeserializer()) + .registerTypeAdapter(Holdout.class, new HoldoutGsonDeserializer()) .registerTypeAdapter(FeatureFlag.class, new FeatureFlagGsonDeserializer()) .registerTypeAdapter(Group.class, new GroupGsonDeserializer()) .registerTypeAdapter(DatafileProjectConfig.class, new DatafileGsonDeserializer()) 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 1399497b2..97cf5b521 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 @@ -25,7 +25,9 @@ import com.google.gson.reflect.TypeToken; import com.optimizely.ab.bucketing.DecisionService; import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.Holdout; import com.optimizely.ab.config.Experiment.ExperimentStatus; +import com.optimizely.ab.config.Holdout.HoldoutStatus; import com.optimizely.ab.config.FeatureFlag; import com.optimizely.ab.config.FeatureVariable; import com.optimizely.ab.config.FeatureVariableUsageInstance; @@ -151,6 +153,43 @@ static Experiment parseExperiment(JsonObject experimentJson, JsonDeserialization return parseExperiment(experimentJson, "", context); } + static Holdout parseHoldout(JsonObject holdoutJson, JsonDeserializationContext context) { + String id = holdoutJson.get("id").getAsString(); + String key = holdoutJson.get("key").getAsString(); + String status = holdoutJson.get("status").getAsString(); + + JsonArray audienceIdsJson = holdoutJson.getAsJsonArray("audienceIds"); + List audienceIds = new ArrayList<>(audienceIdsJson.size()); + for (JsonElement audienceIdObj : audienceIdsJson) { + audienceIds.add(audienceIdObj.getAsString()); + } + + Condition conditions = parseAudienceConditions(holdoutJson); + + // parse the child objects + List variations = parseVariations(holdoutJson.getAsJsonArray("variations"), context); + List trafficAllocations = + parseTrafficAllocation(holdoutJson.getAsJsonArray("trafficAllocation")); + + List includedFlags = new ArrayList<>(); + if (holdoutJson.has("includedFlags")) { + JsonArray includedIdsJson = holdoutJson.getAsJsonArray("includedFlags"); + for (JsonElement hoIdObj : includedIdsJson) { + includedFlags.add(hoIdObj.getAsString()); + } + } + + List excludedFlags = new ArrayList<>(); + if (holdoutJson.has("excludedFlags")) { + JsonArray excludedIdsJson = holdoutJson.getAsJsonArray("excludedFlags"); + for (JsonElement hoIdObj : excludedIdsJson) { + excludedFlags.add(hoIdObj.getAsString()); + } + } + + return new Holdout(id, key, status, audienceIds, conditions, variations, trafficAllocations, includedFlags, excludedFlags); + } + static FeatureFlag parseFeatureFlag(JsonObject featureFlagJson, JsonDeserializationContext context) { String id = featureFlagJson.get("id").getAsString(); String key = featureFlagJson.get("key").getAsString(); diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/HoldoutGsonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/HoldoutGsonDeserializer.java new file mode 100644 index 000000000..f64f355d4 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/HoldoutGsonDeserializer.java @@ -0,0 +1,38 @@ +/** + * + * Copyright 2016-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 java.lang.reflect.Type; + +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.Holdout; + +final class HoldoutGsonDeserializer implements JsonDeserializer { + + @Override + public Holdout deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + + JsonObject jsonObject = json.getAsJsonObject(); + + return GsonHelpers.parseHoldout(jsonObject, context); + } +} 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 ea5101054..4582e4749 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 @@ -48,6 +48,13 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse List experiments = parseExperiments(rootObject.getJSONArray("experiments")); + List holdouts; + if (rootObject.has("holdouts")) { + holdouts = parseHoldouts(rootObject.getJSONArray("holdouts")); + } else { + holdouts = Collections.emptyList(); + } + List attributes; attributes = parseAttributes(rootObject.getJSONArray("attributes")); @@ -108,6 +115,7 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse typedAudiences, events, experiments, + holdouts, featureFlags, groups, rollouts, @@ -165,6 +173,69 @@ private List parseExperiments(JSONArray experimentJson, String group return experiments; } + + private List parseHoldouts(JSONArray holdoutJson) { + List holdouts = new ArrayList(holdoutJson.length()); + + for (int i = 0; i < holdoutJson.length(); i++) { + Object obj = holdoutJson.get(i); + JSONObject holdoutObject = (JSONObject) obj; + String id = holdoutObject.getString("id"); + String key = holdoutObject.getString("key"); + String status = holdoutObject.getString("status"); + + JSONArray audienceIdsJson = holdoutObject.getJSONArray("audienceIds"); + List audienceIds = new ArrayList(audienceIdsJson.length()); + + for (int j = 0; j < audienceIdsJson.length(); j++) { + Object audienceIdObj = audienceIdsJson.get(j); + audienceIds.add((String) audienceIdObj); + } + + Condition conditions = null; + if (holdoutObject.has("audienceConditions")) { + Object jsonCondition = holdoutObject.get("audienceConditions"); + conditions = ConditionUtils.parseConditions(AudienceIdCondition.class, jsonCondition); + } + + // parse the child objects + List variations = parseVariations(holdoutObject.getJSONArray("variations")); + + List trafficAllocations = + parseTrafficAllocation(holdoutObject.getJSONArray("trafficAllocation")); + + List includedFlags; + if (holdoutObject.has("includedFlags")) { + JSONArray includedIdsJson = holdoutObject.getJSONArray("includedFlags"); + includedFlags = new ArrayList<>(includedIdsJson.length()); + + for (int j = 0; j < includedIdsJson.length(); j++) { + Object idObj = includedIdsJson.get(j); + includedFlags.add((String) idObj); + } + } else { + includedFlags = Collections.emptyList(); + } + + List excludedFlags; + if (holdoutObject.has("excludedFlags")) { + JSONArray excludedIdsJson = holdoutObject.getJSONArray("excludedFlags"); + excludedFlags = new ArrayList<>(excludedIdsJson.length()); + + for (int j = 0; j < excludedIdsJson.length(); j++) { + Object idObj = excludedIdsJson.get(j); + excludedFlags.add((String) idObj); + } + } else { + excludedFlags = Collections.emptyList(); + } + + holdouts.add(new Holdout(id, key, status, audienceIds, conditions, variations, + trafficAllocations, includedFlags, excludedFlags)); + } + + return holdouts; + } private List parseExperimentIds(JSONArray experimentIdsJson) { ArrayList experimentIds = new ArrayList(experimentIdsJson.length()); 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 c65eb6213..b9a170880 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 @@ -57,6 +57,13 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse List experiments = parseExperiments((JSONArray) rootObject.get("experiments")); + List holdouts; + if (rootObject.containsKey("holdouts")) { + holdouts = parseHoldouts((JSONArray) rootObject.get("holdouts")); + } else { + holdouts = Collections.emptyList(); + } + List attributes; attributes = parseAttributes((JSONArray) rootObject.get("attributes")); @@ -111,6 +118,7 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse typedAudiences, events, experiments, + holdouts, featureFlags, groups, rollouts, @@ -173,6 +181,59 @@ private List parseExperiments(JSONArray experimentJson, String group return experiments; } + private List parseHoldouts(JSONArray holdoutJson) { + List holdouts = new ArrayList(holdoutJson.size()); + + for (Object obj : holdoutJson) { + JSONObject hoObject = (JSONObject) obj; + String id = (String) hoObject.get("id"); + String key = (String) hoObject.get("key"); + String status = (String) hoObject.get("status"); + + JSONArray audienceIdsJson = (JSONArray) hoObject.get("audienceIds"); + List audienceIds = new ArrayList(audienceIdsJson.size()); + + for (Object audienceIdObj : audienceIdsJson) { + audienceIds.add((String) audienceIdObj); + } + + Condition conditions = null; + if (hoObject.containsKey("audienceConditions")) { + Object jsonCondition = hoObject.get("audienceConditions"); + try { + conditions = ConditionUtils.parseConditions(AudienceIdCondition.class, jsonCondition); + } catch (Exception e) { + // unable to parse conditions. + Logger.getAnonymousLogger().log(Level.ALL, "problem parsing audience conditions", e); + } + } + // parse the child objects + List variations = parseVariations((JSONArray) hoObject.get("variations")); + + List trafficAllocations = + parseTrafficAllocation((JSONArray) hoObject.get("trafficAllocation")); + + List includedFlags; + if (hoObject.containsKey("includedFlags")) { + includedFlags = new ArrayList((JSONArray) hoObject.get("includedFlags")); + } else { + includedFlags = Collections.emptyList(); + } + + List excludedFlags; + if (hoObject.containsKey("excludedFlags")) { + excludedFlags = new ArrayList((JSONArray) hoObject.get("excludedFlags")); + } else { + excludedFlags = Collections.emptyList(); + } + + holdouts.add(new Holdout(id, key, status, audienceIds, conditions, variations, + trafficAllocations, includedFlags, excludedFlags)); + } + + return holdouts; + } + private List parseExperimentIds(JSONArray experimentIdsJsonArray) { List experimentIds = new ArrayList(experimentIdsJsonArray.size()); diff --git a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java index 9b65421bb..ef9a8ccc2 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java +++ b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java @@ -16,34 +16,35 @@ */ package com.optimizely.ab.config; -import com.google.common.base.Charsets; -import com.google.common.io.Resources; -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 javax.annotation.CheckForNull; -import javax.annotation.Nonnull; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import static java.util.Arrays.asList; import java.util.Collections; +import static java.util.Collections.singletonList; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; -import static java.util.Arrays.asList; -import static java.util.Collections.singletonList; +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; + 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; +import com.google.common.base.Charsets; +import com.google.common.io.Resources; +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; + /** * Helper class that provides common functionality and resources for testing {@link DatafileProjectConfig}. */ @@ -382,11 +383,16 @@ private static ProjectConfig generateNoAudienceProjectConfigV3() { } private static final ProjectConfig VALID_PROJECT_CONFIG_V4 = generateValidProjectConfigV4(); + private static final ProjectConfig VALID_PROJECT_CONFIG_V4_HOLDOUT = generateValidProjectConfigV4_holdout(); private static ProjectConfig generateValidProjectConfigV4() { return ValidProjectConfigV4.generateValidProjectConfigV4(); } + private static ProjectConfig generateValidProjectConfigV4_holdout() { + return ValidProjectConfigV4.generateValidProjectConfigV4_holdout(); + } + private DatafileProjectConfigTestUtils() { } @@ -410,6 +416,10 @@ public static String validConfigJsonV4() throws IOException { return Resources.toString(Resources.getResource("config/valid-project-config-v4.json"), Charsets.UTF_8); } + public static String validConfigHoldoutJsonV4() throws IOException { + return Resources.toString(Resources.getResource("config/holdouts-project-config.json"), Charsets.UTF_8); + } + public static String nullFeatureEnabledConfigJsonV4() throws IOException { return Resources.toString(Resources.getResource("config/null-featureEnabled-config-v4.json"), Charsets.UTF_8); } @@ -446,6 +456,10 @@ public static ProjectConfig validProjectConfigV4() { return VALID_PROJECT_CONFIG_V4; } + public static ProjectConfig validProjectConfigV4_holdout() { + return VALID_PROJECT_CONFIG_V4_HOLDOUT; + } + /** * @return the expected {@link DatafileProjectConfig} for the json produced by {@link #invalidProjectConfigV5()} */ @@ -471,6 +485,7 @@ public static void verifyProjectConfig(@CheckForNull ProjectConfig actual, @Nonn verifyAudiences(actual.getTypedAudiences(), expected.getTypedAudiences()); verifyEvents(actual.getEventTypes(), expected.getEventTypes()); verifyExperiments(actual.getExperiments(), expected.getExperiments()); + verifyHoldouts(actual.getHoldouts(), expected.getHoldouts()); verifyFeatureFlags(actual.getFeatureFlags(), expected.getFeatureFlags()); verifyGroups(actual.getGroups(), expected.getGroups()); verifyRollouts(actual.getRollouts(), expected.getRollouts()); @@ -502,6 +517,37 @@ private static void verifyExperiments(List actual, List } } + private static void verifyHoldouts(List actual, List expected) { + // print the holdouts for debugging BEFORE assertion + // System.out.println("Actual holdouts: " + actual); + // System.out.println("Expected holdouts: " + expected); + // System.out.println("Actual size: " + actual.size()); + // System.out.println("Expected size: " + expected.size()); + + assertThat(actual.size(), is(expected.size())); + + + for (int i = 0; i < actual.size(); i++) { + Holdout actualHoldout = actual.get(i); + Holdout expectedHoldout = expected.get(i); + + assertThat(actualHoldout.getId(), is(expectedHoldout.getId())); + assertThat(actualHoldout.getKey(), is(expectedHoldout.getKey())); + assertThat(actualHoldout.getGroupId(), is(expectedHoldout.getGroupId())); + assertThat(actualHoldout.getStatus(), is(expectedHoldout.getStatus())); + assertThat(actualHoldout.getAudienceIds(), is(expectedHoldout.getAudienceIds())); + /// debug print audience conditions + // System.out.println("Actual audience conditions: " + actualHoldout.getAudienceConditions()); + // System.out.println("Expected audience conditions: " + expectedHoldout.getAudienceConditions()); + assertThat(actualHoldout.getAudienceConditions(), is(expectedHoldout.getAudienceConditions())); + assertThat(actualHoldout.getIncludedFlags(), is(expectedHoldout.getIncludedFlags())); + assertThat(actualHoldout.getExcludedFlags(), is(expectedHoldout.getExcludedFlags())); + verifyVariations(actualHoldout.getVariations(), expectedHoldout.getVariations()); + verifyTrafficAllocations(actualHoldout.getTrafficAllocation(), + expectedHoldout.getTrafficAllocation()); + } + } + private static void verifyFeatureFlags(List actual, List expected) { assertEquals(expected.size(), actual.size()); for (int i = 0; i < actual.size(); i++) { diff --git a/core-api/src/test/java/com/optimizely/ab/config/HoldoutConfigTest.java b/core-api/src/test/java/com/optimizely/ab/config/HoldoutConfigTest.java new file mode 100644 index 000000000..5c0b2fef1 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/HoldoutConfigTest.java @@ -0,0 +1,233 @@ +/** + * + * Copyright 2016-2019, 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import org.junit.Before; +import org.junit.Test; + +public class HoldoutConfigTest { + + private Holdout globalHoldout; + private Holdout includedHoldout; + private Holdout excludedHoldout; + private Holdout mixedHoldout; + + @Before + public void setUp() { + // Global holdout (no included/excluded flags) + globalHoldout = new Holdout("global1", "global_holdout"); + + // Holdout with included flags + includedHoldout = new Holdout("included1", "included_holdout", "Running", + Collections.emptyList(), null, Collections.emptyList(), + Collections.emptyList(), Arrays.asList("flag1", "flag2"), null); + + // Global holdout with excluded flags + excludedHoldout = new Holdout("excluded1", "excluded_holdout", "Running", + Collections.emptyList(), null, Collections.emptyList(), + Collections.emptyList(), null, Arrays.asList("flag3")); + + // Another global holdout for testing + mixedHoldout = new Holdout("mixed1", "mixed_holdout"); + } + + @Test + public void testEmptyConstructor() { + HoldoutConfig config = new HoldoutConfig(); + + assertTrue(config.getAllHoldouts().isEmpty()); + assertTrue(config.getHoldoutForFlag("any_flag").isEmpty()); + assertNull(config.getHoldout("any_id")); + } + + @Test + public void testConstructorWithEmptyList() { + HoldoutConfig config = new HoldoutConfig(Collections.emptyList()); + + assertTrue(config.getAllHoldouts().isEmpty()); + assertTrue(config.getHoldoutForFlag("any_flag").isEmpty()); + assertNull(config.getHoldout("any_id")); + } + + @Test + public void testConstructorWithGlobalHoldouts() { + List holdouts = Arrays.asList(globalHoldout, mixedHoldout); + HoldoutConfig config = new HoldoutConfig(holdouts); + + assertEquals(2, config.getAllHoldouts().size()); + assertTrue(config.getAllHoldouts().contains(globalHoldout)); + } + + @Test + public void testGetHoldout() { + List holdouts = Arrays.asList(globalHoldout, includedHoldout); + HoldoutConfig config = new HoldoutConfig(holdouts); + + assertEquals(globalHoldout, config.getHoldout("global1")); + assertEquals(includedHoldout, config.getHoldout("included1")); + assertNull(config.getHoldout("nonexistent")); + } + + @Test + public void testGetHoldoutForFlagWithGlobalHoldouts() { + List holdouts = Arrays.asList(globalHoldout, mixedHoldout); + HoldoutConfig config = new HoldoutConfig(holdouts); + + List flagHoldouts = config.getHoldoutForFlag("any_flag"); + assertEquals(2, flagHoldouts.size()); + assertTrue(flagHoldouts.contains(globalHoldout)); + assertTrue(flagHoldouts.contains(mixedHoldout)); + } + + @Test + public void testGetHoldoutForFlagWithIncludedHoldouts() { + List holdouts = Arrays.asList(globalHoldout, includedHoldout); + HoldoutConfig config = new HoldoutConfig(holdouts); + + // Flag included in holdout + List flag1Holdouts = config.getHoldoutForFlag("flag1"); + assertEquals(2, flag1Holdouts.size()); + assertTrue(flag1Holdouts.contains(globalHoldout)); // Global first + assertTrue(flag1Holdouts.contains(includedHoldout)); // Included second + + List flag2Holdouts = config.getHoldoutForFlag("flag2"); + assertEquals(2, flag2Holdouts.size()); + assertTrue(flag2Holdouts.contains(globalHoldout)); + assertTrue(flag2Holdouts.contains(includedHoldout)); + + // Flag not included in holdout + List flag3Holdouts = config.getHoldoutForFlag("flag3"); + assertEquals(1, flag3Holdouts.size()); + assertTrue(flag3Holdouts.contains(globalHoldout)); // Only global + } + + @Test + public void testGetHoldoutForFlagWithExcludedHoldouts() { + List holdouts = Arrays.asList(globalHoldout, excludedHoldout); + HoldoutConfig config = new HoldoutConfig(holdouts); + + // Flag excluded from holdout + List flag3Holdouts = config.getHoldoutForFlag("flag3"); + assertEquals(1, flag3Holdouts.size()); + assertTrue(flag3Holdouts.contains(globalHoldout)); // excludedHoldout should be filtered out + + // Flag not excluded + List flag1Holdouts = config.getHoldoutForFlag("flag1"); + assertEquals(2, flag1Holdouts.size()); + assertTrue(flag1Holdouts.contains(globalHoldout)); + assertTrue(flag1Holdouts.contains(excludedHoldout)); + } + + @Test + public void testGetHoldoutForFlagWithMixedHoldouts() { + List holdouts = Arrays.asList(globalHoldout, includedHoldout, excludedHoldout); + HoldoutConfig config = new HoldoutConfig(holdouts); + + // flag1 is included in includedHoldout + List flag1Holdouts = config.getHoldoutForFlag("flag1"); + assertEquals(3, flag1Holdouts.size()); + assertTrue(flag1Holdouts.contains(globalHoldout)); + assertTrue(flag1Holdouts.contains(excludedHoldout)); + assertTrue(flag1Holdouts.contains(includedHoldout)); + + // flag3 is excluded from excludedHoldout + List flag3Holdouts = config.getHoldoutForFlag("flag3"); + assertEquals(1, flag3Holdouts.size()); + assertTrue(flag3Holdouts.contains(globalHoldout)); // Only global, excludedHoldout filtered out + + // flag4 has no specific inclusion/exclusion + List flag4Holdouts = config.getHoldoutForFlag("flag4"); + assertEquals(2, flag4Holdouts.size()); + assertTrue(flag4Holdouts.contains(globalHoldout)); + assertTrue(flag4Holdouts.contains(excludedHoldout)); + } + + @Test + public void testCachingBehavior() { + List holdouts = Arrays.asList(globalHoldout, includedHoldout); + HoldoutConfig config = new HoldoutConfig(holdouts); + + // First call + List firstCall = config.getHoldoutForFlag("flag1"); + // Second call should return cached result (same object reference) + List secondCall = config.getHoldoutForFlag("flag1"); + + assertSame(firstCall, secondCall); + assertEquals(2, firstCall.size()); + } + + @Test + public void testGetAllHoldoutsIsUnmodifiable() { + List holdouts = Arrays.asList(globalHoldout, includedHoldout); + HoldoutConfig config = new HoldoutConfig(holdouts); + + List allHoldouts = config.getAllHoldouts(); + + try { + allHoldouts.add(mixedHoldout); + fail("Should throw UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // Expected + } + } + + @Test + public void testEmptyFlagHoldouts() { + HoldoutConfig config = new HoldoutConfig(); + + List flagHoldouts = config.getHoldoutForFlag("any_flag"); + assertTrue(flagHoldouts.isEmpty()); + + // Should return same empty list for subsequent calls (caching) + List secondCall = config.getHoldoutForFlag("any_flag"); + assertSame(flagHoldouts, secondCall); + } + + @Test + public void testHoldoutWithBothIncludedAndExcluded() { + // Create a holdout with both included and excluded flags (included takes precedence) + Holdout bothHoldout = new Holdout("both1", "both_holdout", "Running", + Collections.emptyList(), null, Collections.emptyList(), + Collections.emptyList(), Arrays.asList("flag1"), Arrays.asList("flag2")); + + List holdouts = Arrays.asList(globalHoldout, bothHoldout); + HoldoutConfig config = new HoldoutConfig(holdouts); + + // flag1 should include bothHoldout (included takes precedence) + List flag1Holdouts = config.getHoldoutForFlag("flag1"); + assertEquals(2, flag1Holdouts.size()); + assertTrue(flag1Holdouts.contains(globalHoldout)); + assertTrue(flag1Holdouts.contains(bothHoldout)); + + // flag2 should not include bothHoldout (not in included list) + List flag2Holdouts = config.getHoldoutForFlag("flag2"); + assertEquals(1, flag2Holdouts.size()); + assertTrue(flag2Holdouts.contains(globalHoldout)); + assertFalse(flag2Holdouts.contains(bothHoldout)); + } + +} \ No newline at end of file diff --git a/core-api/src/test/java/com/optimizely/ab/config/HoldoutTest.java b/core-api/src/test/java/com/optimizely/ab/config/HoldoutTest.java new file mode 100644 index 000000000..f61925137 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/HoldoutTest.java @@ -0,0 +1,211 @@ +/** + * + * Copyright 2025, 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 java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +import com.optimizely.ab.config.audience.AndCondition; +import com.optimizely.ab.config.audience.AudienceIdCondition; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.EmptyCondition; +import com.optimizely.ab.config.audience.NotCondition; +import com.optimizely.ab.config.audience.OrCondition; + +public class HoldoutTest { + + @Test + public void testStringifyConditionScenarios() { + List audienceConditionsScenarios = getAudienceConditionsList(); + Map expectedScenarioStringsMap = getExpectedScenariosMap(); + Map audiencesMap = new HashMap<>(); + audiencesMap.put("1", "us"); + audiencesMap.put("2", "female"); + audiencesMap.put("3", "adult"); + audiencesMap.put("11", "fr"); + audiencesMap.put("12", "male"); + audiencesMap.put("13", "kid"); + + if (expectedScenarioStringsMap.size() == audienceConditionsScenarios.size()) { + for (int i = 0; i < audienceConditionsScenarios.size() - 1; i++) { + Holdout holdout = makeMockHoldoutWithStatus(Holdout.HoldoutStatus.RUNNING, + audienceConditionsScenarios.get(i)); + String audiences = holdout.serializeConditions(audiencesMap); + assertEquals(expectedScenarioStringsMap.get(i+1), audiences); + } + } + } + + public Map getExpectedScenariosMap() { + Map expectedScenarioStringsMap = new HashMap<>(); + expectedScenarioStringsMap.put(1, ""); + expectedScenarioStringsMap.put(2, "\"us\" OR \"female\""); + expectedScenarioStringsMap.put(3, "\"us\" AND \"female\" AND \"adult\""); + expectedScenarioStringsMap.put(4, "NOT \"us\""); + expectedScenarioStringsMap.put(5, "\"us\""); + expectedScenarioStringsMap.put(6, "\"us\""); + expectedScenarioStringsMap.put(7, "\"us\""); + expectedScenarioStringsMap.put(8, "\"us\" OR \"female\""); + expectedScenarioStringsMap.put(9, "(\"us\" OR \"female\") AND \"adult\""); + expectedScenarioStringsMap.put(10, "(\"us\" OR (\"female\" AND \"adult\")) AND (\"fr\" AND (\"male\" OR \"kid\"))"); + expectedScenarioStringsMap.put(11, "NOT (\"us\" AND \"female\")"); + expectedScenarioStringsMap.put(12, "\"us\" OR \"100000\""); + expectedScenarioStringsMap.put(13, ""); + + return expectedScenarioStringsMap; + } + + public List getAudienceConditionsList() { + AudienceIdCondition one = new AudienceIdCondition("1"); + AudienceIdCondition two = new AudienceIdCondition("2"); + AudienceIdCondition three = new AudienceIdCondition("3"); + AudienceIdCondition eleven = new AudienceIdCondition("11"); + AudienceIdCondition twelve = new AudienceIdCondition("12"); + AudienceIdCondition thirteen = new AudienceIdCondition("13"); + + // Scenario 1 - [] + EmptyCondition scenario1 = new EmptyCondition(); + + // Scenario 2 - ["or", "1", "2"] + List scenario2List = new ArrayList<>(); + scenario2List.add(one); + scenario2List.add(two); + OrCondition scenario2 = new OrCondition(scenario2List); + + // Scenario 3 - ["and", "1", "2", "3"] + List scenario3List = new ArrayList<>(); + scenario3List.add(one); + scenario3List.add(two); + scenario3List.add(three); + AndCondition scenario3 = new AndCondition(scenario3List); + + // Scenario 4 - ["not", "1"] + NotCondition scenario4 = new NotCondition(one); + + // Scenario 5 - ["or", "1"] + List scenario5List = new ArrayList<>(); + scenario5List.add(one); + OrCondition scenario5 = new OrCondition(scenario5List); + + // Scenario 6 - ["and", "1"] + List scenario6List = new ArrayList<>(); + scenario6List.add(one); + AndCondition scenario6 = new AndCondition(scenario6List); + + // Scenario 7 - ["1"] + AudienceIdCondition scenario7 = one; + + // Scenario 8 - ["1", "2"] + // Defaults to Or in Datafile Parsing resulting in an OrCondition + OrCondition scenario8 = scenario2; + + // Scenario 9 - ["and", ["or", "1", "2"], "3"] + List Scenario9List = new ArrayList<>(); + Scenario9List.add(scenario2); + Scenario9List.add(three); + AndCondition scenario9 = new AndCondition(Scenario9List); + + // Scenario 10 - ["and", ["or", "1", ["and", "2", "3"]], ["and", "11, ["or", "12", "13"]]] + List scenario10List = new ArrayList<>(); + + List or1213List = new ArrayList<>(); + or1213List.add(twelve); + or1213List.add(thirteen); + OrCondition or1213 = new OrCondition(or1213List); + + List and11Or1213List = new ArrayList<>(); + and11Or1213List.add(eleven); + and11Or1213List.add(or1213); + AndCondition and11Or1213 = new AndCondition(and11Or1213List); + + List and23List = new ArrayList<>(); + and23List.add(two); + and23List.add(three); + AndCondition and23 = new AndCondition(and23List); + + List or1And23List = new ArrayList<>(); + or1And23List.add(one); + or1And23List.add(and23); + OrCondition or1And23 = new OrCondition(or1And23List); + + scenario10List.add(or1And23); + scenario10List.add(and11Or1213); + AndCondition scenario10 = new AndCondition(scenario10List); + + // Scenario 11 - ["not", ["and", "1", "2"]] + List and12List = new ArrayList<>(); + and12List.add(one); + and12List.add(two); + AndCondition and12 = new AndCondition(and12List); + + NotCondition scenario11 = new NotCondition(and12); + + // Scenario 12 - ["or", "1", "100000"] + List scenario12List = new ArrayList<>(); + scenario12List.add(one); + AudienceIdCondition unknownAudience = new AudienceIdCondition("100000"); + scenario12List.add(unknownAudience); + + OrCondition scenario12 = new OrCondition(scenario12List); + + // Scenario 13 - ["and", ["and", invalidAudienceIdCondition]] which becomes + // the scenario of ["and", "and"] and results in empty string. + AudienceIdCondition invalidAudience = new AudienceIdCondition("5"); + List invalidIdList = new ArrayList<>(); + invalidIdList.add(invalidAudience); + AndCondition andCondition = new AndCondition(invalidIdList); + List andInvalidAudienceId = new ArrayList<>(); + andInvalidAudienceId.add(andCondition); + AndCondition scenario13 = new AndCondition(andInvalidAudienceId); + + List conditionTestScenarios = new ArrayList<>(); + conditionTestScenarios.add(scenario1); + conditionTestScenarios.add(scenario2); + conditionTestScenarios.add(scenario3); + conditionTestScenarios.add(scenario4); + conditionTestScenarios.add(scenario5); + conditionTestScenarios.add(scenario6); + conditionTestScenarios.add(scenario7); + conditionTestScenarios.add(scenario8); + conditionTestScenarios.add(scenario9); + conditionTestScenarios.add(scenario10); + conditionTestScenarios.add(scenario11); + conditionTestScenarios.add(scenario12); + conditionTestScenarios.add(scenario13); + + return conditionTestScenarios; + } + + private Holdout makeMockHoldoutWithStatus(Holdout.HoldoutStatus status, Condition audienceConditions) { + return new Holdout("12345", + "mockHoldoutKey", + status.toString(), + Collections.emptyList(), + audienceConditions, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList() + ); + } +} 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 faacfda76..a59721d4f 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 @@ -20,6 +20,7 @@ import com.optimizely.ab.config.audience.Audience; import com.optimizely.ab.config.audience.AudienceIdCondition; import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.EmptyCondition; import com.optimizely.ab.config.audience.OrCondition; import com.optimizely.ab.config.audience.UserAttribute; @@ -488,6 +489,11 @@ public class ValidProjectConfigV4 { VARIATION_BASIC_EXPERIMENT_VARIATION_A_KEY, Collections.emptyList() ); + private static final Variation VARIATION_HOLDOUT_VARIATION_OFF = new Variation( + "$opt_dummy_variation_id", + "ho_off_key", + false + ); 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( @@ -529,6 +535,113 @@ public class ValidProjectConfigV4 { ) ) ); + private static final Holdout HOLDOUT_BASIC_HOLDOUT = new Holdout( + "10075323428", + "basic_holdout", + Holdout.HoldoutStatus.RUNNING.toString(), + Collections.emptyList(), + null, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_HOLDOUT_VARIATION_OFF + ), + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + "327323", + 500 + ) + ), + null, + null + ); + + private static final Holdout HOLDOUT_ZERO_TRAFFIC_HOLDOUT = new Holdout( + "1007532345428", + "holdout_zero_traffic", + Holdout.HoldoutStatus.RUNNING.toString(), + Collections.emptyList(), + null, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_HOLDOUT_VARIATION_OFF + ), + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + "327323", + 0 + ) + ), + null, + null + ); + + private static final Holdout HOLDOUT_INCLUDED_FLAGS_HOLDOUT = new Holdout( + "1007543323427", + "holdout_included_flags", + Holdout.HoldoutStatus.RUNNING.toString(), + Collections.emptyList(), + null, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_HOLDOUT_VARIATION_OFF + ), + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + "327323", + 2000 + ) + ), + DatafileProjectConfigTestUtils.createListOfObjects( + "4195505407", + "3926744821", + "3281420120" + ), + null + ); + + private static final Holdout HOLDOUT_EXCLUDED_FLAGS_HOLDOUT = new Holdout( + "100753234214", + "holdout_excluded_flags", + Holdout.HoldoutStatus.RUNNING.toString(), + Collections.emptyList(), + null, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_HOLDOUT_VARIATION_OFF + ), + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + "327323", + 1500 + ) + ), + null, + DatafileProjectConfigTestUtils.createListOfObjects( + "2591051011", + "2079378557", + "3263342226" + ) + ); + + private static final Holdout HOLDOUT_TYPEDAUDIENCE_HOLDOUT = new Holdout( + "10075323429", + "typed_audience_holdout", + Holdout.HoldoutStatus.RUNNING.toString(), + DatafileProjectConfigTestUtils.createListOfObjects( + AUDIENCE_BOOL_ID, + AUDIENCE_INT_ID, + AUDIENCE_INT_EXACT_ID, + AUDIENCE_DOUBLE_ID + ), + AUDIENCE_COMBINATION, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_HOLDOUT_VARIATION_OFF + ), + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + "327323", + 1000 + ) + ), + Collections.emptyList(), + Collections.emptyList() + ); private static final String LAYER_TYPEDAUDIENCE_EXPERIMENT_ID = "1630555627"; private static final String EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_ID = "1323241597"; public static final String EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_KEY = "typed_audience_experiment"; @@ -1461,6 +1574,109 @@ public static ProjectConfig generateValidProjectConfigV4() { typedAudiences, events, experiments, + null, + featureFlags, + groups, + rollouts, + integrations + ); + } + + public static ProjectConfig generateValidProjectConfigV4_holdout() { + + // list attributes + List attributes = new ArrayList(); + attributes.add(ATTRIBUTE_HOUSE); + attributes.add(ATTRIBUTE_NATIONALITY); + attributes.add(ATTRIBUTE_OPT); + attributes.add(ATTRIBUTE_BOOLEAN); + attributes.add(ATTRIBUTE_INTEGER); + attributes.add(ATTRIBUTE_DOUBLE); + attributes.add(ATTRIBUTE_EMPTY); + + // list audiences + List audiences = new ArrayList(); + audiences.add(AUDIENCE_GRYFFINDOR); + audiences.add(AUDIENCE_SLYTHERIN); + audiences.add(AUDIENCE_ENGLISH_CITIZENS); + audiences.add(AUDIENCE_WITH_MISSING_VALUE); + + List typedAudiences = new ArrayList(); + typedAudiences.add(TYPED_AUDIENCE_BOOL); + typedAudiences.add(TYPED_AUDIENCE_EXACT_INT); + typedAudiences.add(TYPED_AUDIENCE_INT); + typedAudiences.add(TYPED_AUDIENCE_DOUBLE); + typedAudiences.add(TYPED_AUDIENCE_GRYFFINDOR); + typedAudiences.add(TYPED_AUDIENCE_SLYTHERIN); + typedAudiences.add(TYPED_AUDIENCE_ENGLISH_CITIZENS); + typedAudiences.add(AUDIENCE_WITH_MISSING_VALUE); + + // 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_TYPEDAUDIENCE_EXPERIMENT); + experiments.add(EXPERIMENT_TYPEDAUDIENCE_WITH_AND_EXPERIMENT); + experiments.add(EXPERIMENT_TYPEDAUDIENCE_LEAF_EXPERIMENT); + experiments.add(EXPERIMENT_MULTIVARIATE_EXPERIMENT); + experiments.add(EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT); + experiments.add(EXPERIMENT_PAUSED_EXPERIMENT); + experiments.add(EXPERIMENT_LAUNCHED_EXPERIMENT); + experiments.add(EXPERIMENT_WITH_MALFORMED_AUDIENCE); + + // list holdouts + List holdouts = new ArrayList(); + holdouts.add(HOLDOUT_ZERO_TRAFFIC_HOLDOUT); + holdouts.add(HOLDOUT_INCLUDED_FLAGS_HOLDOUT); + holdouts.add(HOLDOUT_BASIC_HOLDOUT); + holdouts.add(HOLDOUT_TYPEDAUDIENCE_HOLDOUT); + holdouts.add(HOLDOUT_EXCLUDED_FLAGS_HOLDOUT); + + // list featureFlags + List featureFlags = new ArrayList(); + featureFlags.add(FEATURE_FLAG_BOOLEAN_FEATURE); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_INTEGER); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_BOOLEAN); + featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_STRING); + featureFlags.add(FEATURE_FLAG_MULTI_VARIATE_FEATURE); + featureFlags.add(FEATURE_FLAG_MULTI_VARIATE_FUTURE_FEATURE); + featureFlags.add(FEATURE_FLAG_MUTEX_GROUP_FEATURE); + + List groups = new ArrayList(); + groups.add(GROUP_1); + groups.add(GROUP_2); + + // list rollouts + List rollouts = new ArrayList(); + rollouts.add(ROLLOUT_1); + rollouts.add(ROLLOUT_2); + rollouts.add(ROLLOUT_3); + + List integrations = new ArrayList<>(); + integrations.add(odpIntegration); + + return new DatafileProjectConfig( + ACCOUNT_ID, + ANONYMIZE_IP, + SEND_FLAG_DECISIONS, + BOT_FILTERING, + PROJECT_ID, + REVISION, + SDK_KEY, + ENVIRONMENT_KEY, + VERSION, + attributes, + audiences, + typedAudiences, + events, + experiments, + holdouts, featureFlags, groups, rollouts, diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java index ea0d9cac8..ec02aaad0 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 @@ -16,40 +16,43 @@ */ package com.optimizely.ab.config.parser; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.reflect.TypeToken; -import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.FeatureVariable; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.audience.Audience; -import com.optimizely.ab.config.audience.Condition; -import com.optimizely.ab.config.audience.TypedAudience; -import com.optimizely.ab.internal.InvalidAudienceCondition; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import org.junit.Ignore; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; - import java.lang.reflect.Type; import java.util.HashMap; import java.util.List; import java.util.Map; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.nullFeatureEnabledConfigJsonV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigHoldoutJsonV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV2; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV2; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4_holdout; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.*; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.TypedAudience; +import com.optimizely.ab.internal.InvalidAudienceCondition; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; /** * Tests for {@link GsonConfigParser}. @@ -86,6 +89,15 @@ public void parseProjectConfigV4() throws Exception { verifyProjectConfig(actual, expected); } + @Test + public void parseProjectConfigHoldoutV4() throws Exception { + GsonConfigParser parser = new GsonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigHoldoutJsonV4()); + ProjectConfig expected = validProjectConfigV4_holdout(); + + verifyProjectConfig(actual, expected); + } + @Test public void parseNullFeatureEnabledProjectConfigV4() throws Exception { GsonConfigParser parser = new GsonConfigParser(); 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 733ae49a5..336c6f576 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 @@ -16,33 +16,38 @@ */ package com.optimizely.ab.config.parser; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; -import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.FeatureVariable; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.audience.Audience; -import com.optimizely.ab.config.audience.Condition; -import com.optimizely.ab.config.audience.TypedAudience; -import com.optimizely.ab.internal.InvalidAudienceCondition; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import org.junit.Ignore; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; -import java.util.HashMap; -import java.util.Map; - +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.nullFeatureEnabledConfigJsonV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigHoldoutJsonV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV2; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV2; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4_holdout; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; -import static org.junit.Assert.*; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.TypedAudience; +import com.optimizely.ab.internal.InvalidAudienceCondition; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; /** * Tests for {@link JacksonConfigParser}. @@ -80,6 +85,16 @@ public void parseProjectConfigV4() throws Exception { verifyProjectConfig(actual, expected); } + @SuppressFBWarnings("NP_NULL_PARAM_DEREF") + @Test + public void parseProjectConfigHoldoutV4() throws Exception { + JacksonConfigParser parser = new JacksonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigHoldoutJsonV4()); + ProjectConfig expected = validProjectConfigV4_holdout(); + + verifyProjectConfig(actual, expected); + } + @Test public void parseNullFeatureEnabledProjectConfigV4() throws Exception { JacksonConfigParser parser = new JacksonConfigParser(); 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 844d7448b..7ff22338f 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 @@ -16,35 +16,40 @@ */ package com.optimizely.ab.config.parser; -import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.FeatureVariable; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.audience.AudienceIdCondition; -import com.optimizely.ab.config.audience.Condition; -import com.optimizely.ab.config.audience.UserAttribute; -import com.optimizely.ab.internal.ConditionUtils; -import com.optimizely.ab.internal.InvalidAudienceCondition; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + import org.json.JSONArray; import org.json.JSONObject; -import org.junit.Ignore; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.nullFeatureEnabledConfigJsonV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigHoldoutJsonV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV2; -import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4_holdout; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; -import static org.junit.Assert.*; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.audience.AudienceIdCondition; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.UserAttribute; +import com.optimizely.ab.internal.ConditionUtils; +import com.optimizely.ab.internal.InvalidAudienceCondition; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; /** * Tests for {@link JsonConfigParser}. @@ -81,6 +86,16 @@ public void parseProjectConfigV4() throws Exception { verifyProjectConfig(actual, expected); } + @Test + public void parseProjectConfigHoldoutV4() throws Exception { + JsonConfigParser parser = new JsonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigHoldoutJsonV4()); + ProjectConfig expected = validProjectConfigV4_holdout(); + + verifyProjectConfig(actual, expected); + } + + @Test public void parseNullFeatureEnabledProjectConfigV4() throws Exception { JsonConfigParser parser = new JsonConfigParser(); 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 1844fa967..135db70f6 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 @@ -16,35 +16,39 @@ */ package com.optimizely.ab.config.parser; -import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.FeatureVariable; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.audience.AudienceIdCondition; -import com.optimizely.ab.config.audience.Condition; -import com.optimizely.ab.config.audience.UserAttribute; -import com.optimizely.ab.internal.ConditionUtils; -import com.optimizely.ab.internal.InvalidAudienceCondition; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + import org.json.JSONArray; import org.json.JSONObject; -import org.junit.Ignore; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.nullFeatureEnabledConfigJsonV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigHoldoutJsonV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV2; -import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4_holdout; import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; -import static org.junit.Assert.*; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.audience.AudienceIdCondition; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.UserAttribute; +import com.optimizely.ab.internal.ConditionUtils; +import com.optimizely.ab.internal.InvalidAudienceCondition; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; /** * Tests for {@link JsonSimpleConfigParser}. @@ -81,6 +85,15 @@ public void parseProjectConfigV4() throws Exception { verifyProjectConfig(actual, expected); } + @Test + public void parseProjectConfigWithHoldouts() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigHoldoutJsonV4()); + ProjectConfig expected = validProjectConfigV4_holdout(); + + verifyProjectConfig(actual, expected); + } + @Test public void parseNullFeatureEnabledProjectConfigV4() throws Exception { JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java index 418cb2494..7d165ffbc 100644 --- a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java @@ -333,6 +333,7 @@ private ProjectConfig generateOptimizelyConfig() { ) ) ), + null, asList( new FeatureFlag( "4195505407", diff --git a/core-api/src/test/resources/config/holdouts-project-config.json b/core-api/src/test/resources/config/holdouts-project-config.json new file mode 100644 index 000000000..5a83fad17 --- /dev/null +++ b/core-api/src/test/resources/config/holdouts-project-config.json @@ -0,0 +1,1064 @@ +{ + "accountId": "2360254204", + "anonymizeIP": true, + "botFiltering": true, + "sendFlagDecisions": true, + "projectId": "3918735994", + "revision": "1480511547", + "sdkKey": "ValidProjectConfigV4", + "environmentKey": "production", + "version": "4", + "audiences": [ + { + "id": "3468206642", + "name": "Gryffindors", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"house\", \"type\": \"custom_attribute\", \"value\":\"Gryffindor\"}]]]" + }, + { + "id": "3988293898", + "name": "Slytherins", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"house\", \"type\": \"custom_attribute\", \"value\":\"Slytherin\"}]]]" + }, + { + "id": "4194404272", + "name": "english_citizens", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"nationality\", \"type\": \"custom_attribute\", \"value\":\"English\"}]]]" + }, + { + "id": "2196265320", + "name": "audience_with_missing_value", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"nationality\", \"type\": \"custom_attribute\", \"value\": \"English\"}, {\"name\": \"nationality\", \"type\": \"custom_attribute\"}]]]" + } + ], + "typedAudiences": [ + { + "id": "3468206643", + "name": "BOOL", + "conditions": ["and", ["or", ["or", {"name": "booleanKey", "type": "custom_attribute", "match":"exact", "value":true}]]] + }, + { + "id": "3468206646", + "name": "INTEXACT", + "conditions": ["and", ["or", ["or", {"name": "integerKey", "type": "custom_attribute", "match":"exact", "value":1.0}]]] + }, + { + "id": "3468206644", + "name": "INT", + "conditions": ["and", ["or", ["or", {"name": "integerKey", "type": "custom_attribute", "match":"gt", "value":1.0}]]] + }, + { + "id": "3468206645", + "name": "DOUBLE", + "conditions": ["and", ["or", ["or", {"name": "doubleKey", "type": "custom_attribute", "match":"lt", "value":100.0}]]] + }, + { + "id": "3468206642", + "name": "Gryffindors", + "conditions": ["and", ["or", ["or", {"name": "house", "type": "custom_attribute", "match":"exact", "value":"Gryffindor"}]]] + }, + { + "id": "3988293898", + "name": "Slytherins", + "conditions": ["and", ["or", ["or", {"name": "house", "type": "custom_attribute", "match":"substring", "value":"Slytherin"}]]] + }, + { + "id": "4194404272", + "name": "english_citizens", + "conditions": ["and", ["or", ["or", {"name": "nationality", "type": "custom_attribute", "match":"exact", "value":"English"}]]] + }, + { + "id": "2196265320", + "name": "audience_with_missing_value", + "conditions": ["and", ["or", ["or", {"name": "nationality", "type": "custom_attribute", "value": "English"}, {"name": "nationality", "type": "custom_attribute"}]]] + } + ], + "attributes": [ + { + "id": "553339214", + "key": "house" + }, + { + "id": "58339410", + "key": "nationality" + }, + { + "id": "583394100", + "key": "$opt_test" + }, + { + "id": "323434545", + "key": "booleanKey" + }, + { + "id": "616727838", + "key": "integerKey" + }, + { + "id": "808797686", + "key": "doubleKey" + }, + { + "id": "808797686", + "key": "" + } + ], + "events": [ + { + "id": "3785620495", + "key": "basic_event", + "experimentIds": [ + "1323241596", + "2738374745", + "3042640549", + "3262035800", + "3072915611" + ] + }, + { + "id": "3195631717", + "key": "event_with_paused_experiment", + "experimentIds": [ + "2667098701" + ] + }, + { + "id": "1987018666", + "key": "event_with_launched_experiments_only", + "experimentIds": [ + "3072915611" + ] + } + ], + "experiments": [ + { + "id": "1323241596", + "key": "basic_experiment", + "layerId": "1630555626", + "status": "Running", + "variations": [ + { + "id": "1423767502", + "key": "A", + "variables": [] + }, + { + "id": "3433458314", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767502", + "endOfRange": 5000 + }, + { + "entityId": "3433458314", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "forcedVariations": { + "Harry Potter": "A", + "Tom Riddle": "B" + } + }, + { + "id": "1323241597", + "key": "typed_audience_experiment", + "layerId": "1630555627", + "status": "Running", + "variations": [ + { + "id": "1423767503", + "key": "A", + "variables": [] + }, + { + "id": "3433458315", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767503", + "endOfRange": 5000 + }, + { + "entityId": "3433458315", + "endOfRange": 10000 + } + ], + "audienceIds": ["3468206643", "3468206644", "3468206646", "3468206645"], + "audienceConditions" : ["or", "3468206643", "3468206644", "3468206646", "3468206645" ], + "forcedVariations": {} + }, + { + "id": "1323241598", + "key": "typed_audience_experiment_with_and", + "layerId": "1630555628", + "status": "Running", + "variations": [ + { + "id": "1423767504", + "key": "A", + "variables": [] + }, + { + "id": "3433458316", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767504", + "endOfRange": 5000 + }, + { + "entityId": "3433458316", + "endOfRange": 10000 + } + ], + "audienceIds": ["3468206643", "3468206644", "3468206645"], + "audienceConditions" : ["and", "3468206643", "3468206644", "3468206645"], + "forcedVariations": {} + }, + { + "id": "1323241599", + "key": "typed_audience_experiment_leaf_condition", + "layerId": "1630555629", + "status": "Running", + "variations": [ + { + "id": "1423767505", + "key": "A", + "variables": [] + }, + { + "id": "3433458317", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767505", + "endOfRange": 5000 + }, + { + "entityId": "3433458317", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions" : "3468206643", + "forcedVariations": {} + }, + { + "id": "3262035800", + "key": "multivariate_experiment", + "layerId": "3262035800", + "status": "Running", + "variations": [ + { + "id": "1880281238", + "key": "Fred", + "featureEnabled": true, + "variables": [ + { + "id": "675244127", + "value": "F" + }, + { + "id": "4052219963", + "value": "red" + }, + { + "id": "4111661000", + "value": "{\"k1\":\"s1\",\"k2\":103.5,\"k3\":false,\"k4\":{\"kk1\":\"ss1\",\"kk2\":true}}" + } + ] + }, + { + "id": "3631049532", + "key": "Feorge", + "featureEnabled": true, + "variables": [ + { + "id": "675244127", + "value": "F" + }, + { + "id": "4052219963", + "value": "eorge" + }, + { + "id": "4111661000", + "value": "{\"k1\":\"s2\",\"k2\":203.5,\"k3\":true,\"k4\":{\"kk1\":\"ss2\",\"kk2\":true}}" + } + ] + }, + { + "id": "4204375027", + "key": "Gred", + "featureEnabled": false, + "variables": [ + { + "id": "675244127", + "value": "G" + }, + { + "id": "4052219963", + "value": "red" + }, + { + "id": "4111661000", + "value": "{\"k1\":\"s3\",\"k2\":303.5,\"k3\":true,\"k4\":{\"kk1\":\"ss3\",\"kk2\":false}}" + } + ] + }, + { + "id": "2099211198", + "key": "George", + "featureEnabled": true, + "variables": [ + { + "id": "675244127", + "value": "G" + }, + { + "id": "4052219963", + "value": "eorge" + }, + { + "id": "4111661000", + "value": "{\"k1\":\"s4\",\"k2\":403.5,\"k3\":false,\"k4\":{\"kk1\":\"ss4\",\"kk2\":true}}" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "1880281238", + "endOfRange": 2500 + }, + { + "entityId": "3631049532", + "endOfRange": 5000 + }, + { + "entityId": "4204375027", + "endOfRange": 7500 + }, + { + "entityId": "2099211198", + "endOfRange": 10000 + } + ], + "audienceIds": [ + "3468206642" + ], + "forcedVariations": { + "Fred": "Fred", + "Feorge": "Feorge", + "Gred": "Gred", + "George": "George" + } + }, + { + "id": "2201520193", + "key": "double_single_variable_feature_experiment", + "layerId": "1278722008", + "status": "Running", + "variations": [ + { + "id": "1505457580", + "key": "pi_variation", + "featureEnabled": true, + "variables": [ + { + "id": "4111654444", + "value": "3.14" + } + ] + }, + { + "id": "119616179", + "key": "euler_variation", + "variables": [ + { + "id": "4111654444", + "value": "2.718" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "1505457580", + "endOfRange": 4000 + }, + { + "entityId": "119616179", + "endOfRange": 8000 + } + ], + "audienceIds": ["3988293898"], + "forcedVariations": {} + }, + { + "id": "2667098701", + "key": "paused_experiment", + "layerId": "3949273892", + "status": "Paused", + "variations": [ + { + "id": "391535909", + "key": "Control", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "391535909", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "forcedVariations": { + "Harry Potter": "Control" + } + }, + { + "id": "3072915611", + "key": "launched_experiment", + "layerId": "3587821424", + "status": "Launched", + "variations": [ + { + "id": "1647582435", + "key": "launch_control", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1647582435", + "endOfRange": 8000 + } + ], + "audienceIds": [], + "forcedVariations": {} + }, + { + "id": "748215081", + "key": "experiment_with_malformed_audience", + "layerId": "1238149537", + "status": "Running", + "variations": [ + { + "id": "535538389", + "key": "var1", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "535538389", + "endOfRange": 10000 + } + ], + "audienceIds": ["2196265320"], + "forcedVariations": {} + } + ], + "holdouts": [ + { + "audienceIds": [], + "id": "1007532345428", + "key": "holdout_zero_traffic", + "status": "Running", + "trafficAllocation": [ + { + "endOfRange": 0, + "entityId": "327323" + } + ], + "variations": [ + { + "featureEnabled": false, + "id": "$opt_dummy_variation_id", + "key": "ho_off_key" + } + ] + }, + { + "audienceIds": [], + "id": "1007543323427", + "key": "holdout_included_flags", + "status": "Running", + "trafficAllocation": [ + { + "endOfRange": 2000, + "entityId": "327323" + } + ], + "variations": [ + { + "featureEnabled": false, + "id": "$opt_dummy_variation_id", + "key": "ho_off_key" + } + ], + "includedFlags": [ + "4195505407", + "3926744821", + "3281420120" + ] + }, + { + "audienceIds": [], + "id": "10075323428", + "key": "basic_holdout", + "status": "Running", + "trafficAllocation": [ + { + "endOfRange": 500, + "entityId": "327323" + } + ], + "variations": [ + { + "featureEnabled": false, + "id": "$opt_dummy_variation_id", + "key": "ho_off_key" + } + ] + }, + { + "id": "10075323429", + "key": "typed_audience_holdout", + "status": "Running", + "trafficAllocation": [ + { + "endOfRange": 1000, + "entityId": "327323" + } + ], + "variations": [ + { + "featureEnabled": false, + "id": "$opt_dummy_variation_id", + "key": "ho_off_key" + } + ], + "audienceIds": ["3468206643", "3468206644", "3468206646", "3468206645"], + "audienceConditions" : ["or", "3468206643", "3468206644", "3468206646", "3468206645"] + }, + { + "audienceIds": [], + "id": "100753234214", + "key": "holdout_excluded_flags", + "status": "Running", + "trafficAllocation": [ + { + "endOfRange": 1500, + "entityId": "327323" + } + ], + "variations": [ + { + "featureEnabled": false, + "id": "$opt_dummy_variation_id", + "key": "ho_off_key" + } + ], + "excludedFlags": [ + "2591051011", + "2079378557", + "3263342226" + ] + } + ], + "groups": [ + { + "id": "1015968292", + "policy": "random", + "experiments": [ + { + "id": "2738374745", + "key": "first_grouped_experiment", + "layerId": "3301900159", + "status": "Running", + "variations": [ + { + "id": "2377378132", + "key": "A", + "variables": [] + }, + { + "id": "1179171250", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "2377378132", + "endOfRange": 5000 + }, + { + "entityId": "1179171250", + "endOfRange": 10000 + } + ], + "audienceIds": [ + "3468206642" + ], + "forcedVariations": { + "Harry Potter": "A", + "Tom Riddle": "B" + } + }, + { + "id": "3042640549", + "key": "second_grouped_experiment", + "layerId": "2625300442", + "status": "Running", + "variations": [ + { + "id": "1558539439", + "key": "A", + "variables": [] + }, + { + "id": "2142748370", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1558539439", + "endOfRange": 5000 + }, + { + "entityId": "2142748370", + "endOfRange": 10000 + } + ], + "audienceIds": [ + "3468206642" + ], + "forcedVariations": { + "Hermione Granger": "A", + "Ronald Weasley": "B" + } + } + ], + "trafficAllocation": [ + { + "entityId": "2738374745", + "endOfRange": 4000 + }, + { + "entityId": "3042640549", + "endOfRange": 8000 + } + ] + }, + { + "id": "2606208781", + "policy": "random", + "experiments": [ + { + "id": "4138322202", + "key": "mutex_group_2_experiment_1", + "layerId": "3755588495", + "status": "Running", + "variations": [ + { + "id": "1394671166", + "key": "mutex_group_2_experiment_1_variation_1", + "featureEnabled": true, + "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", + "featureEnabled": true, + "variables": [ + { + "id": "2059187672", + "value": "mutex_group_2_experiment_2_variation_2" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "1619235542", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "forcedVariations": {} + } + ], + "trafficAllocation": [ + { + "entityId": "4138322202", + "endOfRange": 5000 + }, + { + "entityId": "1786133852", + "endOfRange": 10000 + } + ] + } + ], + "featureFlags": [ + { + "id": "4195505407", + "key": "boolean_feature", + "rolloutId": "", + "experimentIds": [], + "variables": [] + }, + { + "id": "3926744821", + "key": "double_single_variable_feature", + "rolloutId": "", + "experimentIds": ["2201520193"], + "variables": [ + { + "id": "4111654444", + "key": "double_variable", + "type": "double", + "defaultValue": "14.99" + } ] + }, + { + "id": "3281420120", + "key": "integer_single_variable_feature", + "rolloutId": "2048875663", + "experimentIds": [], + "variables": [ + { + "id": "593964691", + "key": "integer_variable", + "type": "integer", + "defaultValue": "7" + } + ] + }, + { + "id": "2591051011", + "key": "boolean_single_variable_feature", + "rolloutId": "", + "experimentIds": [], + "variables": [ + { + "id": "3974680341", + "key": "boolean_variable", + "type": "boolean", + "defaultValue": "true" + } + ] + }, + { + "id": "2079378557", + "key": "string_single_variable_feature", + "rolloutId": "1058508303", + "experimentIds": [], + "variables": [ + { + "id": "2077511132", + "key": "string_variable", + "type": "string", + "defaultValue": "wingardium leviosa" + } + ] + }, + { + "id": "3263342226", + "key": "multi_variate_feature", + "rolloutId": "813411034", + "experimentIds": ["3262035800"], + "variables": [ + { + "id": "675244127", + "key": "first_letter", + "type": "string", + "defaultValue": "H" + }, + { + "id": "4052219963", + "key": "rest_of_name", + "type": "string", + "defaultValue": "arry" + }, + { + "id": "4111661000", + "key": "json_patched", + "type": "string", + "subType": "json", + "defaultValue": "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}" + } + ] + }, + { + "id": "3263342227", + "key": "multi_variate_future_feature", + "rolloutId": "813411034", + "experimentIds": ["3262035800"], + "variables": [ + { + "id": "4111661001", + "key": "json_native", + "type": "json", + "defaultValue": "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}" + }, + { + "id": "4111661002", + "key": "future_variable", + "type": "future_type", + "defaultValue": "future_value" + } + ] + }, + { + "id": "3263342226", + "key": "mutex_group_feature", + "rolloutId": "", + "experimentIds": ["4138322202", "1786133852"], + "variables": [ + { + "id": "2059187672", + "key": "correlating_variation_name", + "type": "string", + "defaultValue": "null" + } + ] + } + ], + "rollouts": [ + { + "id": "1058508303", + "experiments": [ + { + "id": "1785077004", + "key": "1785077004", + "status": "Running", + "layerId": "1058508303", + "audienceIds": [], + "forcedVariations": {}, + "variations": [ + { + "id": "1566407342", + "key": "1566407342", + "featureEnabled": true, + "variables": [ + { + "id": "2077511132", + "value": "lumos" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "1566407342", + "endOfRange": 5000 + } + ] + } + ] + }, + { + "id": "813411034", + "experiments": [ + { + "id": "3421010877", + "key": "3421010877", + "status": "Running", + "layerId": "813411034", + "audienceIds": ["3468206642"], + "forcedVariations": {}, + "variations": [ + { + "id": "521740985", + "key": "521740985", + "variables": [ + { + "id": "675244127", + "value": "G" + }, + { + "id": "4052219963", + "value": "odric" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "521740985", + "endOfRange": 5000 + } + ] + }, + { + "id": "600050626", + "key": "600050626", + "status": "Running", + "layerId": "813411034", + "audienceIds": ["3988293898"], + "forcedVariations": {}, + "variations": [ + { + "id": "180042646", + "key": "180042646", + "featureEnabled": true, + "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", + "featureEnabled": true, + "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", + "featureEnabled": true, + "variables": [ + { + "id": "675244127", + "value": "M" + }, + { + "id": "4052219963", + "value": "uggle" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "3137445031", + "endOfRange": 5000 + } + ] + } + ] + }, + { + "id": "2048875663", + "experiments": [ + { + "id": "3794675122", + "key": "3794675122", + "status": "Running", + "layerId": "2048875663", + "audienceIds": [], + "forcedVariations": {}, + "variations": [ + { + "id": "589640735", + "key": "589640735", + "featureEnabled": true, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "589640735", + "endOfRange": 10000 + } + ] + } + ] + } + ], + "integrations": [ + { + "key": "odp", + "host": "https://example.com", + "publicKey": "test-key" + } + ] +}