diff --git a/.github/workflows/instrumented.yml b/.github/workflows/instrumented.yml index 69ac5077c..6ec106f2e 100644 --- a/.github/workflows/instrumented.yml +++ b/.github/workflows/instrumented.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - 'development' + - '*_baseline' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/src/main/java/io/split/android/client/EvaluatorImpl.java b/src/main/java/io/split/android/client/EvaluatorImpl.java index 305405eb1..28166977b 100644 --- a/src/main/java/io/split/android/client/EvaluatorImpl.java +++ b/src/main/java/io/split/android/client/EvaluatorImpl.java @@ -9,6 +9,7 @@ import io.split.android.engine.experiments.ParsedCondition; import io.split.android.engine.experiments.ParsedSplit; import io.split.android.engine.experiments.SplitParser; +import io.split.android.engine.matchers.PrerequisitesMatcher; import io.split.android.engine.splitter.Splitter; import io.split.android.grammar.Treatments; @@ -55,6 +56,17 @@ private EvaluationResult getTreatment(String matchingKey, String bucketingKey, P return new EvaluationResult(parsedSplit.defaultTreatment(), TreatmentLabels.KILLED, parsedSplit.changeNumber(), configForTreatment(parsedSplit, parsedSplit.defaultTreatment()), parsedSplit.impressionsDisabled()); } + if (!parsedSplit.prerequisites().isEmpty()) { + PrerequisitesMatcher matcher = new PrerequisitesMatcher(parsedSplit.prerequisites()); + if (!matcher.match(matchingKey, bucketingKey, attributes, this)) { + return new EvaluationResult(parsedSplit.defaultTreatment(), + TreatmentLabels.PREREQUISITES_NOT_MET, + parsedSplit.changeNumber(), + configForTreatment(parsedSplit, parsedSplit.defaultTreatment()), + parsedSplit.impressionsDisabled()); + } + } + /* * There are three parts to a single Split: 1) Whitelists 2) Traffic Allocation * 3) Rollout. The flag inRollout is there to understand when we move into the Rollout diff --git a/src/main/java/io/split/android/client/SplitManagerImpl.java b/src/main/java/io/split/android/client/SplitManagerImpl.java index 81b856d5c..86c37f169 100644 --- a/src/main/java/io/split/android/client/SplitManagerImpl.java +++ b/src/main/java/io/split/android/client/SplitManagerImpl.java @@ -145,6 +145,7 @@ private SplitView toSplitView(ParsedSplit parsedSplit) { splitView.sets = new ArrayList<>(parsedSplit.sets() == null ? new HashSet<>() : parsedSplit.sets()); splitView.defaultTreatment = parsedSplit.defaultTreatment(); splitView.impressionsDisabled = parsedSplit.impressionsDisabled(); + splitView.prerequisites = parsedSplit.prerequisites(); Set treatments = new HashSet<>(); for (ParsedCondition condition : parsedSplit.parsedConditions()) { diff --git a/src/main/java/io/split/android/client/TreatmentLabels.java b/src/main/java/io/split/android/client/TreatmentLabels.java index 604537132..9133fc788 100644 --- a/src/main/java/io/split/android/client/TreatmentLabels.java +++ b/src/main/java/io/split/android/client/TreatmentLabels.java @@ -8,4 +8,5 @@ public class TreatmentLabels { public static final String KILLED = "killed"; public static final String NOT_READY = "not ready"; public static final String UNSUPPORTED_MATCHER_TYPE = "targeting rule type unsupported by sdk"; + public static final String PREREQUISITES_NOT_MET = "prerequisites not met"; } diff --git a/src/main/java/io/split/android/client/api/SplitView.java b/src/main/java/io/split/android/client/api/SplitView.java index 4c76bdc6d..84cc81a2d 100644 --- a/src/main/java/io/split/android/client/api/SplitView.java +++ b/src/main/java/io/split/android/client/api/SplitView.java @@ -7,6 +7,7 @@ import java.util.Map; import io.split.android.client.SplitManager; +import io.split.android.client.dtos.Prerequisite; /** * A view of a feature flag, meant for consumption through {@link SplitManager} interface. @@ -22,4 +23,5 @@ public class SplitView { public List sets = new ArrayList<>(); public String defaultTreatment; public boolean impressionsDisabled; + public List prerequisites = new ArrayList<>(); } diff --git a/src/main/java/io/split/android/client/dtos/Prerequisite.java b/src/main/java/io/split/android/client/dtos/Prerequisite.java new file mode 100644 index 000000000..989edf894 --- /dev/null +++ b/src/main/java/io/split/android/client/dtos/Prerequisite.java @@ -0,0 +1,35 @@ +package io.split.android.client.dtos; + +import androidx.annotation.NonNull; + +import com.google.gson.annotations.SerializedName; + +import java.util.HashSet; +import java.util.Set; + +public class Prerequisite { + + @SerializedName("n") + private String name; + + @SerializedName("ts") + private Set treatments; + + public Prerequisite() { + } + + public Prerequisite(String name, Set treatments) { + this.name = name; + this.treatments = treatments; + } + + @NonNull + public String getFlagName() { + return name == null ? "" : name; + } + + @NonNull + public Set getTreatments() { + return treatments == null ? new HashSet<>() : treatments; + } +} diff --git a/src/main/java/io/split/android/client/dtos/Split.java b/src/main/java/io/split/android/client/dtos/Split.java index e22838048..0c81bafbe 100644 --- a/src/main/java/io/split/android/client/dtos/Split.java +++ b/src/main/java/io/split/android/client/dtos/Split.java @@ -4,6 +4,7 @@ import com.google.gson.annotations.SerializedName; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; @@ -53,6 +54,10 @@ public class Split { @SerializedName("impressionsDisabled") public boolean impressionsDisabled = false; + @Nullable + @SerializedName("prerequisites") + public List prerequisites; + public String json = null; public Split() { @@ -63,4 +68,8 @@ public Split(String name, String json) { this.name = name; this.json = json; } + + public List getPrerequisites() { + return prerequisites == null ? new ArrayList<>() : prerequisites; + } } diff --git a/src/main/java/io/split/android/engine/experiments/ParsedSplit.java b/src/main/java/io/split/android/engine/experiments/ParsedSplit.java index a7d936de9..ec7f784bc 100644 --- a/src/main/java/io/split/android/engine/experiments/ParsedSplit.java +++ b/src/main/java/io/split/android/engine/experiments/ParsedSplit.java @@ -9,6 +9,8 @@ import java.util.Objects; import java.util.Set; +import io.split.android.client.dtos.Prerequisite; + public class ParsedSplit { private final String mSplit; @@ -24,6 +26,7 @@ public class ParsedSplit { private final Map mConfigurations; private final Set mSets; private final boolean mImpressionsDisabled; + private final List mPrerequisites; public ParsedSplit( String feature, @@ -38,7 +41,8 @@ public ParsedSplit( int algo, Map configurations, Set sets, - boolean impressionsDisabled + boolean impressionsDisabled, + List prerequisites ) { mSplit = feature; mSeed = seed; @@ -57,6 +61,7 @@ public ParsedSplit( mTrafficAllocation = trafficAllocation; mTrafficAllocationSeed = trafficAllocationSeed; mSets = sets; + mPrerequisites = prerequisites; } public String feature() { @@ -111,6 +116,10 @@ public boolean impressionsDisabled() { return mImpressionsDisabled; } + public List prerequisites() { + return mPrerequisites; + } + @Override public int hashCode() { int result = 17; @@ -124,6 +133,7 @@ public int hashCode() { result = 31 * result + (mAlgo ^ (mAlgo >>> 32)); result = 31 * result + ((mSets != null) ? mSets.hashCode() : 0); result = 31 * result + (mImpressionsDisabled ? 1 : 0); + result = 31 * result + ((mPrerequisites != null) ? mPrerequisites.hashCode() : 0); return result; } @@ -144,8 +154,8 @@ public boolean equals(Object obj) { && mAlgo == other.mAlgo && (Objects.equals(mConfigurations, other.mConfigurations)) && (Objects.equals(mSets, other.mSets) - && mImpressionsDisabled == other.mImpressionsDisabled); - + && mImpressionsDisabled == other.mImpressionsDisabled + && (Objects.equals(mPrerequisites, other.mPrerequisites))); } @NonNull @@ -155,7 +165,8 @@ public String toString() { ", default treatment:" + mDefaultTreatment + ", parsedConditions:" + mParsedCondition + ", trafficTypeName:" + mTrafficTypeName + ", changeNumber:" + mChangeNumber + - ", algo:" + mAlgo + ", config:" + mConfigurations + ", sets:" + mSets + ", impressionsDisabled:" + mImpressionsDisabled; + ", algo:" + mAlgo + ", config:" + mConfigurations + ", sets:" + mSets + + ", impressionsDisabled:" + mImpressionsDisabled + ", prerequisites:" + mPrerequisites; } } diff --git a/src/main/java/io/split/android/engine/experiments/SplitParser.java b/src/main/java/io/split/android/engine/experiments/SplitParser.java index 03cbc27e4..3aa5198ea 100644 --- a/src/main/java/io/split/android/engine/experiments/SplitParser.java +++ b/src/main/java/io/split/android/engine/experiments/SplitParser.java @@ -4,6 +4,7 @@ import androidx.annotation.Nullable; +import java.util.ArrayList; import java.util.List; import io.split.android.client.dtos.Split; @@ -61,6 +62,7 @@ private ParsedSplit parseWithoutExceptionHandling(Split split, String matchingKe split.algo, split.configurations, split.sets, - split.impressionsDisabled); + split.impressionsDisabled, + new ArrayList<>(split.getPrerequisites())); } } diff --git a/src/main/java/io/split/android/engine/matchers/PrerequisitesMatcher.java b/src/main/java/io/split/android/engine/matchers/PrerequisitesMatcher.java new file mode 100644 index 000000000..977548a08 --- /dev/null +++ b/src/main/java/io/split/android/engine/matchers/PrerequisitesMatcher.java @@ -0,0 +1,36 @@ +package io.split.android.engine.matchers; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import io.split.android.client.EvaluationResult; +import io.split.android.client.Evaluator; +import io.split.android.client.dtos.Prerequisite; + +public class PrerequisitesMatcher implements Matcher { + + @NonNull + private final List mPrerequisites; + + public PrerequisitesMatcher(List prerequisites) { + mPrerequisites = prerequisites == null ? new ArrayList<>() : prerequisites; + } + + @Override + public boolean match(Object matchValue, String bucketingKey, Map attributes, Evaluator evaluator) { + if (!(matchValue instanceof String)) { + return false; + } + + for (Prerequisite prerequisite : mPrerequisites) { + EvaluationResult treatment = evaluator.getTreatment((String) matchValue, bucketingKey, prerequisite.getFlagName(), attributes); + if (treatment == null || !prerequisite.getTreatments().contains(treatment.getTreatment())) { + return false; + } + } + return true; + } +} diff --git a/src/test/java/io/split/android/client/SplitManagerImplTest.java b/src/test/java/io/split/android/client/SplitManagerImplTest.java index 180bbf3ff..fce55876d 100644 --- a/src/test/java/io/split/android/client/SplitManagerImplTest.java +++ b/src/test/java/io/split/android/client/SplitManagerImplTest.java @@ -8,6 +8,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.when; import org.junit.Before; @@ -18,12 +19,15 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import io.split.android.client.api.SplitView; import io.split.android.client.dtos.Condition; +import io.split.android.client.dtos.Prerequisite; import io.split.android.client.dtos.Split; import io.split.android.client.storage.mysegments.MySegmentsStorage; import io.split.android.client.storage.mysegments.MySegmentsStorageContainer; @@ -204,6 +208,49 @@ public void impressionsDisabledIsPresent() { assertFalse(featureFlag.impressionsDisabled); } + @Test + public void prerequisitesIsPresent() { + Split split = SplitHelper.createSplit("FeatureName", 123, true, + "some_treatment", Arrays.asList(getTestCondition()), + "traffic", 456L, 1, null); + Prerequisite p1 = new Prerequisite("prereq_feature", Collections.singleton("some_treatment")); + HashSet treatments = new HashSet<>(); + treatments.add("some_treatment_1"); + treatments.add("some_other_treatment"); + Prerequisite p2 = new Prerequisite("prereq_feature_2", treatments); + split.prerequisites = Arrays.asList(p1, p2); + + when(mSplitsStorage.get("FeatureName")).thenReturn(split); + + SplitView featureFlag = mSplitManager.split("FeatureName"); + + List prerequisites = featureFlag.prerequisites; + Prerequisite prereq1 = prerequisites.get(0); + Prerequisite prereq2 = prerequisites.get(1); + assertEquals(2, prerequisites.size()); + assertEquals("prereq_feature", prereq1.getFlagName()); + assertEquals(1, prereq1.getTreatments().size()); + assertEquals("some_treatment", prereq1.getTreatments().iterator().next()); + assertEquals("prereq_feature_2", prereq2.getFlagName()); + assertEquals(2, prereq2.getTreatments().size()); + assertTrue(prereq2.getTreatments().contains("some_treatment_1")); + assertTrue(prereq2.getTreatments().contains("some_other_treatment")); + } + + @Test + public void nullPrerequisitesDefaultToEmptyList() { + Split split = SplitHelper.createSplit("FeatureName", 123, true, + "some_treatment", Arrays.asList(getTestCondition()), + "traffic", 456L, 1, null); + split.prerequisites = null; + when(mSplitsStorage.get("FeatureName")).thenReturn(split); + + SplitView featureFlag = mSplitManager.split("FeatureName"); + + List prerequisites = featureFlag.prerequisites; + assertEquals(0, prerequisites.size()); + } + private Condition getTestCondition() { return SplitHelper.createCondition(CombiningMatcher.of(new AllKeysMatcher()), Arrays.asList(ConditionsTestUtil.partition("off", 10))); } diff --git a/src/test/java/io/split/android/client/service/rules/TargetingRulesResponseParserTest.java b/src/test/java/io/split/android/client/service/rules/TargetingRulesResponseParserTest.java index 2ce2ded65..c5622d343 100644 --- a/src/test/java/io/split/android/client/service/rules/TargetingRulesResponseParserTest.java +++ b/src/test/java/io/split/android/client/service/rules/TargetingRulesResponseParserTest.java @@ -8,10 +8,13 @@ import org.junit.Before; import org.junit.Test; +import java.util.List; import java.util.Set; import io.split.android.client.dtos.Excluded; import io.split.android.client.dtos.ExcludedSegment; +import io.split.android.client.dtos.Prerequisite; +import io.split.android.client.dtos.Split; import io.split.android.client.dtos.TargetingRulesChange; import io.split.android.client.service.http.HttpResponseParserException; import io.split.android.helpers.FileHelper; @@ -63,6 +66,37 @@ public void parsesLegacySplitChangeJson() throws Exception { assertTrue(result.getRuleBasedSegmentsChange().getSegments().isEmpty()); } + @Test + public void parsesPrerequisites() throws HttpResponseParserException { + String json = fileHelper.loadFileContent("split_changes_prerequisites.json"); + TargetingRulesChange result = parser.parse(json); + + assertNotNull(result); + assertNotNull(result.getFeatureFlagsChange()); + Split firstSplit = result.getFeatureFlagsChange().splits.get(0); + assertEquals("FACUNDO_TEST", firstSplit.name); + List preReqs = firstSplit.getPrerequisites(); + assertEquals(2, preReqs.size()); + assertEquals("flag1", preReqs.get(0).getFlagName()); + assertEquals("flag2", preReqs.get(1).getFlagName()); + assertEquals(2, preReqs.get(0).getTreatments().size()); + assertEquals(1, preReqs.get(1).getTreatments().size()); + assertTrue(preReqs.get(0).getTreatments().contains("on")); + assertTrue(preReqs.get(0).getTreatments().contains("v1")); + assertTrue(preReqs.get(1).getTreatments().contains("off")); + } + + @Test + public void nonExistingPrerequisitesDefaultsToEmpty() throws HttpResponseParserException { + String json = fileHelper.loadFileContent("split_changes_prerequisites.json"); + TargetingRulesChange result = parser.parse(json); + assertNotNull(result); + Split split = result.getFeatureFlagsChange().splits.get(1); + assertEquals("FACUNDO_TEST_2", split.name); + List preReqs = split.getPrerequisites(); + assertEquals(0, preReqs.size()); + } + @Test public void parseNullReturnsNull() throws HttpResponseParserException { TargetingRulesChange result = parser.parse(null); diff --git a/src/test/java/io/split/android/engine/experiments/PrerequisitesEvaluatorTest.java b/src/test/java/io/split/android/engine/experiments/PrerequisitesEvaluatorTest.java new file mode 100644 index 000000000..601198904 --- /dev/null +++ b/src/test/java/io/split/android/engine/experiments/PrerequisitesEvaluatorTest.java @@ -0,0 +1,124 @@ +package io.split.android.engine.experiments; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.split.android.client.EvaluationResult; +import io.split.android.client.Evaluator; +import io.split.android.client.EvaluatorImpl; +import io.split.android.client.TreatmentLabels; +import io.split.android.client.dtos.Split; +import io.split.android.client.storage.mysegments.MySegmentsStorage; +import io.split.android.client.storage.mysegments.MySegmentsStorageContainer; +import io.split.android.client.storage.rbs.RuleBasedSegmentStorage; +import io.split.android.client.storage.splits.SplitsStorage; +import io.split.android.helpers.FileHelper; + +/** + * Tests for the prerequisite functionality in the Evaluator + */ +public class PrerequisitesEvaluatorTest { + + private Evaluator evaluator; + private Map splitsMap; + + @Before + public void loadSplitsFromFile() { + if (evaluator == null) { + FileHelper fileHelper = new FileHelper(); + MySegmentsStorage mySegmentsStorage = mock(MySegmentsStorage.class); + MySegmentsStorage myLargeSegmentsStorage = mock(MySegmentsStorage.class); + MySegmentsStorageContainer mySegmentsStorageContainer = mock(MySegmentsStorageContainer.class); + MySegmentsStorageContainer myLargeSegmentsStorageContainer = mock(MySegmentsStorageContainer.class); + RuleBasedSegmentStorage ruleBasedSegmentStorage = mock(RuleBasedSegmentStorage.class); + SplitsStorage splitsStorage = mock(SplitsStorage.class); + + List splits = fileHelper.loadAndParseSplitChangeFile("split_changes_with_prerequisites.json"); + SplitParser splitParser = new SplitParser(new ParserCommons(mySegmentsStorageContainer, myLargeSegmentsStorageContainer)); + + splitsMap = splitsMap(splits); + when(splitsStorage.getAll()).thenReturn(splitsMap); + when(splitsStorage.get(any())).thenAnswer(new Answer() { + @Override + public Split answer(InvocationOnMock invocation) throws Throwable { + return splitsMap.get(invocation.getArgument(0)); + } + }); + + when(splitsStorage.get("parent_split")).thenReturn(splitsMap.get("parent_split")); + when(splitsStorage.get("child_split_1")).thenReturn(splitsMap.get("child_split_1")); + when(splitsStorage.get("child_split_2")).thenReturn(splitsMap.get("child_split_2")); + when(splitsStorage.get("parent_split_with_one_failing_prerequisite")).thenReturn(splitsMap.get("parent_split_with_one_failing_prerequisite")); + when(splitsStorage.get("parent_split_with_non_existent_prerequisite")).thenReturn(splitsMap.get("parent_split_with_non_existent_prerequisite")); + when(splitsStorage.get("non_existent_split")).thenReturn(null); + + when(mySegmentsStorageContainer.getStorageForKey(any())).thenReturn(mySegmentsStorage); + when(myLargeSegmentsStorageContainer.getStorageForKey(any())).thenReturn(myLargeSegmentsStorage); + + evaluator = new EvaluatorImpl(splitsStorage, splitParser); + } + } + + @Test + public void testPrerequisitesAllMet() { + String matchingKey = "user1"; + String splitName = "parent_split"; + EvaluationResult result = evaluator.getTreatment(matchingKey, matchingKey, splitName, null); + + Assert.assertNotNull(result); + Assert.assertEquals("off", result.getTreatment()); + Assert.assertEquals(TreatmentLabels.PREREQUISITES_NOT_MET, result.getLabel()); + } + + @Test + public void testPrerequisitesNotMet() { + String matchingKey = "user1"; + String splitName = "parent_split_with_one_failing_prerequisite"; + EvaluationResult result = evaluator.getTreatment(matchingKey, matchingKey, splitName, null); + + Assert.assertNotNull(result); + Assert.assertEquals("off", result.getTreatment()); + Assert.assertEquals(TreatmentLabels.PREREQUISITES_NOT_MET, result.getLabel()); + } + + @Test + public void testPrerequisiteNonExistent() { + String matchingKey = "user1"; + String splitName = "parent_split_with_non_existent_prerequisite"; + EvaluationResult result = evaluator.getTreatment(matchingKey, matchingKey, splitName, null); + + Assert.assertNotNull(result); + Assert.assertEquals("off", result.getTreatment()); + Assert.assertEquals(TreatmentLabels.PREREQUISITES_NOT_MET, result.getLabel()); + } + + @Test + public void testChildSplitEvaluation() { + String matchingKey = "user1"; + String splitName = "child_split_1"; + EvaluationResult result = evaluator.getTreatment(matchingKey, matchingKey, splitName, null); + + Assert.assertNotNull(result); + Assert.assertEquals("on", result.getTreatment()); + Assert.assertEquals("in segment all", result.getLabel()); + } + + private Map splitsMap(List splits) { + Map splitsMap = new HashMap<>(); + for (Split split : splits) { + splitsMap.put(split.name, split); + } + return splitsMap; + } +} diff --git a/src/test/java/io/split/android/engine/matchers/PrerequisitesMatcherTest.java b/src/test/java/io/split/android/engine/matchers/PrerequisitesMatcherTest.java new file mode 100644 index 000000000..5ad25788d --- /dev/null +++ b/src/test/java/io/split/android/engine/matchers/PrerequisitesMatcherTest.java @@ -0,0 +1,196 @@ +package io.split.android.engine.matchers; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import io.split.android.client.EvaluationResult; +import io.split.android.client.Evaluator; +import io.split.android.client.dtos.Condition; +import io.split.android.client.dtos.Partition; +import io.split.android.client.dtos.Prerequisite; +import io.split.android.client.dtos.Split; +import io.split.android.client.storage.splits.SplitsStorage; +import io.split.android.helpers.SplitHelper; + +public class PrerequisitesMatcherTest { + + @Mock + private SplitsStorage mSplitsStorage; + + @Mock + private Evaluator mEvaluator; + + private final Map mStoredSplits = new HashMap<>(); + + @Before + public void setUp() { + mSplitsStorage = mock(SplitsStorage.class); + mEvaluator = mock(Evaluator.class); + + List alwaysOnPartitions = createPartitions("on", 100, "off", 0); + List alwaysOnConditions = new ArrayList<>(); + alwaysOnConditions.add(SplitHelper.createCondition(null, alwaysOnPartitions)); + + Split alwaysOn = SplitHelper.createSplit( + "always-on", + -725161385, + false, + "off", + alwaysOnConditions, + "user", + 1494364996459L, + 1, + null); + + List alwaysOffPartitions = createPartitions("on", 0, "off", 100); + List alwaysOffConditions = new ArrayList<>(); + alwaysOffConditions.add(SplitHelper.createCondition(null, alwaysOffPartitions)); + + Split alwaysOff = SplitHelper.createSplit( + "always-off", + 403891040, + false, + "on", + alwaysOffConditions, + "user", + 1494365020316L, + 1, + null); + + mStoredSplits.put("always-on", alwaysOn); + mStoredSplits.put("always-off", alwaysOff); + + when(mSplitsStorage.get(eq("always-on"))).thenReturn(mStoredSplits.get("always-on")); + when(mSplitsStorage.get(eq("always-off"))).thenReturn(mStoredSplits.get("always-off")); + when(mSplitsStorage.get(eq("not-existent-feature-flag"))).thenReturn(null); + + when(mEvaluator.getTreatment(any(), any(), eq("always-on"), any())) + .thenReturn(new EvaluationResult("on", "in segment all")); + + when(mEvaluator.getTreatment(any(), any(), eq("always-off"), any())) + .thenReturn(new EvaluationResult("off", "in segment all")); + + when(mEvaluator.getTreatment(any(), any(), eq("not-existent-feature-flag"), any())) + .thenReturn(null); + } + + private List createPartitions(String treatment1, int size1, String treatment2, int size2) { + List partitions = new ArrayList<>(); + + Partition partition1 = new Partition(); + partition1.treatment = treatment1; + partition1.size = size1; + partitions.add(partition1); + + Partition partition2 = new Partition(); + partition2.treatment = treatment2; + partition2.size = size2; + partitions.add(partition2); + + return partitions; + } + + @Test + public void shouldReturnTrueWhenSinglePrerequisiteIsMetForAlwaysOn() { + List prerequisites = new ArrayList<>(); + prerequisites.add(new Prerequisite("always-on", new HashSet<>(Arrays.asList("not-existing", "on", "other")))); + PrerequisitesMatcher matcher = new PrerequisitesMatcher(prerequisites); + + assertTrue(matcher.match("a-key", null, null, mEvaluator)); + } + + @Test + public void shouldReturnFalseWhenSinglePrerequisiteIsNotMetForAlwaysOn() { + List prerequisites = new ArrayList<>(); + prerequisites.add(new Prerequisite("always-on", new HashSet<>(Arrays.asList("off", "v1")))); + PrerequisitesMatcher matcher = new PrerequisitesMatcher(prerequisites); + + assertFalse(matcher.match("a-key", null, null, mEvaluator)); + } + + @Test + public void shouldReturnTrueWhenSinglePrerequisiteIsMetForAlwaysOff() { + List prerequisites = new ArrayList<>(); + prerequisites.add(new Prerequisite("always-off", new HashSet<>(Arrays.asList("not-existing", "off")))); + PrerequisitesMatcher matcher = new PrerequisitesMatcher(prerequisites); + + assertTrue(matcher.match("a-key", null, null, mEvaluator)); + } + + @Test + public void shouldReturnFalseWhenSinglePrerequisiteIsNotMetForAlwaysOff() { + List prerequisites = new ArrayList<>(); + prerequisites.add(new Prerequisite("always-off", new HashSet<>(Arrays.asList("v1", "on")))); + PrerequisitesMatcher matcher = new PrerequisitesMatcher(prerequisites); + + assertFalse(matcher.match("a-key", null, null, mEvaluator)); + } + + @Test + public void shouldReturnTrueWhenAllMultiplePrerequisitesAreMet() { + List prerequisites = new ArrayList<>(); + prerequisites.add(new Prerequisite("always-on", new HashSet<>(Collections.singletonList("on")))); + prerequisites.add(new Prerequisite("always-off", new HashSet<>(Collections.singletonList("off")))); + PrerequisitesMatcher matcher = new PrerequisitesMatcher(prerequisites); + + assertTrue(matcher.match("a-key", null, null, mEvaluator)); + } + + @Test + public void shouldReturnFalseWhenOneOfMultiplePrerequisitesIsNotMet() { + List prerequisites = new ArrayList<>(); + prerequisites.add(new Prerequisite("always-on", new HashSet<>(Collections.singletonList("on")))); + prerequisites.add(new Prerequisite("always-off", new HashSet<>(Collections.singletonList("on")))); + PrerequisitesMatcher matcher = new PrerequisitesMatcher(prerequisites); + + assertFalse(matcher.match("a-key", null, null, mEvaluator)); + } + + @Test + public void shouldReturnTrueWithNullPrerequisites() { + PrerequisitesMatcher matcher = new PrerequisitesMatcher(null); + + assertTrue(matcher.match("a-key", null, null, mEvaluator)); + } + + @Test + public void shouldReturnTrueWithEmptyPrerequisites() { + PrerequisitesMatcher matcher = new PrerequisitesMatcher(new ArrayList<>()); + + assertTrue(matcher.match("a-key", null, null, mEvaluator)); + } + + @Test + public void shouldReturnFalseWhenFeatureFlagDoesNotExist() { + List prerequisites = new ArrayList<>(); + prerequisites.add(new Prerequisite("not-existent-feature-flag", new HashSet<>(Arrays.asList("on", "off")))); + PrerequisitesMatcher matcher = new PrerequisitesMatcher(prerequisites); + + assertFalse(matcher.match("a-key", null, null, mEvaluator)); + } + + @Test + public void shouldReturnFalseWithEmptyTreatmentsList() { + List prerequisites = new ArrayList<>(); + prerequisites.add(new Prerequisite("always-on", new HashSet<>())); + PrerequisitesMatcher matcher = new PrerequisitesMatcher(prerequisites); + + assertFalse(matcher.match("a-key", null, null, mEvaluator)); + } +} diff --git a/src/test/java/io/split/android/helpers/SplitHelper.java b/src/test/java/io/split/android/helpers/SplitHelper.java index 636c376ca..a07c00bb8 100644 --- a/src/test/java/io/split/android/helpers/SplitHelper.java +++ b/src/test/java/io/split/android/helpers/SplitHelper.java @@ -123,7 +123,8 @@ public static ParsedSplit createParsedSplit( algo, configurations, Collections.emptySet(), - false + false, + new ArrayList<>() ); } diff --git a/src/test/resources/split_changes_prerequisites.json b/src/test/resources/split_changes_prerequisites.json new file mode 100644 index 000000000..1b9467bc3 --- /dev/null +++ b/src/test/resources/split_changes_prerequisites.json @@ -0,0 +1,253 @@ +{ + "ff": { + "splits": [ + { + "trafficTypeName": "account", + "name": "FACUNDO_TEST", + "trafficAllocation": 59, + "trafficAllocationSeed": -2108186082, + "seed": -1947050785, + "status": "ACTIVE", + "killed": false, + "defaultTreatment": "off", + "changeNumber": 1506703262916, + "prerequisites": [ + { + "n": "flag1", + "ts": ["on","v1"] + }, + { + "n": "flag2", + "ts": ["off"] + } + ], + "algo": 2, + "conditions": [ + { + "conditionType": "WHITELIST", + "matcherGroup": { + "combiner": "AND", + "matchers": [ + { + "keySelector": null, + "matcherType": "WHITELIST", + "negate": false, + "userDefinedSegmentMatcherData": null, + "whitelistMatcherData": { + "whitelist": [ + "nico_test", + "othertest" + ] + }, + "unaryNumericMatcherData": null, + "betweenMatcherData": null, + "booleanMatcherData": null, + "dependencyMatcherData": null, + "stringMatcherData": null + } + ] + }, + "partitions": [ + { + "treatment": "on", + "size": 100 + } + ], + "label": "whitelisted" + }, + { + "conditionType": "WHITELIST", + "matcherGroup": { + "combiner": "AND", + "matchers": [ + { + "keySelector": null, + "matcherType": "WHITELIST", + "negate": false, + "userDefinedSegmentMatcherData": null, + "whitelistMatcherData": { + "whitelist": [ + "bla" + ] + }, + "unaryNumericMatcherData": null, + "betweenMatcherData": null, + "booleanMatcherData": null, + "dependencyMatcherData": null, + "stringMatcherData": null + } + ] + }, + "partitions": [ + { + "treatment": "off", + "size": 100 + } + ], + "label": "whitelisted" + }, + { + "conditionType": "ROLLOUT", + "matcherGroup": { + "combiner": "AND", + "matchers": [ + { + "keySelector": { + "trafficType": "account", + "attribute": null + }, + "matcherType": "ALL_KEYS", + "negate": false, + "userDefinedSegmentMatcherData": null, + "whitelistMatcherData": null, + "unaryNumericMatcherData": null, + "betweenMatcherData": null, + "booleanMatcherData": null, + "dependencyMatcherData": null, + "stringMatcherData": null + } + ] + }, + "partitions": [ + { + "treatment": "on", + "size": 0 + }, + { + "treatment": "off", + "size": 100 + }, + { + "treatment": "visa", + "size": 0 + } + ], + "label": "in segment all" + } + ] + }, + { + "trafficTypeName": "account", + "name": "FACUNDO_TEST_2", + "trafficAllocation": 59, + "trafficAllocationSeed": -2108186082, + "seed": -1947050785, + "status": "ACTIVE", + "killed": false, + "defaultTreatment": "off", + "changeNumber": 1506703262916, + "algo": 2, + "conditions": [ + { + "conditionType": "WHITELIST", + "matcherGroup": { + "combiner": "AND", + "matchers": [ + { + "keySelector": null, + "matcherType": "WHITELIST", + "negate": false, + "userDefinedSegmentMatcherData": null, + "whitelistMatcherData": { + "whitelist": [ + "nico_test", + "othertest" + ] + }, + "unaryNumericMatcherData": null, + "betweenMatcherData": null, + "booleanMatcherData": null, + "dependencyMatcherData": null, + "stringMatcherData": null + } + ] + }, + "partitions": [ + { + "treatment": "on", + "size": 100 + } + ], + "label": "whitelisted" + }, + { + "conditionType": "WHITELIST", + "matcherGroup": { + "combiner": "AND", + "matchers": [ + { + "keySelector": null, + "matcherType": "WHITELIST", + "negate": false, + "userDefinedSegmentMatcherData": null, + "whitelistMatcherData": { + "whitelist": [ + "bla" + ] + }, + "unaryNumericMatcherData": null, + "betweenMatcherData": null, + "booleanMatcherData": null, + "dependencyMatcherData": null, + "stringMatcherData": null + } + ] + }, + "partitions": [ + { + "treatment": "off", + "size": 100 + } + ], + "label": "whitelisted" + }, + { + "conditionType": "ROLLOUT", + "matcherGroup": { + "combiner": "AND", + "matchers": [ + { + "keySelector": { + "trafficType": "account", + "attribute": null + }, + "matcherType": "ALL_KEYS", + "negate": false, + "userDefinedSegmentMatcherData": null, + "whitelistMatcherData": null, + "unaryNumericMatcherData": null, + "betweenMatcherData": null, + "booleanMatcherData": null, + "dependencyMatcherData": null, + "stringMatcherData": null + } + ] + }, + "partitions": [ + { + "treatment": "on", + "size": 0 + }, + { + "treatment": "off", + "size": 100 + }, + { + "treatment": "visa", + "size": 0 + } + ], + "label": "in segment all" + } + ] + } + ], + "s": -1, + "t": 1506703262916 + }, + "rbs": { + "d": [], + "s": 1506703262920, + "t": 1506703263000 + } +} \ No newline at end of file diff --git a/src/test/resources/split_changes_with_prerequisites.json b/src/test/resources/split_changes_with_prerequisites.json new file mode 100644 index 000000000..c499582a2 --- /dev/null +++ b/src/test/resources/split_changes_with_prerequisites.json @@ -0,0 +1,283 @@ +{ + "ff": { + "splits": [ + { + "trafficTypeName": "user", + "name": "parent_split", + "trafficAllocation": 100, + "trafficAllocationSeed": 1012950810, + "seed": -725161385, + "status": "ACTIVE", + "killed": false, + "defaultTreatment": "off", + "changeNumber": 1494364996459, + "algo": 2, + "conditions": [ + { + "conditionType": "ROLLOUT", + "matcherGroup": { + "combiner": "AND", + "matchers": [ + { + "keySelector": { + "trafficType": "user", + "attribute": null + }, + "matcherType": "ALL_KEYS", + "negate": false, + "userDefinedSegmentMatcherData": null, + "whitelistMatcherData": null, + "unaryNumericMatcherData": null, + "betweenMatcherData": null, + "booleanMatcherData": null, + "dependencyMatcherData": null, + "stringMatcherData": null + } + ] + }, + "partitions": [ + { + "treatment": "on", + "size": 100 + }, + { + "treatment": "off", + "size": 0 + } + ], + "label": "in segment all" + } + ], + "configurations": { + "on": "{\"color\": \"blue\"}", + "off": "{\"color\": \"red\"}" + }, + "prerequisites": [ + { + "n": "child_split_1", + "ts": ["on"] + }, + { + "n": "child_split_2", + "ts": ["on"] + } + ] + }, + { + "trafficTypeName": "user", + "name": "child_split_1", + "trafficAllocation": 100, + "trafficAllocationSeed": 1012950810, + "seed": -725161385, + "status": "ACTIVE", + "killed": false, + "defaultTreatment": "off", + "changeNumber": 1494364996459, + "algo": 2, + "conditions": [ + { + "conditionType": "ROLLOUT", + "matcherGroup": { + "combiner": "AND", + "matchers": [ + { + "keySelector": { + "trafficType": "user", + "attribute": null + }, + "matcherType": "ALL_KEYS", + "negate": false, + "userDefinedSegmentMatcherData": null, + "whitelistMatcherData": null, + "unaryNumericMatcherData": null, + "betweenMatcherData": null, + "booleanMatcherData": null, + "dependencyMatcherData": null, + "stringMatcherData": null + } + ] + }, + "partitions": [ + { + "treatment": "on", + "size": 100 + }, + { + "treatment": "off", + "size": 0 + } + ], + "label": "in segment all" + } + ] + }, + { + "trafficTypeName": "user", + "name": "child_split_2", + "trafficAllocation": 100, + "trafficAllocationSeed": 1012950810, + "seed": -725161385, + "status": "ACTIVE", + "killed": false, + "defaultTreatment": "off", + "changeNumber": 1494364996459, + "algo": 2, + "conditions": [ + { + "conditionType": "ROLLOUT", + "matcherGroup": { + "combiner": "AND", + "matchers": [ + { + "keySelector": { + "trafficType": "user", + "attribute": null + }, + "matcherType": "ALL_KEYS", + "negate": false, + "userDefinedSegmentMatcherData": null, + "whitelistMatcherData": null, + "unaryNumericMatcherData": null, + "betweenMatcherData": null, + "booleanMatcherData": null, + "dependencyMatcherData": null, + "stringMatcherData": null + } + ] + }, + "partitions": [ + { + "treatment": "on", + "size": 0 + }, + { + "treatment": "off", + "size": 100 + } + ], + "label": "in segment all" + } + ] + }, + { + "trafficTypeName": "user", + "name": "parent_split_with_one_failing_prerequisite", + "trafficAllocation": 100, + "trafficAllocationSeed": 1012950810, + "seed": -725161385, + "status": "ACTIVE", + "killed": false, + "defaultTreatment": "off", + "changeNumber": 1494364996459, + "algo": 2, + "conditions": [ + { + "conditionType": "ROLLOUT", + "matcherGroup": { + "combiner": "AND", + "matchers": [ + { + "keySelector": { + "trafficType": "user", + "attribute": null + }, + "matcherType": "ALL_KEYS", + "negate": false, + "userDefinedSegmentMatcherData": null, + "whitelistMatcherData": null, + "unaryNumericMatcherData": null, + "betweenMatcherData": null, + "booleanMatcherData": null, + "dependencyMatcherData": null, + "stringMatcherData": null + } + ] + }, + "partitions": [ + { + "treatment": "on", + "size": 100 + }, + { + "treatment": "off", + "size": 0 + } + ], + "label": "in segment all" + } + ], + "prerequisites": [ + { + "n": "child_split_1", + "ts": ["on"] + }, + { + "n": "child_split_2", + "ts": ["on"] + } + ] + }, + { + "trafficTypeName": "user", + "name": "parent_split_with_non_existent_prerequisite", + "trafficAllocation": 100, + "trafficAllocationSeed": 1012950810, + "seed": -725161385, + "status": "ACTIVE", + "killed": false, + "defaultTreatment": "off", + "changeNumber": 1494364996459, + "algo": 2, + "conditions": [ + { + "conditionType": "ROLLOUT", + "matcherGroup": { + "combiner": "AND", + "matchers": [ + { + "keySelector": { + "trafficType": "user", + "attribute": null + }, + "matcherType": "ALL_KEYS", + "negate": false, + "userDefinedSegmentMatcherData": null, + "whitelistMatcherData": null, + "unaryNumericMatcherData": null, + "betweenMatcherData": null, + "booleanMatcherData": null, + "dependencyMatcherData": null, + "stringMatcherData": null + } + ] + }, + "partitions": [ + { + "treatment": "on", + "size": 100 + }, + { + "treatment": "off", + "size": 0 + } + ], + "label": "in segment all" + } + ], + "prerequisites": [ + { + "n": "non_existent_split", + "ts": ["on"] + } + ] + } + ], + "since": -1, + "till": 1506703262916 + }, + "rbs": { + "d": [], + "s": -1, + "t": 1506703261916 + } +}