diff --git a/src/androidTest/java/helper/TestableSplitConfigBuilder.java b/src/androidTest/java/helper/TestableSplitConfigBuilder.java index 2854673cb..8b21cfd49 100644 --- a/src/androidTest/java/helper/TestableSplitConfigBuilder.java +++ b/src/androidTest/java/helper/TestableSplitConfigBuilder.java @@ -6,6 +6,7 @@ import io.split.android.client.ServiceEndpoints; import io.split.android.client.SplitClientConfig; import io.split.android.client.SyncConfig; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; import io.split.android.client.impressions.ImpressionListener; import io.split.android.client.network.CertificatePinningConfiguration; import io.split.android.client.network.DevelopmentSslConfig; @@ -67,6 +68,7 @@ public class TestableSplitConfigBuilder { private CertificatePinningConfiguration mCertificatePinningConfiguration; private long mImpressionsDedupeTimeInterval = ServiceConstants.DEFAULT_IMPRESSIONS_DEDUPE_TIME_INTERVAL; private RolloutCacheConfiguration mRolloutCacheConfiguration = RolloutCacheConfiguration.builder().build(); + private FallbackTreatmentsConfiguration mFallbackTreatments; private ProxyConfiguration mProxyConfiguration = null; public TestableSplitConfigBuilder() { @@ -283,6 +285,11 @@ public TestableSplitConfigBuilder rolloutCacheConfiguration(RolloutCacheConfigur return this; } + public TestableSplitConfigBuilder fallbackTreatments(FallbackTreatmentsConfiguration fallbackTreatments) { + this.mFallbackTreatments = fallbackTreatments; + return this; + } + public TestableSplitConfigBuilder logger(ProxyConfiguration proxyConfiguration) { this.mProxyConfiguration = proxyConfiguration; return this; @@ -345,7 +352,8 @@ public SplitClientConfig build() { mCertificatePinningConfiguration, mImpressionsDedupeTimeInterval, mRolloutCacheConfiguration, - mProxyConfiguration); + mProxyConfiguration, + mFallbackTreatments); Logger.instance().setLevel(mLogLevel); return config; diff --git a/src/androidTest/java/tests/integration/fallback/FallbackTreatmentsTest.java b/src/androidTest/java/tests/integration/fallback/FallbackTreatmentsTest.java new file mode 100644 index 000000000..53c853af6 --- /dev/null +++ b/src/androidTest/java/tests/integration/fallback/FallbackTreatmentsTest.java @@ -0,0 +1,557 @@ +package tests.integration.fallback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import helper.IntegrationHelper; +import io.split.android.client.ServiceEndpoints; +import io.split.android.client.SplitClient; +import io.split.android.client.SplitClientConfig; +import io.split.android.client.SplitFactory; +import io.split.android.client.SplitResult; +import io.split.android.client.api.Key; +import io.split.android.client.events.SplitEvent; +import io.split.android.client.events.SplitEventTask; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; +import io.split.android.client.fallback.FallbackTreatment; +import io.split.android.client.impressions.Impression; +import io.split.android.client.impressions.ImpressionListener; +import io.split.android.client.service.impressions.ImpressionsMode; +import io.split.android.client.utils.logger.SplitLogLevel; +import io.split.android.grammar.Treatments; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +public class FallbackTreatmentsTest { + + private Context mContext; + private MockWebServer mWebServer; + private int mCurSplitReqId; + + private ServiceEndpoints endpoints() { + final String url = mWebServer.url("/").url().toString(); + return ServiceEndpoints.builder() + .apiEndpoint(url) + .eventsEndpoint(url) + .build(); + } + + // Helpers + private static ImpressionListener createImpressionCapturingListener(final List sink) { + return new ImpressionListener() { + @Override + public void log(Impression impression) { sink.add(impression); } + @Override + public void close() { } + }; + } + + private static SplitClientConfig buildDebugConfigWithListener(ServiceEndpoints endpoints, + FallbackTreatmentsConfiguration fbConfig, + ImpressionListener listener) { + return SplitClientConfig.builder() + .serviceEndpoints(endpoints) + .ready(30000) + .featuresRefreshRate(3) + .segmentsRefreshRate(3) + .trafficType("account") + .impressionsRefreshRate(1) + .impressionsMode(ImpressionsMode.DEBUG) + .fallbackTreatments(fbConfig) + .impressionListener(listener) + .build(); + } + + private static void assertPayloadHasOnlyKnownFlagNoDnf(String body) { + boolean hasKnown = body.contains("\"f\":\"real_flag\"") || body.contains("real_flag"); + boolean hasUnknownFlag = body.contains("\"f\":\"dnf_flag\""); + boolean hasDnfLabel = body.contains("\"r\":\"definition not found\""); + boolean hasFallbackDnfLabel = body.contains("fallback - definition not found"); + + assertTrue("Expected at least one impression for real_flag", hasKnown); + assertFalse("Unknown flag should not produce impressions", hasUnknownFlag); + assertFalse("Label 'definition not found' should not appear in impressions", hasDnfLabel); + assertFalse("Label 'fallback - definition not found' should not appear in impressions", hasFallbackDnfLabel); + } + + private static void assertLocalNoUnknownOrDnf(List captured) { + assertEquals("Expected exactly one impression locally (real_flag)", 1, captured.size()); + Impression imp = captured.get(0); + assertEquals("real_flag", imp.split()); + String label = imp.appliedRule(); + assertFalse("Label 'definition not found' should not appear in impressions (listener)", + "definition not found".equals(label)); + assertFalse("Label 'fallback - definition not found' should not appear in impressions (listener)", + label != null && label.contains("fallback - definition not found")); + } + + private SplitClientConfig buildConfig(FallbackTreatmentsConfiguration fbConfig) { + return buildConfig(fbConfig, false, null); + } + + private SplitClientConfig buildConfig(FallbackTreatmentsConfiguration fbConfig, boolean debugImpressions, Integer impressionsRefreshRate) { + SplitClientConfig.Builder builder = SplitClientConfig.builder() + .serviceEndpoints(endpoints()) + .ready(30000) + .featuresRefreshRate(3) + .segmentsRefreshRate(3) + .logLevel(SplitLogLevel.VERBOSE) + .trafficType("account"); + if (impressionsRefreshRate != null) { + builder.impressionsRefreshRate(impressionsRefreshRate); + } else { + builder.impressionsRefreshRate(3); + } + if (debugImpressions) { + builder.impressionsMode(ImpressionsMode.DEBUG); + } + if (fbConfig != null) { + builder.fallbackTreatments(fbConfig); + } + return builder.build(); + } + + private SplitFactory buildFactory(SplitClientConfig config) { + return IntegrationHelper.buildFactory( + IntegrationHelper.dummyApiKey(), new Key("DEFAULT_KEY"), config, mContext, null); + } + + private void awaitReady(SplitClient client) throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + client.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client) { + latch.countDown(); + } + }); + latch.await(30, TimeUnit.SECONDS); + } + + @Before + public void setup() { + mWebServer = new MockWebServer(); + mCurSplitReqId = 1; + final Dispatcher dispatcher = new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + final String path = request.getPath(); + if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) { + return new MockResponse().setResponseCode(200).setBody(IntegrationHelper.dummyAllSegments()); + } else if (path.contains("/splitChanges")) { + // Return empty changes to keep no real flags available + long id = mCurSplitReqId++; + return new MockResponse().setResponseCode(200) + .setBody(IntegrationHelper.emptyTargetingRulesChanges(id, id)); + } else if (path.contains("/testImpressions/bulk")) { + return new MockResponse().setResponseCode(200); + } + return new MockResponse().setResponseCode(404); + } + }; + mWebServer.setDispatcher(dispatcher); + mContext = InstrumentationRegistry.getInstrumentation().getContext(); + } + + @After + public void tearDown() throws Exception { + if (mWebServer != null) mWebServer.shutdown(); + } + + @Test + public void case1_controlTreatment_noFallbacks_returnsControlForUnknownFlags_andTwoKeys() throws Exception { + SplitClientConfig config = buildConfig(null); + + SplitFactory factory = buildFactory(config); + + SplitClient clientKey1 = factory.client(new Key("key_1")); + SplitClient clientKey2 = factory.client(new Key("key_2")); + + awaitReady(clientKey1); + + String t1_flag1 = clientKey1.getTreatment("non_existent_flag"); + String t1_flag2 = clientKey1.getTreatment("non_existent_flag_2"); + String t2_flag1 = clientKey2.getTreatment("non_existent_flag"); + String t2_flag2 = clientKey2.getTreatment("non_existent_flag_2"); + + // Assert + assertEquals(Treatments.CONTROL, t1_flag1); + assertEquals(Treatments.CONTROL, t1_flag2); + assertEquals(Treatments.CONTROL, t2_flag1); + assertEquals(Treatments.CONTROL, t2_flag2); + + factory.destroy(); + } + + @Test + public void case6_impressionsCorrectnessWithFallbackLabelsPrefixedForOverriddenFlagOnlyNotReadyForOthers() throws Exception { + final String url = mWebServer.url("/").url().toString(); + ServiceEndpoints endpoints = ServiceEndpoints.builder() + .apiEndpoint(url) + .eventsEndpoint(url) + .build(); + + final StringBuilder postedImpressions = new StringBuilder(); + final CountDownLatch impressionsLatch = new CountDownLatch(1); + mCurSplitReqId = 1; + final Dispatcher dispatcher = new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + final String path = request.getPath(); + if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) { + return new MockResponse().setResponseCode(200).setBody(IntegrationHelper.dummyAllSegments()); + } else if (path.contains("/splitChanges")) { + long id = mCurSplitReqId++; + // Keep no real flags to ensure not-ready path applies before SDK ready + return new MockResponse().setResponseCode(200) + .setBody(IntegrationHelper.emptyTargetingRulesChanges(id, id)); + } else if (path.contains("/testImpressions/bulk")) { + try { + // Capture body for assertions + postedImpressions.append(request.getBody().readUtf8()); + } catch (Exception ignore) { } + impressionsLatch.countDown(); + return new MockResponse().setResponseCode(200); + } + return new MockResponse().setResponseCode(404); + } + }; + mWebServer.setDispatcher(dispatcher); + + Map byFlag = new HashMap<>(); + byFlag.put("any_flag", new FallbackTreatment("OFF_FALLBACK")); + FallbackTreatmentsConfiguration fbConfig = FallbackTreatmentsConfiguration.builder() + .byFlag(byFlag) + .build(); + + SplitClientConfig config = buildConfig(fbConfig, true, 1); + + SplitFactory factory = buildFactory(config); + + SplitClient c = factory.client(new Key("key_1")); + + String t_overridden = c.getTreatment("any_flag"); + String t_other = c.getTreatment("other_flag"); + Thread.sleep(1000); + c.flush(); + + impressionsLatch.await(5, TimeUnit.SECONDS); + + assertEquals("OFF_FALLBACK", t_overridden); + + String body = postedImpressions.toString(); + System.out.println("IMPRESSIONS BODY: " + body); + boolean hasPrefixed = body.contains("\"f\":\"any_flag\"") && body.contains("\"r\":\"fallback - not ready\""); + boolean hasPlain = body.contains("\"f\":\"other_flag\"") && body.contains("\"r\":\"not ready\""); + if (!hasPrefixed || !hasPlain) { + hasPrefixed = body.contains("fallback - not ready"); + hasPlain = body.contains("\"r\":\"not ready\""); + } + assertTrue("Expected impression with label 'fallback - not ready' for any_flag", hasPrefixed); + assertTrue("Expected impression with label 'not ready' for other_flag", hasPlain); + + factory.destroy(); + } + + @Test + public void case5_overrideAppliesOnlyWhenOriginalWouldBeControlRealFlagUnaffectedUnknownGetsFallback() throws Exception { + final String url = mWebServer.url("/").url().toString(); + ServiceEndpoints endpoints = ServiceEndpoints.builder() + .apiEndpoint(url) + .eventsEndpoint(url) + .build(); + + final Dispatcher dispatcher = new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + final String path = request.getPath(); + if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) { + return new MockResponse().setResponseCode(200).setBody(IntegrationHelper.dummyAllSegments()); + } else if (path.contains("/splitChanges")) { + String change = IntegrationHelper.loadSplitChanges(mContext, "simple_split.json"); + change = change.replace("\"workm\"", "\"real_flag\""); + return new MockResponse().setResponseCode(200).setBody(change); + } else if (path.contains("/testImpressions/bulk")) { + return new MockResponse().setResponseCode(200); + } + return new MockResponse().setResponseCode(404); + } + }; + mWebServer.setDispatcher(dispatcher); + + FallbackTreatmentsConfiguration fbConfig = FallbackTreatmentsConfiguration.builder() + .global(new FallbackTreatment("OFF_FALLBACK")) + .build(); + + SplitClientConfig config = buildConfig(fbConfig); + + SplitFactory factory = buildFactory(config); + + SplitClient clientKey1 = factory.client(new Key("key_1")); + + awaitReady(clientKey1); + + String realFlag = clientKey1.getTreatment("real_flag"); + String unknown = clientKey1.getTreatment("non_existent_flag"); + + assertEquals("on", realFlag); + assertEquals("OFF_FALLBACK", unknown); + + factory.destroy(); + } + + @Test + public void case4_FlagOverrideBeatsFactoryDefaultReturnsOnFallbackForOverriddenAndOffFallbackForOthers() throws Exception { + Map byFlag = new HashMap<>(); + byFlag.put("my_flag", new FallbackTreatment("ON_FALLBACK")); + FallbackTreatmentsConfiguration fbConfig = FallbackTreatmentsConfiguration.builder() + .global(new FallbackTreatment("OFF_FALLBACK")) + .byFlag(byFlag) + .build(); + + SplitClientConfig config = buildConfig(fbConfig); + + SplitFactory factory = buildFactory(config); + + SplitClient clientKey1 = factory.client(new Key("key_1")); + SplitClient clientKey2 = factory.client(new Key("key_2")); + + awaitReady(clientKey1); + + String t1_myFlag = clientKey1.getTreatment("my_flag"); + String t1_other = clientKey1.getTreatment("non_existent_flag_2"); + String t2_myFlag = clientKey2.getTreatment("my_flag"); + String t2_other = clientKey2.getTreatment("non_existent_flag_2"); + + assertEquals("ON_FALLBACK", t1_myFlag); + assertEquals("OFF_FALLBACK", t1_other); + assertEquals("ON_FALLBACK", t2_myFlag); + assertEquals("OFF_FALLBACK", t2_other); + + factory.destroy(); + } + + @Test + public void case2_factoryWideOverrideReturnsFallbackForUnknownFlagsAndTwoKeys() throws Exception { + // endpoints provided by helper in buildConfig + + FallbackTreatmentsConfiguration fbConfig = FallbackTreatmentsConfiguration.builder() + .global(new FallbackTreatment("FALLBACK_TREATMENT")) + .build(); + + SplitClientConfig config = buildConfig(fbConfig); + + SplitFactory factory = buildFactory(config); + + SplitClient clientKey1 = factory.client(new Key("key_1")); + SplitClient clientKey2 = factory.client(new Key("key_2")); + + CountDownLatch readyLatch = new CountDownLatch(1); + clientKey1.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client) { + readyLatch.countDown(); + } + }); + readyLatch.await(5, TimeUnit.SECONDS); + + String t1_flag1 = clientKey1.getTreatment("non_existent_flag"); + String t1_flag2 = clientKey1.getTreatment("non_existent_flag_2"); + String t2_flag1 = clientKey2.getTreatment("non_existent_flag"); + String t2_flag2 = clientKey2.getTreatment("non_existent_flag_2"); + + assertEquals("FALLBACK_TREATMENT", t1_flag1); + assertEquals("FALLBACK_TREATMENT", t1_flag2); + assertEquals("FALLBACK_TREATMENT", t2_flag1); + assertEquals("FALLBACK_TREATMENT", t2_flag2); + + factory.destroy(); + } + + @Test + public void case3_factorySpecificOverrideReturnsFallbackForOneFlagAndControlForOthersAndTwoKeys() throws Exception { + final String url = mWebServer.url("/").url().toString(); + ServiceEndpoints endpoints = ServiceEndpoints.builder() + .apiEndpoint(url) + .eventsEndpoint(url) + .build(); + + Map byFlag = new HashMap<>(); + byFlag.put("non_existent_flag", new FallbackTreatment("FALLBACK_TREATMENT")); + FallbackTreatmentsConfiguration fbConfig = FallbackTreatmentsConfiguration.builder() + .byFlag(byFlag) + .build(); + + SplitClientConfig config = SplitClientConfig.builder() + .serviceEndpoints(endpoints) + .ready(30000) + .featuresRefreshRate(3) + .segmentsRefreshRate(3) + .impressionsRefreshRate(3) + .logLevel(SplitLogLevel.DEBUG) + .trafficType("account") + .fallbackTreatments(fbConfig) + .build(); + + SplitFactory factory = IntegrationHelper.buildFactory( + IntegrationHelper.dummyApiKey(), new Key("DEFAULT_KEY"), config, mContext, null); + + SplitClient clientKey1 = factory.client(new Key("key_1")); + SplitClient clientKey2 = factory.client(new Key("key_2")); + + CountDownLatch readyLatch = new CountDownLatch(1); + clientKey1.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client) { + readyLatch.countDown(); + } + }); + readyLatch.await(5, TimeUnit.SECONDS); + + String t1_flag1 = clientKey1.getTreatment("non_existent_flag"); + String t1_flag2 = clientKey1.getTreatment("non_existent_flag_2"); + String t2_flag1 = clientKey2.getTreatment("non_existent_flag"); + String t2_flag2 = clientKey2.getTreatment("non_existent_flag_2"); + + assertEquals("FALLBACK_TREATMENT", t1_flag1); + assertEquals(Treatments.CONTROL, t1_flag2); + assertEquals("FALLBACK_TREATMENT", t2_flag1); + assertEquals(Treatments.CONTROL, t2_flag2); + + factory.destroy(); + } + + @Test + public void case7_fallbackDynamicConfigPropagationTreatmentAndConfigReturned() throws Exception { + final String url = mWebServer.url("/").url().toString(); + ServiceEndpoints endpoints = ServiceEndpoints.builder() + .apiEndpoint(url) + .eventsEndpoint(url) + .build(); + + + Map byFlag = new HashMap<>(); + byFlag.put("my_flag", new FallbackTreatment("ON_FALLBACK", "{\"flag\":true}")); + FallbackTreatmentsConfiguration fbConfig = FallbackTreatmentsConfiguration.builder() + .global(new FallbackTreatment("OFF_FALLBACK", "{\"global\":true}")) + .byFlag(byFlag) + .build(); + + SplitClientConfig config = SplitClientConfig.builder() + .serviceEndpoints(endpoints) + .ready(30000) + .featuresRefreshRate(3) + .segmentsRefreshRate(3) + .impressionsRefreshRate(3) + .logLevel(SplitLogLevel.DEBUG) + .trafficType("account") + .fallbackTreatments(fbConfig) + .build(); + + SplitFactory factory = IntegrationHelper.buildFactory( + IntegrationHelper.dummyApiKey(), new Key("DEFAULT_KEY"), config, mContext, null); + + SplitClient client = factory.client(new Key("key_1")); + + CountDownLatch readyLatch = new CountDownLatch(1); + client.on(SplitEvent.SDK_READY, new SplitEventTask() { + @Override + public void onPostExecution(SplitClient client) { + readyLatch.countDown(); + } + }); + readyLatch.await(5, TimeUnit.SECONDS); + + SplitResult rMy = client.getTreatmentWithConfig("my_flag", null); + SplitResult rUnknown = client.getTreatmentWithConfig("non_existent_flag", null); + + assertEquals("ON_FALLBACK", rMy.treatment()); + assertEquals("{\"flag\":true}", rMy.config()); + assertEquals("OFF_FALLBACK", rUnknown.treatment()); + assertEquals("{\"global\":true}", rUnknown.config()); + + factory.destroy(); + } + + @Test + public void case8_noImpressionsForDefinitionNotFoundOrFallbackDefinitionNotFoundAfterReady() throws Exception { + final String url = mWebServer.url("/").url().toString(); + ServiceEndpoints endpoints = ServiceEndpoints.builder() + .apiEndpoint(url) + .eventsEndpoint(url) + .build(); + + final StringBuilder postedImpressions = new StringBuilder(); + final CountDownLatch impressionsLatch = new CountDownLatch(1); + mCurSplitReqId = 1; + final Dispatcher dispatcher = new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + final String path = request.getPath(); + if (path.contains("/" + IntegrationHelper.ServicePath.MEMBERSHIPS)) { + return new MockResponse().setResponseCode(200).setBody(IntegrationHelper.dummyAllSegments()); + } else if (path.contains("/splitChanges")) { + // Serve a real flag so we do generate impressions in DEBUG mode + String change = IntegrationHelper.loadSplitChanges(mContext, "simple_split.json"); + change = change.replace("\"workm\"", "\"real_flag\""); + return new MockResponse().setResponseCode(200).setBody(change); + } else if (path.contains("/testImpressions/bulk")) { + try { + postedImpressions.append(request.getBody().readUtf8()); + } catch (Exception ignore) { } + impressionsLatch.countDown(); + return new MockResponse().setResponseCode(200); + } + return new MockResponse().setResponseCode(404); + } + }; + mWebServer.setDispatcher(dispatcher); + + // Configure global fallback so unknown flags return a fallback treatment + FallbackTreatmentsConfiguration fbConfig = FallbackTreatmentsConfiguration.builder() + .global(new FallbackTreatment("OFF_FALLBACK")) + .build(); + + final List capturedImpressions = Collections.synchronizedList(new ArrayList<>()); + ImpressionListener listener = createImpressionCapturingListener(capturedImpressions); + + SplitClientConfig config = buildDebugConfigWithListener(endpoints, fbConfig, listener); + SplitFactory factory = buildFactory(config); + + SplitClient client = factory.client(new Key("key_1")); + awaitReady(client); + + // Evaluate a real flag (will log impression) and an unknown flag (should not log impression) + String tUnknown = client.getTreatment("dnf_flag"); + String tKnown = client.getTreatment("real_flag"); + + // Push impressions + Thread.sleep(1000); + client.flush(); + impressionsLatch.await(5, TimeUnit.SECONDS); + + String body = postedImpressions.toString(); + assertPayloadHasOnlyKnownFlagNoDnf(body); + assertLocalNoUnknownOrDnf(capturedImpressions); + + factory.destroy(); + } +} diff --git a/src/main/java/io/split/android/client/EvaluatorImpl.java b/src/main/java/io/split/android/client/EvaluatorImpl.java index 28166977b..7eb3db19d 100644 --- a/src/main/java/io/split/android/client/EvaluatorImpl.java +++ b/src/main/java/io/split/android/client/EvaluatorImpl.java @@ -4,6 +4,10 @@ import io.split.android.client.dtos.ConditionType; import io.split.android.client.exceptions.ChangeNumberExceptionWrapper; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; +import io.split.android.client.fallback.FallbackTreatment; +import io.split.android.client.fallback.FallbackTreatmentsCalculator; +import io.split.android.client.fallback.FallbackTreatmentsCalculatorImpl; import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.client.utils.logger.Logger; import io.split.android.engine.experiments.ParsedCondition; @@ -11,16 +15,21 @@ 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; public class EvaluatorImpl implements Evaluator { private final SplitsStorage mSplitsStorage; private final SplitParser mSplitParser; + private final FallbackTreatmentsCalculator mFallbackCalculator; public EvaluatorImpl(SplitsStorage splitsStorage, SplitParser splitParser) { + this(splitsStorage, splitParser, new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); + } + + public EvaluatorImpl(SplitsStorage splitsStorage, SplitParser splitParser, FallbackTreatmentsCalculator fallbackCalculator) { mSplitsStorage = splitsStorage; mSplitParser = splitParser; + mFallbackCalculator = fallbackCalculator; } @Override @@ -29,16 +38,19 @@ public EvaluationResult getTreatment(String matchingKey, String bucketingKey, St try { ParsedSplit parsedSplit = mSplitParser.parse(mSplitsStorage.get(splitName), matchingKey); if (parsedSplit == null) { - return new EvaluationResult(Treatments.CONTROL, TreatmentLabels.DEFINITION_NOT_FOUND, true); + FallbackTreatment fallback = mFallbackCalculator.resolve(splitName, TreatmentLabels.DEFINITION_NOT_FOUND); + return new EvaluationResult(fallback.getTreatment(), fallback.getLabel(), null, fallback.getConfig(), true); } return getTreatment(matchingKey, bucketingKey, parsedSplit, attributes); } catch (ChangeNumberExceptionWrapper ex) { Logger.e(ex, "Catch Change Number Exception"); - return new EvaluationResult(Treatments.CONTROL, TreatmentLabels.EXCEPTION, ex.changeNumber(), true); + FallbackTreatment fallback = mFallbackCalculator.resolve(splitName, TreatmentLabels.EXCEPTION); + return new EvaluationResult(fallback.getTreatment(), fallback.getLabel(), ex.changeNumber(), fallback.getConfig(), true); } catch (Exception e) { Logger.e(e, "Catch All Exception"); - return new EvaluationResult(Treatments.CONTROL, TreatmentLabels.EXCEPTION, true); + FallbackTreatment fallback = mFallbackCalculator.resolve(splitName, TreatmentLabels.EXCEPTION); + return new EvaluationResult(fallback.getTreatment(), fallback.getLabel(), null, fallback.getConfig(), true); } } @@ -95,7 +107,7 @@ private EvaluationResult getTreatment(String matchingKey, String bucketingKey, P } if (parsedCondition.matcher().match(matchingKey, bucketingKey, attributes, this)) { - String treatment = Splitter.getTreatment(bk, parsedSplit.seed(), parsedCondition.partitions(), parsedSplit.algo()); + String treatment = Splitter.getTreatment(bk, parsedSplit.seed(), parsedCondition.partitions(), parsedSplit.algo(), mFallbackCalculator); return new EvaluationResult(treatment, parsedCondition.label(), parsedSplit.changeNumber(), configForTreatment(parsedSplit, treatment), parsedSplit.impressionsDisabled()); } } diff --git a/src/main/java/io/split/android/client/SplitClientConfig.java b/src/main/java/io/split/android/client/SplitClientConfig.java index 004db0ad0..3660ff761 100644 --- a/src/main/java/io/split/android/client/SplitClientConfig.java +++ b/src/main/java/io/split/android/client/SplitClientConfig.java @@ -29,6 +29,7 @@ import io.split.android.client.utils.logger.SplitLogLevel; import io.split.android.client.validators.PrefixValidatorImpl; import io.split.android.client.validators.ValidationErrorInfo; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; /** * Configurations for the SplitClient. @@ -136,6 +137,8 @@ public class SplitClientConfig { private final RolloutCacheConfiguration mRolloutCacheConfiguration; @Nullable private final ProxyConfiguration mProxyConfiguration; + @Nullable + private final FallbackTreatmentsConfiguration mFallbackTreatments; public static Builder builder() { return new Builder(); @@ -192,7 +195,8 @@ private SplitClientConfig(String endpoint, CertificatePinningConfiguration certificatePinningConfiguration, long impressionsDedupeTimeInterval, @NonNull RolloutCacheConfiguration rolloutCacheConfiguration, - @Nullable ProxyConfiguration proxyConfiguration) { + @Nullable ProxyConfiguration proxyConfiguration, + @Nullable FallbackTreatmentsConfiguration fallbackTreatments) { mEndpoint = endpoint; mEventsEndpoint = eventsEndpoint; mTelemetryEndpoint = telemetryEndpoint; @@ -252,6 +256,7 @@ private SplitClientConfig(String endpoint, mImpressionsDedupeTimeInterval = impressionsDedupeTimeInterval; mRolloutCacheConfiguration = rolloutCacheConfiguration; mProxyConfiguration = proxyConfiguration; + mFallbackTreatments = fallbackTreatments; } public String trafficType() { @@ -506,6 +511,11 @@ public RolloutCacheConfiguration rolloutCacheConfiguration() { return mRolloutCacheConfiguration; } + @Nullable + public FallbackTreatmentsConfiguration fallbackTreatments() { + return mFallbackTreatments; + } + public static final class Builder { static final int PROXY_PORT_DEFAULT = 80; @@ -583,6 +593,8 @@ public static final class Builder { private long mImpressionsDedupeTimeInterval = ServiceConstants.DEFAULT_IMPRESSIONS_DEDUPE_TIME_INTERVAL; private RolloutCacheConfiguration mRolloutCacheConfiguration = RolloutCacheConfiguration.builder().build(); + @Nullable + private FallbackTreatmentsConfiguration mFallbackTreatments = null; private ProxyConfiguration mProxyConfiguration = null; @@ -590,6 +602,11 @@ public Builder() { mServiceEndpoints = ServiceEndpoints.builder().build(); } + public Builder fallbackTreatments(@Nullable FallbackTreatmentsConfiguration fallbackTreatments) { + mFallbackTreatments = fallbackTreatments; + return this; + } + /** * Default Traffic Type to use in .track method * @@ -1289,7 +1306,8 @@ public SplitClientConfig build() { mCertificatePinningConfiguration, mImpressionsDedupeTimeInterval, mRolloutCacheConfiguration, - mProxyConfiguration); + mProxyConfiguration, + mFallbackTreatments); } private HttpProxy parseProxyHost(String proxyUri, ProxyConfiguration proxyConfiguration) { diff --git a/src/main/java/io/split/android/client/SplitClientFactoryImpl.java b/src/main/java/io/split/android/client/SplitClientFactoryImpl.java index d2ab82416..79ab93f9d 100644 --- a/src/main/java/io/split/android/client/SplitClientFactoryImpl.java +++ b/src/main/java/io/split/android/client/SplitClientFactoryImpl.java @@ -84,7 +84,8 @@ public SplitClientFactoryImpl(@NonNull SplitFactory splitFactory, mStorageContainer.getTelemetryStorage(), mSplitParser, flagSetsFilter, - splitsStorage + splitsStorage, + config.fallbackTreatments() ); } diff --git a/src/main/java/io/split/android/client/fallback/FallbackTreatment.java b/src/main/java/io/split/android/client/fallback/FallbackTreatment.java new file mode 100644 index 000000000..b596bd37b --- /dev/null +++ b/src/main/java/io/split/android/client/fallback/FallbackTreatment.java @@ -0,0 +1,66 @@ +package io.split.android.client.fallback; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Objects; + +/** + * Represents the fallback treatment, with an optional config and a fixed label. + */ +public final class FallbackTreatment { + + @NonNull + private final String mTreatment; + @Nullable + private final String mConfig; + @Nullable + private final String mLabel; + + public FallbackTreatment(@NonNull String treatment) { + this(treatment, null); + } + + public FallbackTreatment(@NonNull String treatment, @Nullable String config) { + this(treatment, config, null); + } + + FallbackTreatment(@NonNull String treatment, @Nullable String config, @Nullable String label) { + mTreatment = treatment; + mConfig = config; + mLabel = label; + } + + public String getTreatment() { + return mTreatment; + } + + @Nullable + public String getConfig() { + return mConfig; + } + + @Nullable + public String getLabel() { + return mLabel; + } + + FallbackTreatment copyWithLabel(String label) { + return new FallbackTreatment(mTreatment, mConfig, label); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FallbackTreatment that = (FallbackTreatment) o; + return Objects.equals(mTreatment, that.mTreatment) && + Objects.equals(mConfig, that.mConfig) && + Objects.equals(mLabel, that.mLabel); + } + + @Override + public int hashCode() { + return Objects.hash(mTreatment, mConfig, mLabel); + } +} diff --git a/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculator.java b/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculator.java new file mode 100644 index 000000000..5f9d097c5 --- /dev/null +++ b/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculator.java @@ -0,0 +1,29 @@ +package io.split.android.client.fallback; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Resolves a fallback treatment for a given flag name. + * Returns null if no fallback applies (caller should use control). + */ +public interface FallbackTreatmentsCalculator { + + /** + * Resolve a fallback for a given flag name. + * @param flagName non-null flag name + * @return a fallback treatment with a null label, if configured; otherwise "control" + */ + @NonNull + FallbackTreatment resolve(@NonNull String flagName); + + /** + * Resolve a fallback for a given flag name and label. + * + * @param flagName non-null flag name + * @param label nullable label + * @return a fallback treatment if configured, with a prefixed label if provided; otherwise "control" + */ + @NonNull + FallbackTreatment resolve(@NonNull String flagName, @Nullable String label); +} diff --git a/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorImpl.java b/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorImpl.java new file mode 100644 index 000000000..0eb727a1e --- /dev/null +++ b/src/main/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorImpl.java @@ -0,0 +1,54 @@ +package io.split.android.client.fallback; + +import static io.split.android.client.utils.Utils.checkNotNull; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Map; + +import io.split.android.grammar.Treatments; + +public final class FallbackTreatmentsCalculatorImpl implements FallbackTreatmentsCalculator { + + private static final String LABEL_PREFIX = "fallback - "; + + @NonNull + private final FallbackTreatmentsConfiguration mConfig; + + public FallbackTreatmentsCalculatorImpl(@NonNull FallbackTreatmentsConfiguration config) { + mConfig = checkNotNull(config); + } + + @NonNull + @Override + public FallbackTreatment resolve(@NonNull String flagName) { + return resolve(flagName, null); + } + + @NonNull + @Override + public FallbackTreatment resolve(@NonNull String flagName, @Nullable String label) { + Map byFlag = mConfig.getByFlag(); + if (byFlag != null) { + FallbackTreatment flagTreatment = byFlag.get(flagName); + if (flagTreatment != null) { + return flagTreatment.copyWithLabel(resolveLabel(label)); + } + } + FallbackTreatment global = mConfig.getGlobal(); + if (global != null) { + return global.copyWithLabel(resolveLabel(label)); + } + return new FallbackTreatment(Treatments.CONTROL, null, label); + } + + @Nullable + private static String resolveLabel(@Nullable String label) { + if (label == null) { + return null; + } + + return LABEL_PREFIX + label; + } +} diff --git a/src/main/java/io/split/android/client/fallback/FallbackTreatmentsConfiguration.java b/src/main/java/io/split/android/client/fallback/FallbackTreatmentsConfiguration.java new file mode 100644 index 000000000..992ebae2e --- /dev/null +++ b/src/main/java/io/split/android/client/fallback/FallbackTreatmentsConfiguration.java @@ -0,0 +1,176 @@ +package io.split.android.client.fallback; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import io.split.android.client.utils.logger.Logger; + +public final class FallbackTreatmentsConfiguration { + + @Nullable + private final FallbackTreatment mGlobal; + private final Map mByFlag; + + private FallbackTreatmentsConfiguration(@Nullable FallbackTreatment global, + @Nullable Map byFlag) { + mGlobal = global; + if (byFlag == null || byFlag.isEmpty()) { + mByFlag = Collections.emptyMap(); + } else { + mByFlag = Collections.unmodifiableMap(new HashMap<>(byFlag)); + } + } + + @Nullable + public FallbackTreatment getGlobal() { + return mGlobal; + } + + public Map getByFlag() { + return mByFlag; + } + + /** + * Creates a new {@link Builder} for {@link FallbackTreatmentsConfiguration}. + * Use this to provide an optional global fallback and flag-specific fallbacks. + */ + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + @Nullable + private FallbackTreatment mGlobal; + @Nullable + private Map mByFlag; + private FallbacksSanitizer mSanitizer; + + private Builder() { + mGlobal = null; + mByFlag = null; + mSanitizer = new FallbacksSanitizerImpl(); + } + + /** + * Sets an optional global fallback treatment to be used when no flag-specific + * fallback exists for a given flag. This value is returned only in place of + * the "control" treatment. + * + * @param global optional global {@link FallbackTreatment} + * @return this builder instance + */ + public Builder global(@Nullable FallbackTreatment global) { + if (mGlobal != null && global != null) { + Logger.w("Fallback treatments - You had previously set a global fallback. The new value will replace it"); + } + mGlobal = global; + return this; + } + + /** + * Sets an optional global fallback treatment to be used when no flag-specific + * fallback exists for a given flag. This value is returned only in place of + * the "control" treatment. + * + * @param treatment the treatment string to use as global + * @return this builder instance + */ + public Builder global(String treatment) { + if (mGlobal != null) { + Logger.w("Fallback treatments - You had previously set a global fallback. The new value will replace it"); + } + mGlobal = new FallbackTreatment(treatment); + return this; + } + + /** + * Sets optional flag-specific fallback treatments, where keys are flag names. + * These take precedence over the global fallback. + * + * @param byFlag map of flag name to {@link FallbackTreatment}; may be null or empty + * @return this builder instance + */ + public Builder byFlag(@Nullable Map byFlag) { + if (byFlag == null || byFlag.isEmpty()) { + return this; + } + if (mByFlag == null) { + mByFlag = new HashMap<>(); + } + for (Map.Entry e : byFlag.entrySet()) { + String key = e.getKey(); + if (mByFlag.containsKey(key)) { + Logger.w(getDuplicateFlagMessage(key)); + } + mByFlag.put(key, e.getValue()); + } + return this; + } + + /** + * Sets optional flag-specific fallback treatments, where keys are flag names. + * These take precedence over the global fallback. + * + * @param byFlag map of flag name to treatment string; may be null or empty + * @return this builder instance + */ + public Builder byFlagStrings(@Nullable Map byFlag) { + if (byFlag == null || byFlag.isEmpty()) { + return this; + } + if (mByFlag == null) { + mByFlag = new HashMap<>(); + } + for (Map.Entry e : byFlag.entrySet()) { + String key = e.getKey(); + if (mByFlag.containsKey(key)) { + Logger.w(getDuplicateFlagMessage(key)); + } + mByFlag.put(key, new FallbackTreatment(e.getValue())); + } + return this; + } + + /** + * Builds a {@link FallbackTreatmentsConfiguration} for the configured values. + * + * @return a new immutable {@link FallbackTreatmentsConfiguration} + */ + public FallbackTreatmentsConfiguration build() { + FallbackTreatment sanitizedGlobal = mSanitizer.sanitizeGlobal(mGlobal); + Map sanitizedByFlag = mSanitizer.sanitizeByFlag(mByFlag); + return new FallbackTreatmentsConfiguration(sanitizedGlobal, sanitizedByFlag); + } + + @VisibleForTesting + Builder sanitizer(FallbacksSanitizer sanitizer) { + mSanitizer = sanitizer; + return this; + } + + @NonNull + private static String getDuplicateFlagMessage(String key) { + return "Fallback treatments - Duplicate fallback for flag '" + key + "'. Overriding existing value."; + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FallbackTreatmentsConfiguration that = (FallbackTreatmentsConfiguration) o; + return Objects.equals(mGlobal, that.mGlobal) && + Objects.equals(mByFlag, that.mByFlag); + } + + @Override + public int hashCode() { + return Objects.hash(mGlobal, mByFlag); + } +} diff --git a/src/main/java/io/split/android/client/fallback/FallbacksSanitizer.java b/src/main/java/io/split/android/client/fallback/FallbacksSanitizer.java new file mode 100644 index 000000000..29aaf0307 --- /dev/null +++ b/src/main/java/io/split/android/client/fallback/FallbacksSanitizer.java @@ -0,0 +1,12 @@ +package io.split.android.client.fallback; + +import androidx.annotation.Nullable; +import java.util.Map; + +interface FallbacksSanitizer { + + @Nullable + FallbackTreatment sanitizeGlobal(@Nullable FallbackTreatment global); + + Map sanitizeByFlag(@Nullable Map byFlag); +} diff --git a/src/main/java/io/split/android/client/fallback/FallbacksSanitizerImpl.java b/src/main/java/io/split/android/client/fallback/FallbacksSanitizerImpl.java new file mode 100644 index 000000000..70e34194d --- /dev/null +++ b/src/main/java/io/split/android/client/fallback/FallbacksSanitizerImpl.java @@ -0,0 +1,81 @@ +package io.split.android.client.fallback; + +import androidx.annotation.Nullable; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import io.split.android.client.utils.logger.Logger; + +/** + * Validates and sanitizes fallback configurations by applying validation rules. + * Invalid entries are dropped and warnings are logged. + */ +class FallbacksSanitizerImpl implements FallbacksSanitizer { + + private static final int MAX_FLAG_NAME_LENGTH = 100; + private static final int MAX_TREATMENT_LENGTH = 100; + private static final String TREATMENT_REGEXP = "^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$"; + private static final Pattern TREATMENT_PATTERN = Pattern.compile(TREATMENT_REGEXP); + + + + @Override + @Nullable + public FallbackTreatment sanitizeGlobal(@Nullable FallbackTreatment global) { + if (global == null) { + return null; + } + + if (!isValidTreatment(global)) { + Logger.e("Fallback treatments - Discarded global fallback: Invalid treatment (max " + MAX_TREATMENT_LENGTH + " chars and comply with " + TREATMENT_REGEXP + ")"); + return null; + } + + return global; + } + + @Override + public Map sanitizeByFlag(@Nullable Map byFlag) { + if (byFlag == null || byFlag.isEmpty()) { + return new HashMap<>(); + } + + Map sanitized = new HashMap<>(); + + for (Map.Entry entry : byFlag.entrySet()) { + String flagName = entry.getKey(); + FallbackTreatment treatment = entry.getValue(); + + if (!isValidFlagName(flagName)) { + Logger.e("Fallback treatments - Discarded flag '" + flagName + "': Invalid flag name (max " + MAX_FLAG_NAME_LENGTH + " chars, no spaces)"); + continue; + } + + if (!isValidTreatment(treatment)) { + Logger.e("Fallback treatments - Discarded treatment for flag '" + flagName + "': Invalid treatment (max " + MAX_TREATMENT_LENGTH + " chars and comply with " + TREATMENT_REGEXP + ")"); + continue; + } + + sanitized.put(flagName, treatment); + } + + return sanitized; + } + + private static boolean isValidFlagName(String flagName) { + if (flagName == null) { + return false; + } + return flagName.length() <= MAX_FLAG_NAME_LENGTH && !flagName.contains(" "); + } + + private static boolean isValidTreatment(FallbackTreatment treatment) { + if (treatment == null || treatment.getTreatment() == null) { + return false; + } + String value = treatment.getTreatment(); + return value.length() <= MAX_TREATMENT_LENGTH && TREATMENT_PATTERN.matcher(value).matches(); + } +} diff --git a/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java b/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java index 83b1bd4fe..dacead934 100644 --- a/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java +++ b/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java @@ -32,6 +32,9 @@ import io.split.android.client.telemetry.storage.TelemetryStorageProducer; import io.split.android.client.utils.logger.Logger; import io.split.android.client.validators.FlagSetsValidatorImpl; +import io.split.android.client.fallback.FallbackTreatmentsCalculator; +import io.split.android.client.fallback.FallbackTreatmentsCalculatorImpl; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; import io.split.android.client.validators.KeyValidatorImpl; import io.split.android.client.validators.SplitValidatorImpl; import io.split.android.client.validators.TreatmentManager; @@ -72,12 +75,13 @@ public LocalhostSplitClient(@NonNull LocalhostSplitFactory container, mKey = checkNotNull(key); mEventsManager = checkNotNull(eventsManager); mSplitsStorage = splitsStorage; + FallbackTreatmentsCalculator calculator = new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build()); mTreatmentManager = new TreatmentManagerImpl(mKey.matchingKey(), mKey.bucketingKey(), - new EvaluatorImpl(splitsStorage, splitParser), new KeyValidatorImpl(), + new EvaluatorImpl(splitsStorage, splitParser, calculator), new KeyValidatorImpl(), new SplitValidatorImpl(), getImpressionsListener(splitClientConfig), splitClientConfig.labelsEnabled(), eventsManager, attributesManager, attributesMerger, telemetryStorageProducer, flagSetsFilter, splitsStorage, new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), - new PropertyValidatorImpl()); + new PropertyValidatorImpl(), calculator); } @Override diff --git a/src/main/java/io/split/android/client/validators/TreatmentManagerFactoryImpl.java b/src/main/java/io/split/android/client/validators/TreatmentManagerFactoryImpl.java index e9a24631a..287fb94b4 100644 --- a/src/main/java/io/split/android/client/validators/TreatmentManagerFactoryImpl.java +++ b/src/main/java/io/split/android/client/validators/TreatmentManagerFactoryImpl.java @@ -17,6 +17,9 @@ import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.client.telemetry.storage.TelemetryStorageProducer; import io.split.android.engine.experiments.SplitParser; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; +import io.split.android.client.fallback.FallbackTreatmentsCalculator; +import io.split.android.client.fallback.FallbackTreatmentsCalculatorImpl; public class TreatmentManagerFactoryImpl implements TreatmentManagerFactory { @@ -32,6 +35,7 @@ public class TreatmentManagerFactoryImpl implements TreatmentManagerFactory { private final ValidationMessageLogger mValidationMessageLogger; private final SplitFilterValidator mFlagSetsValidator; private final PropertyValidator mPropertyValidator; + private final FallbackTreatmentsCalculator mFallbackCalculator; public TreatmentManagerFactoryImpl(@NonNull KeyValidator keyValidator, @NonNull SplitValidator splitValidator, @@ -41,14 +45,22 @@ public TreatmentManagerFactoryImpl(@NonNull KeyValidator keyValidator, @NonNull TelemetryStorageProducer telemetryStorageProducer, @NonNull SplitParser splitParser, @Nullable FlagSetsFilter flagSetsFilter, - @NonNull SplitsStorage splitsStorage) { + @NonNull SplitsStorage splitsStorage, + @Nullable FallbackTreatmentsConfiguration fallbackTreatments) { mKeyValidator = checkNotNull(keyValidator); mSplitValidator = checkNotNull(splitValidator); mCustomerImpressionListener = checkNotNull(customerImpressionListener); mLabelsEnabled = labelsEnabled; mAttributesMerger = checkNotNull(attributesMerger); mTelemetryStorageProducer = checkNotNull(telemetryStorageProducer); - mEvaluator = new EvaluatorImpl(splitsStorage, splitParser); + FallbackTreatmentsCalculator calculator; + if (fallbackTreatments != null) { + calculator = new FallbackTreatmentsCalculatorImpl(fallbackTreatments); + } else { + calculator = new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build()); + } + mEvaluator = new EvaluatorImpl(splitsStorage, splitParser, calculator); + mFallbackCalculator = calculator; mFlagSetsFilter = flagSetsFilter; mSplitsStorage = checkNotNull(splitsStorage); mValidationMessageLogger = new ValidationMessageLoggerImpl(); @@ -74,7 +86,8 @@ public TreatmentManager getTreatmentManager(Key key, ListenableEventsManager eve mSplitsStorage, mValidationMessageLogger, mFlagSetsValidator, - mPropertyValidator + mPropertyValidator, + mFallbackCalculator ); } } diff --git a/src/main/java/io/split/android/client/validators/TreatmentManagerHelper.java b/src/main/java/io/split/android/client/validators/TreatmentManagerHelper.java index 3539a7c4b..125f594c3 100644 --- a/src/main/java/io/split/android/client/validators/TreatmentManagerHelper.java +++ b/src/main/java/io/split/android/client/validators/TreatmentManagerHelper.java @@ -7,18 +7,20 @@ import java.util.Map; import io.split.android.client.SplitResult; -import io.split.android.grammar.Treatments; +import io.split.android.client.fallback.FallbackTreatment; +import io.split.android.client.fallback.FallbackTreatmentsCalculator; class TreatmentManagerHelper { - static Map controlTreatmentsForSplitsWithConfig(SplitValidator splitValidator, ValidationMessageLogger validationLogger, List splits, String validationTag, TreatmentManagerImpl.ResultTransformer resultTransformer) { + static Map controlTreatmentsForSplitsWithConfig(SplitValidator splitValidator, ValidationMessageLogger validationLogger, List splits, String validationTag, TreatmentManagerImpl.ResultTransformer resultTransformer, FallbackTreatmentsCalculator mFallbackCalculator) { Map results = new HashMap<>(); for (String split : splits) { if (isInvalidSplit(splitValidator, validationTag, validationLogger, split)) { continue; } - results.put(split.trim(), resultTransformer.transform(new SplitResult(Treatments.CONTROL))); + FallbackTreatment fallback = mFallbackCalculator.resolve(split); + results.put(split.trim(), resultTransformer.transform(new SplitResult(fallback.getTreatment(), fallback.getConfig()))); } return results; diff --git a/src/main/java/io/split/android/client/validators/TreatmentManagerImpl.java b/src/main/java/io/split/android/client/validators/TreatmentManagerImpl.java index 7493e2a7c..cf09d400b 100644 --- a/src/main/java/io/split/android/client/validators/TreatmentManagerImpl.java +++ b/src/main/java/io/split/android/client/validators/TreatmentManagerImpl.java @@ -22,6 +22,8 @@ import io.split.android.client.attributes.AttributesMerger; import io.split.android.client.events.ListenableEventsManager; import io.split.android.client.events.SplitEvent; +import io.split.android.client.fallback.FallbackTreatment; +import io.split.android.client.fallback.FallbackTreatmentsCalculator; import io.split.android.client.impressions.DecoratedImpression; import io.split.android.client.impressions.Impression; import io.split.android.client.impressions.ImpressionListener; @@ -30,7 +32,6 @@ import io.split.android.client.telemetry.storage.TelemetryStorageProducer; import io.split.android.client.utils.Json; import io.split.android.client.utils.logger.Logger; -import io.split.android.grammar.Treatments; public class TreatmentManagerImpl implements TreatmentManager { @@ -52,6 +53,8 @@ public class TreatmentManagerImpl implements TreatmentManager { private final SplitsStorage mSplitsStorage; private final SplitFilterValidator mFlagSetsValidator; private final PropertyValidator mPropertyValidator; + @NonNull + private final FallbackTreatmentsCalculator mFallbackCalculator; public TreatmentManagerImpl(String matchingKey, String bucketingKey, @@ -68,7 +71,8 @@ public TreatmentManagerImpl(String matchingKey, @NonNull SplitsStorage splitsStorage, @NonNull ValidationMessageLogger validationLogger, @NonNull SplitFilterValidator flagSetsValidator, - @NonNull PropertyValidator propertyValidator) { + @NonNull PropertyValidator propertyValidator, + @NonNull FallbackTreatmentsCalculator fallbackCalculator) { mEvaluator = evaluator; mKeyValidator = keyValidator; mSplitValidator = splitValidator; @@ -85,6 +89,7 @@ public TreatmentManagerImpl(String matchingKey, mSplitsStorage = checkNotNull(splitsStorage); mFlagSetsValidator = checkNotNull(flagSetsValidator); mPropertyValidator = checkNotNull(propertyValidator); + mFallbackCalculator = checkNotNull(fallbackCalculator); } @Override @@ -100,14 +105,19 @@ public String getTreatment(String split, Map attributes, Evaluat Method.TREATMENT ).get(split); - return (treatment == null) ? Treatments.CONTROL : treatment; + if (treatment == null) { + FallbackTreatment fallback = mFallbackCalculator.resolve(split); + return fallback.getTreatment(); + } + return treatment; } catch (Exception ex) { // In case get fails for some reason Logger.e("Client " + Method.TREATMENT.getMethod() + " exception", ex); mTelemetryStorageProducer.recordException(Method.TREATMENT); - return Treatments.CONTROL; + FallbackTreatment fallback = mFallbackCalculator.resolve(split); + return fallback.getTreatment(); } } @@ -124,13 +134,18 @@ public SplitResult getTreatmentWithConfig(String split, Map attr Method.TREATMENT_WITH_CONFIG ).get(split); - return (splitResult == null) ? new SplitResult(Treatments.CONTROL) : splitResult; + if (splitResult == null) { + FallbackTreatment fallback = mFallbackCalculator.resolve(split); + return new SplitResult(fallback.getTreatment(), fallback.getConfig()); + } + return splitResult; } catch (Exception ex) { // In case get fails for some reason Logger.e("Client " + Method.TREATMENT_WITH_CONFIG.getMethod() + " exception", ex); mTelemetryStorageProducer.recordException(Method.TREATMENT_WITH_CONFIG); - return new SplitResult(Treatments.CONTROL); + FallbackTreatment fallback = mFallbackCalculator.resolve(split); + return new SplitResult(fallback.getTreatment(), fallback.getConfig()); } } @@ -285,7 +300,8 @@ private TreatmentResult getTreatmentWithConfigWithoutMetrics(String split, Map Map getControlTreatmentsForSplitsWithConfig(@Nullable Lis mValidationLogger, (names != null) ? names : new ArrayList<>(), validationTag, - resultTransformer); + resultTransformer, + mFallbackCalculator); } private EvaluationResult evaluateIfReady(String featureFlagName, @@ -390,7 +408,8 @@ private EvaluationResult evaluateIfReady(String featureFlagName, mValidationLogger.w("the SDK is not ready, results may be incorrect for feature flag " + featureFlagName + ". Make sure to wait for SDK readiness before using this method", validationTag); mTelemetryStorageProducer.recordNonReadyUsage(); - return new EvaluationResult(Treatments.CONTROL, TreatmentLabels.NOT_READY, null, null, false); + FallbackTreatment fallback = mFallbackCalculator.resolve(featureFlagName, TreatmentLabels.NOT_READY); + return new EvaluationResult(fallback.getTreatment(), fallback.getLabel(), null, fallback.getConfig(), false); } return mEvaluator.getTreatment(mMatchingKey, mBucketingKey, featureFlagName, attributes); } diff --git a/src/main/java/io/split/android/engine/splitter/Splitter.java b/src/main/java/io/split/android/engine/splitter/Splitter.java index 507ba9c6b..6c181af58 100644 --- a/src/main/java/io/split/android/engine/splitter/Splitter.java +++ b/src/main/java/io/split/android/engine/splitter/Splitter.java @@ -1,10 +1,10 @@ package io.split.android.engine.splitter; +import java.util.List; + import io.split.android.client.dtos.Partition; +import io.split.android.client.fallback.FallbackTreatmentsCalculator; import io.split.android.client.utils.MurmurHash3; -import io.split.android.grammar.Treatments; - -import java.util.List; /** * These set of functions figure out which treatment a key should see. @@ -14,11 +14,11 @@ public class Splitter { private static final int ALGO_LEGACY = 1; private static final int ALGO_MURMUR = 2; - public static String getTreatment(String key, int seed, List partitions, int algo) { + public static String getTreatment(String key, int seed, List partitions, int algo, FallbackTreatmentsCalculator fallbackCalculator) { // 1. when there are no partitions, we just return control if (partitions.isEmpty()) { - return Treatments.CONTROL; + return fallbackCalculator.resolve(key).getTreatment(); } @@ -26,7 +26,8 @@ public static String getTreatment(String key, int seed, List partitio return partitions.get(0).treatment; } - return getTreatment(bucket(hash(key, seed, algo)), partitions); + String controlTreatment = fallbackCalculator.resolve(key).getTreatment(); + return getTreatment(bucket(hash(key, seed, algo)), partitions, controlTreatment); } static long hash(String key, int seed, int algo) { @@ -65,10 +66,11 @@ static int legacy_hash(String key, int seed) { /** * @param bucket - * @param partitions MUST HAVE more than one partitions. + * @param partitions MUST HAVE more than one partitions. + * @param controlTreatment * @return */ - private static String getTreatment(int bucket, List partitions) { + private static String getTreatment(int bucket, List partitions, String controlTreatment) { int bucketsCoveredThusFar = 0; @@ -80,7 +82,7 @@ private static String getTreatment(int bucket, List partitions) { } } - return Treatments.CONTROL; + return controlTreatment; } /*package private*/ diff --git a/src/main/java/io/split/android/grammar/Treatments.java b/src/main/java/io/split/android/grammar/Treatments.java index 9d352b078..66c1e2b1d 100644 --- a/src/main/java/io/split/android/grammar/Treatments.java +++ b/src/main/java/io/split/android/grammar/Treatments.java @@ -7,25 +7,7 @@ public class Treatments { public static final String CONTROL = "control"; - - /** - * OFF is a synonym for CONTROL. - */ public static final String OFF = "off"; public static final String ON = "on"; - public static boolean isControl(String treatment) { - return CONTROL.equals(treatment) || OFF.equals(treatment); - } - - public static String controlSynonym(String treatment) { - if (!isControl(treatment)) { - throw new IllegalArgumentException("Not a control treatment: " + treatment); - } - if (Treatments.OFF.equals(treatment)) { - return Treatments.CONTROL; - } - return Treatments.OFF; - } - } diff --git a/src/test/java/io/split/android/client/SplitClientConfigTest.java b/src/test/java/io/split/android/client/SplitClientConfigTest.java index 8d796a758..b97ea6381 100644 --- a/src/test/java/io/split/android/client/SplitClientConfigTest.java +++ b/src/test/java/io/split/android/client/SplitClientConfigTest.java @@ -3,6 +3,7 @@ import static junit.framework.Assert.assertFalse; import static junit.framework.TestCase.assertEquals; import static junit.framework.TestCase.assertNull; +import static junit.framework.TestCase.assertSame; import static junit.framework.TestCase.assertTrue; import androidx.annotation.NonNull; @@ -14,6 +15,7 @@ import java.util.Queue; import java.util.concurrent.TimeUnit; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; import io.split.android.client.network.CertificatePinningConfiguration; import io.split.android.client.network.ProxyConfiguration; import io.split.android.client.network.SplitAuthenticatedRequest; @@ -260,6 +262,22 @@ public void nullRolloutCacheConfigurationSetsDefault() { assertEquals(1, logMessages.size()); } + @Test + public void fallbackTreatmentsIsNullByDefault() { + SplitClientConfig config = SplitClientConfig.builder().build(); + assertNull(config.fallbackTreatments()); + } + + @Test + public void fallbackTreatmentsAreCorrectlySet() { + FallbackTreatmentsConfiguration ftConfiguration = FallbackTreatmentsConfiguration.builder().build(); + SplitClientConfig config = SplitClientConfig.builder() + .fallbackTreatments(ftConfiguration) + .build(); + + assertSame(ftConfiguration, config.fallbackTreatments()); + } + @Test public void proxyHostAndProxyConfigurationSetLogWarning() { Queue logMessages = getLogMessagesQueue(); diff --git a/src/test/java/io/split/android/client/TreatmentManagerEvaluationOptionsTest.java b/src/test/java/io/split/android/client/TreatmentManagerEvaluationOptionsTest.java index 91fb30c09..e8718653c 100644 --- a/src/test/java/io/split/android/client/TreatmentManagerEvaluationOptionsTest.java +++ b/src/test/java/io/split/android/client/TreatmentManagerEvaluationOptionsTest.java @@ -19,6 +19,8 @@ import io.split.android.client.attributes.AttributesManager; import io.split.android.client.attributes.AttributesMerger; import io.split.android.client.events.ListenableEventsManager; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; +import io.split.android.client.fallback.FallbackTreatmentsCalculatorImpl; import io.split.android.client.impressions.Impression; import io.split.android.client.impressions.ImpressionListener; import io.split.android.client.storage.splits.SplitsStorage; @@ -68,7 +70,8 @@ public void setUp() { mSplitsStorage, mValidationMessageLogger, new FlagSetsValidatorImpl(), - mPropertyValidator); + mPropertyValidator, + new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); } @Test diff --git a/src/test/java/io/split/android/client/TreatmentManagerExceptionsTest.java b/src/test/java/io/split/android/client/TreatmentManagerExceptionsTest.java index e0f494bca..4f8432e18 100644 --- a/src/test/java/io/split/android/client/TreatmentManagerExceptionsTest.java +++ b/src/test/java/io/split/android/client/TreatmentManagerExceptionsTest.java @@ -26,6 +26,8 @@ import io.split.android.client.attributes.AttributesMerger; import io.split.android.client.events.ListenableEventsManager; import io.split.android.client.events.SplitEvent; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; +import io.split.android.client.fallback.FallbackTreatmentsCalculatorImpl; import io.split.android.client.impressions.Impression; import io.split.android.client.impressions.ImpressionListener; import io.split.android.client.storage.splits.SplitsStorage; @@ -83,7 +85,8 @@ public void setUp() { mSplitsStorage, new ValidationMessageLoggerImpl(), mFlagSetsValidator, - new PropertyValidatorImpl()); + new PropertyValidatorImpl(), + new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); when(evaluator.getTreatment(anyString(), anyString(), anyString(), anyMap())).thenReturn(new EvaluationResult("test", "label")); } diff --git a/src/test/java/io/split/android/client/TreatmentManagerTelemetryTest.java b/src/test/java/io/split/android/client/TreatmentManagerTelemetryTest.java index 8d15f22af..222de7750 100644 --- a/src/test/java/io/split/android/client/TreatmentManagerTelemetryTest.java +++ b/src/test/java/io/split/android/client/TreatmentManagerTelemetryTest.java @@ -22,6 +22,8 @@ import io.split.android.client.attributes.AttributesMerger; import io.split.android.client.events.ListenableEventsManager; import io.split.android.client.events.SplitEvent; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; +import io.split.android.client.fallback.FallbackTreatmentsCalculatorImpl; import io.split.android.client.impressions.ImpressionListener; import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.client.telemetry.model.Method; @@ -76,7 +78,8 @@ public void setUp() { mFlagSetsFilter, mSplitsStorage, new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), - new PropertyValidatorImpl()); + new PropertyValidatorImpl(), + new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); when(evaluator.getTreatment(anyString(), anyString(), anyString(), anyMap())).thenReturn(new EvaluationResult("test", "label")); } diff --git a/src/test/java/io/split/android/client/TreatmentManagerTest.java b/src/test/java/io/split/android/client/TreatmentManagerTest.java index 6fc11d770..ce889d69d 100644 --- a/src/test/java/io/split/android/client/TreatmentManagerTest.java +++ b/src/test/java/io/split/android/client/TreatmentManagerTest.java @@ -28,6 +28,8 @@ import io.split.android.client.dtos.Split; import io.split.android.client.events.ListenableEventsManager; import io.split.android.client.events.SplitEvent; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; +import io.split.android.client.fallback.FallbackTreatmentsCalculatorImpl; import io.split.android.client.impressions.DecoratedImpression; import io.split.android.client.impressions.ImpressionListener; import io.split.android.client.storage.mysegments.MySegmentsStorage; @@ -370,7 +372,8 @@ private TreatmentManager createTreatmentManager(String matchingKey, String bucke new KeyValidatorImpl(), splitValidator, mock(ImpressionListener.FederatedImpressionListener.class), config.labelsEnabled(), eventsManager, mock(AttributesManager.class), mock(AttributesMerger.class), - mock(TelemetryStorageProducer.class), mFlagSetsFilter, mSplitsStorage, validationLogger, new FlagSetsValidatorImpl(), new PropertyValidatorImpl()); + mock(TelemetryStorageProducer.class), mFlagSetsFilter, mSplitsStorage, validationLogger, new FlagSetsValidatorImpl(), new PropertyValidatorImpl(), + new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); } private TreatmentManagerImpl initializeTreatmentManager() { @@ -400,7 +403,8 @@ private TreatmentManagerImpl initializeTreatmentManager(Evaluator evaluator) { telemetryStorageProducer, mFlagSetsFilter, mSplitsStorage, - new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), new PropertyValidatorImpl()); + new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), new PropertyValidatorImpl(), + new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); } private Map splitsMap(List splits) { diff --git a/src/test/java/io/split/android/client/TreatmentManagerWithFlagSetsTest.java b/src/test/java/io/split/android/client/TreatmentManagerWithFlagSetsTest.java index 4588b8cb2..aa12c3d5e 100644 --- a/src/test/java/io/split/android/client/TreatmentManagerWithFlagSetsTest.java +++ b/src/test/java/io/split/android/client/TreatmentManagerWithFlagSetsTest.java @@ -26,6 +26,8 @@ import io.split.android.client.attributes.AttributesMerger; import io.split.android.client.events.ListenableEventsManager; import io.split.android.client.events.SplitEvent; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; +import io.split.android.client.fallback.FallbackTreatmentsCalculatorImpl; import io.split.android.client.impressions.ImpressionListener; import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.client.telemetry.model.Method; @@ -155,7 +157,8 @@ private void initializeTreatmentManager() { mAttributesMerger, mTelemetryStorageProducer, mFlagSetsFilter, - mSplitsStorage, new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), new PropertyValidatorImpl()); + mSplitsStorage, new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), new PropertyValidatorImpl(), + new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); } @Test diff --git a/src/test/java/io/split/android/client/fallback/FallbackTreatmentTest.java b/src/test/java/io/split/android/client/fallback/FallbackTreatmentTest.java new file mode 100644 index 000000000..3914e2c58 --- /dev/null +++ b/src/test/java/io/split/android/client/fallback/FallbackTreatmentTest.java @@ -0,0 +1,53 @@ +package io.split.android.client.fallback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +public class FallbackTreatmentTest { + + @Test + public void constructorSetsFields() { + FallbackTreatment ft = new FallbackTreatment("off", "{\"k\":true}", "my label"); + assertEquals("off", ft.getTreatment()); + assertEquals("{\"k\":true}", ft.getConfig()); + assertEquals("my label", ft.getLabel()); + } + + @Test + public void configCanBeNull() { + FallbackTreatment ft = new FallbackTreatment("off", null, "my label"); + assertEquals("off", ft.getTreatment()); + assertNull(ft.getConfig()); + assertEquals("my label", ft.getLabel()); + } + + @Test + public void labelCanBeNull() { + FallbackTreatment ft = new FallbackTreatment("off", null, null); + assertEquals("off", ft.getTreatment()); + assertNull(ft.getConfig()); + assertNull(ft.getLabel()); + } + + @Test + public void convenienceConstructorSetsNullConfigAndLabel() { + FallbackTreatment ft = new FallbackTreatment("off"); + assertEquals("off", ft.getTreatment()); + assertNull(ft.getConfig()); + assertNull(ft.getLabel()); + } + + @Test + public void equalityAndHashCodeByValue() { + FallbackTreatment a = new FallbackTreatment("off", null); + FallbackTreatment b = new FallbackTreatment("off", null); + FallbackTreatment c = new FallbackTreatment("on", null); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertNotEquals(a, c); + } +} diff --git a/src/test/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorTest.java b/src/test/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorTest.java new file mode 100644 index 000000000..37c5353c0 --- /dev/null +++ b/src/test/java/io/split/android/client/fallback/FallbackTreatmentsCalculatorTest.java @@ -0,0 +1,125 @@ +package io.split.android.client.fallback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import io.split.android.client.TreatmentLabels; + +public class FallbackTreatmentsCalculatorTest { + + @Test + public void flagLevelOverrideTakesPrecedence() { + FallbackTreatment global = new FallbackTreatment("off", "{\"g\":true}"); + FallbackTreatment byFlag = new FallbackTreatment("on", "{\"f\":true}"); + Map map = new HashMap<>(); + map.put("my_flag", byFlag); + FallbackTreatmentsConfiguration config = FallbackTreatmentsConfiguration.builder() + .global(global) + .byFlag(map) + .build(); + + FallbackTreatmentsCalculator calculator = new FallbackTreatmentsCalculatorImpl(config); + FallbackTreatment resolvedExisting = calculator.resolve("my_flag"); + FallbackTreatment resolvedOther = calculator.resolve("other_flag"); + + assertNotNull(resolvedExisting); + assertEquals(byFlag, resolvedExisting); + assertNotNull(resolvedOther); + assertEquals(global, resolvedOther); + } + + @Test + public void globalFallbackIsReturnedWhenNoFlagOverride() { + FallbackTreatment global = new FallbackTreatment("off"); + FallbackTreatmentsConfiguration config = FallbackTreatmentsConfiguration.builder() + .global(global) + .byFlag(Collections.emptyMap()) + .build(); + + FallbackTreatmentsCalculator calculator = new FallbackTreatmentsCalculatorImpl(config); + FallbackTreatment resolved = calculator.resolve("any_flag"); + + assertNotNull(resolved); + assertEquals(global, resolved); + } + + @Test + public void flagLevelFallbackIsReturnedWhenConfigured() { + FallbackTreatment byFlag = new FallbackTreatment("on"); + Map map = new HashMap<>(); + map.put("flagA", byFlag); + FallbackTreatmentsConfiguration config = FallbackTreatmentsConfiguration.builder() + .byFlag(map) + .build(); + + FallbackTreatmentsCalculator calculator = new FallbackTreatmentsCalculatorImpl(config); + FallbackTreatment resolved = calculator.resolve("flagA"); + + assertNotNull(resolved); + assertEquals(byFlag, resolved); + } + + @Test + public void returnsControlWhenNoFallbackConfigured() { + FallbackTreatmentsConfiguration config = FallbackTreatmentsConfiguration.builder() + .build(); + + FallbackTreatmentsCalculator calculator = new FallbackTreatmentsCalculatorImpl(config); + FallbackTreatment resolved = calculator.resolve("nope"); + + assertNotNull(resolved); + assertEquals(new FallbackTreatment("control", null, null), resolved); + } + + @Test + public void nonexistentFlagFallsBackToGlobal() { + FallbackTreatment global = new FallbackTreatment("off"); + Map map = new HashMap<>(); + map.put("flagA", new FallbackTreatment("on")); + FallbackTreatmentsConfiguration config = FallbackTreatmentsConfiguration.builder() + .global(global) + .byFlag(map) + .build(); + + FallbackTreatmentsCalculator calculator = new FallbackTreatmentsCalculatorImpl(config); + FallbackTreatment resolved = calculator.resolve("flagB"); + + assertNotNull(resolved); + assertEquals(global, resolved); + } + + @Test + public void labelIsPrefixed() { + FallbackTreatment global = new FallbackTreatment("off"); + FallbackTreatmentsConfiguration config = FallbackTreatmentsConfiguration.builder() + .global(global) + .build(); + + FallbackTreatmentsCalculator calculator = new FallbackTreatmentsCalculatorImpl(config); + FallbackTreatment resolved = calculator.resolve("flagA", TreatmentLabels.EXCEPTION); + + assertNotNull(resolved); + assertEquals("fallback - exception", resolved.getLabel()); + } + + @Test + public void noLabelReturnsNull() { + FallbackTreatment global = new FallbackTreatment("off"); + FallbackTreatmentsConfiguration config = FallbackTreatmentsConfiguration.builder() + .global(global) + .build(); + + FallbackTreatmentsCalculator calculator = new FallbackTreatmentsCalculatorImpl(config); + FallbackTreatment resolved = calculator.resolve("flagA", null); + + assertNotNull(resolved); + assertNull(resolved.getLabel()); + } +} diff --git a/src/test/java/io/split/android/client/fallback/FallbackTreatmentsConfigurationTest.java b/src/test/java/io/split/android/client/fallback/FallbackTreatmentsConfigurationTest.java new file mode 100644 index 000000000..8160e396a --- /dev/null +++ b/src/test/java/io/split/android/client/fallback/FallbackTreatmentsConfigurationTest.java @@ -0,0 +1,194 @@ +package io.split.android.client.fallback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentLinkedDeque; + +import io.split.android.client.utils.logger.LogPrinterStub; +import io.split.android.client.utils.logger.Logger; +import io.split.android.client.utils.logger.SplitLogLevel; + +public class FallbackTreatmentsConfigurationTest { + + @Test + public void constructorSetsFields() { + FallbackTreatment global = new FallbackTreatment("off"); + Map map = new HashMap<>(); + map.put("flagA", new FallbackTreatment("off")); + + FallbackTreatmentsConfiguration cfg = FallbackTreatmentsConfiguration.builder() + .global(global) + .byFlag(map) + .build(); + + assertSame(global, cfg.getGlobal()); + assertEquals(1, cfg.getByFlag().size()); + assertEquals("off", cfg.getByFlag().get("flagA").getTreatment()); + } + + @Test + public void byFlagIsUnmodifiable() { + FallbackTreatment global = new FallbackTreatment("off"); + Map byFlag = new HashMap<>(); + byFlag.put("flagA", new FallbackTreatment("off")); + + FallbackTreatmentsConfiguration config = FallbackTreatmentsConfiguration.builder() + .global(global) + .byFlag(byFlag) + .build(); + + byFlag.put("flagB", new FallbackTreatment("on")); + + // config map must not change + assertEquals(1, config.getByFlag().size()); + + try { + config.getByFlag().put("x", new FallbackTreatment("on")); + throw new AssertionError("Map should be unmodifiable"); + } catch (UnsupportedOperationException expected) { + + } + } + + @Test + public void equalityAndHashCodeByValue() { + FallbackTreatment global = new FallbackTreatment("off"); + Map a = new HashMap<>(); + a.put("flagA", new FallbackTreatment("off")); + + Map b = new HashMap<>(); + b.put("flagA", new FallbackTreatment("off")); + + FallbackTreatmentsConfiguration configOne = FallbackTreatmentsConfiguration.builder().global(global).byFlag(a).build(); + FallbackTreatmentsConfiguration configTwo = FallbackTreatmentsConfiguration.builder().global(global).byFlag(b).build(); + FallbackTreatmentsConfiguration configThree = FallbackTreatmentsConfiguration.builder().global((String) null).byFlag(b).build(); + + assertEquals(configOne, configTwo); + assertEquals(configOne.hashCode(), configTwo.hashCode()); + assertNotEquals(configOne, configThree); + assertNotEquals(configOne.hashCode(), configThree.hashCode()); + } + + @Test + public void globalStringOverloadBuildsFallbackWithNullConfig() { + FallbackTreatmentsConfiguration cfg = FallbackTreatmentsConfiguration.builder() + .global("on") + .build(); + + FallbackTreatment global = cfg.getGlobal(); + assertEquals("on", global.getTreatment()); + assertNull(global.getConfig()); + } + + @Test + public void byFlagStringMapOverloadBuildsFallbacksWithNullConfig() { + Map flagTreatments = new HashMap<>(); + flagTreatments.put("flagA", "on"); + flagTreatments.put("flagB", "off"); + + FallbackTreatmentsConfiguration cfg = FallbackTreatmentsConfiguration.builder() + .byFlagStrings(flagTreatments) + .build(); + + assertEquals(2, cfg.getByFlag().size()); + assertEquals("on", cfg.getByFlag().get("flagA").getTreatment()); + assertNull(cfg.getByFlag().get("flagA").getConfig()); + assertEquals("off", cfg.getByFlag().get("flagB").getTreatment()); + assertNull(cfg.getByFlag().get("flagB").getConfig()); + } + + @Test + public void callingByFlagStringsAfterByFlagMergesResultsAndLogsWarning() { + LogPrinterStub printer = new LogPrinterStub(); + Logger.instance().setPrinter(printer); + Logger.instance().setLevel(SplitLogLevel.WARNING); + + Map first = new HashMap<>(); + first.put("flagA", new FallbackTreatment("off", "cfgA")); + first.put("flagB", new FallbackTreatment("on")); + + Map second = new HashMap<>(); + second.put("flagA", "on"); // should override flagA + second.put("flagC", "off"); + + FallbackTreatmentsConfiguration cfg = FallbackTreatmentsConfiguration.builder() + .byFlag(first) + .byFlagStrings(second) + .build(); + + assertEquals(3, cfg.getByFlag().size()); + assertEquals("on", cfg.getByFlag().get("flagA").getTreatment()); + assertNull(cfg.getByFlag().get("flagA").getConfig()); + assertEquals("on", cfg.getByFlag().get("flagB").getTreatment()); + assertEquals("off", cfg.getByFlag().get("flagC").getTreatment()); + // warning logged with expected content for overridden key + ConcurrentLinkedDeque warns = printer.getLoggedMessages().get(android.util.Log.WARN); + assertFalse("Expected at least one warning", warns.isEmpty()); + boolean containsExpected = warns.stream().anyMatch(m -> m.contains("Fallback treatments - Duplicate fallback for flag 'flagA'. Overriding existing value.")); + assertTrue("Expected warning mentioning overridden key 'flagA'", containsExpected); + } + + @Test + public void callingByFlagAfterByFlagStringsMergesResultsAndLogsWarning() { + LogPrinterStub printer = new LogPrinterStub(); + Logger.instance().setPrinter(printer); + Logger.instance().setLevel(SplitLogLevel.WARNING); + + Map first = new HashMap<>(); + first.put("flagA", "off"); + first.put("flagB", "on"); + + Map second = new HashMap<>(); + second.put("flagA", new FallbackTreatment("on", "cfgA")); // should override flagA + second.put("flagC", new FallbackTreatment("off")); + + FallbackTreatmentsConfiguration cfg = FallbackTreatmentsConfiguration.builder() + .byFlagStrings(first) + .byFlag(second) + .build(); + + assertEquals(3, cfg.getByFlag().size()); + assertEquals("on", cfg.getByFlag().get("flagA").getTreatment()); + assertEquals("cfgA", cfg.getByFlag().get("flagA").getConfig()); + assertEquals("on", cfg.getByFlag().get("flagB").getTreatment()); + assertNull(cfg.getByFlag().get("flagB").getConfig()); + assertEquals("off", cfg.getByFlag().get("flagC").getTreatment()); + + boolean warned = !printer.getLoggedMessages().get(android.util.Log.WARN).isEmpty(); + assertTrue("Expected a warning log when merging byFlag and byFlagStrings", warned); + } + + @Test + public void byFlagAndByFlagStrings_NoOverlap_NoWarning() { + LogPrinterStub printer = new LogPrinterStub(); + Logger.instance().setPrinter(printer); + Logger.instance().setLevel(SplitLogLevel.WARNING); + + Map first = new HashMap<>(); + first.put("flagA", new FallbackTreatment("off")); + + Map second = new HashMap<>(); + second.put("flagB", "on"); + + FallbackTreatmentsConfiguration cfg = FallbackTreatmentsConfiguration.builder() + .byFlag(first) + .byFlagStrings(second) + .build(); + + assertEquals(2, cfg.getByFlag().size()); + assertEquals("off", cfg.getByFlag().get("flagA").getTreatment()); + assertEquals("on", cfg.getByFlag().get("flagB").getTreatment()); + + boolean warned = !printer.getLoggedMessages().get(android.util.Log.WARN).isEmpty(); + assertFalse("Did not expect a warning", warned); + } +} diff --git a/src/test/java/io/split/android/client/fallback/FallbacksSanitizerImplTest.java b/src/test/java/io/split/android/client/fallback/FallbacksSanitizerImplTest.java new file mode 100644 index 000000000..638dab54e --- /dev/null +++ b/src/test/java/io/split/android/client/fallback/FallbacksSanitizerImplTest.java @@ -0,0 +1,173 @@ +package io.split.android.client.fallback; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Deque; +import java.util.HashMap; +import java.util.Map; + +import io.split.android.client.utils.logger.LogPrinterStub; +import io.split.android.client.utils.logger.Logger; +import io.split.android.client.utils.logger.SplitLogLevel; + +public class FallbacksSanitizerImplTest { + + private FallbacksSanitizerImpl mSanitizer; + private LogPrinterStub mLogPrinter; + + private static final String VALID_FLAG = "my_flag"; + private static final String INVALID_FLAG_WITH_SPACE = "my flag"; + private static final String LONG_101; + static { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 101; i++) sb.append('a'); + LONG_101 = sb.toString(); + } + + @Before + public void setUp() { + mSanitizer = new FallbacksSanitizerImpl(); + mLogPrinter = new LogPrinterStub(); + Logger.instance().setLevel(SplitLogLevel.VERBOSE); + Logger.instance().setPrinter(mLogPrinter); + } + + @Test + public void dropsInvalidFlagNamesAndTreatments() { + Map byFlag = new HashMap<>(); + byFlag.put(VALID_FLAG, new FallbackTreatment("on")); + byFlag.put(INVALID_FLAG_WITH_SPACE, new FallbackTreatment("off")); + byFlag.put(LONG_101, new FallbackTreatment("off")); + byFlag.put("tooLongTreatment", new FallbackTreatment(LONG_101)); + + FallbackTreatment sanitizedGlobal = mSanitizer.sanitizeGlobal(new FallbackTreatment("on")); + Map sanitizedByFlag = mSanitizer.sanitizeByFlag(byFlag); + FallbackTreatmentsConfiguration sanitized = FallbackTreatmentsConfiguration.builder() + .global(sanitizedGlobal) + .byFlag(sanitizedByFlag) + .build(); + + Deque errors = mLogPrinter.getLoggedMessages().get(android.util.Log.ERROR); + assertTrue("Expected ERROR logs to be present", errors != null && !errors.isEmpty()); + long invalidFlagNameCount = errors.stream().filter(m -> m.contains("Invalid flag name")).count(); + assertEquals(2, invalidFlagNameCount); + assertTrue(errors.stream().anyMatch(m -> m.contains("Discarded flag 'my flag'"))); + // invalid treatment for a specific flag name and contains the full expected message + assertTrue(errors.stream().anyMatch(m -> m.contains("Discarded treatment for flag 'tooLongTreatment'"))); + assertTrue(errors.stream().anyMatch(m -> m.contains("Invalid treatment (max 100 chars and comply with ^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$)"))); + + assertEquals(1, sanitized.getByFlag().size()); + assertEquals("on", sanitized.getByFlag().get(VALID_FLAG).getTreatment()); + } + + @Test + public void dropsInvalidGlobalTreatment() { + FallbackTreatment sanitizedGlobal = mSanitizer.sanitizeGlobal(new FallbackTreatment(LONG_101)); // invalid treatment length + Map sanitizedByFlag = mSanitizer.sanitizeByFlag(null); + FallbackTreatmentsConfiguration sanitized = FallbackTreatmentsConfiguration.builder() + .global(sanitizedGlobal) + .byFlag(sanitizedByFlag) + .build(); + + // Assert error log for discarded global fallback only + Deque errors = mLogPrinter.getLoggedMessages().get(android.util.Log.ERROR); + assertTrue("Expected ERROR logs to be present", errors != null && !errors.isEmpty()); + assertTrue(errors.stream().anyMatch(m -> m.contains("Discarded global fallback"))); + + assertNull(sanitized.getGlobal()); + assertEquals(0, sanitized.getByFlag().size()); + } + + @Test + public void byFlagTreatmentIsDroppedWhenInvalidFormat() { + Map byFlag = new HashMap<>(); + byFlag.put(VALID_FLAG, new FallbackTreatment("on.off")); + byFlag.put("valid_num_dot", new FallbackTreatment("123.on")); + byFlag.put("null_treatment", new FallbackTreatment(null)); + + FallbackTreatment sanitizedGlobal = mSanitizer.sanitizeGlobal(null); + Map sanitizedByFlag = mSanitizer.sanitizeByFlag(byFlag); + FallbackTreatmentsConfiguration sanitized = FallbackTreatmentsConfiguration.builder() + .global(sanitizedGlobal) + .byFlag(sanitizedByFlag) + .build(); + + // Assert error logs for invalid treatments under flags + Deque errors = mLogPrinter.getLoggedMessages().get(android.util.Log.ERROR); + assertTrue("Expected ERROR logs to be present", errors != null && !errors.isEmpty()); + assertTrue(errors.stream().anyMatch(m -> m.contains("Discarded treatment for flag '" + VALID_FLAG + "'"))); + assertTrue(errors.stream().anyMatch(m -> m.contains("Invalid treatment (max 100 chars and comply with ^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$)"))); + assertTrue(errors.stream().anyMatch(m -> m.contains("Discarded treatment for flag 'null_treatment'"))); + // Ensure no error for valid flag/treatment + assertTrue(errors.stream().noneMatch(m -> m.contains("Discarded treatment for flag 'valid_num_dot'"))); + + // Only the valid one should remain + assertEquals(1, sanitized.getByFlag().size()); + assertEquals("123.on", sanitized.getByFlag().get("valid_num_dot").getTreatment()); + } + + @Test + public void globalTreatmentIsDroppedWhenInvalidFormat() { + Map byFlag = new HashMap<>(); + byFlag.put(VALID_FLAG, new FallbackTreatment("on_1-2")); + byFlag.put("null_treatment", new FallbackTreatment(null)); + + // Global invalid due to regex (letters cannot be followed by '.') + FallbackTreatment sanitizedGlobal = mSanitizer.sanitizeGlobal(new FallbackTreatment("on.off")); + Map sanitizedByFlag = mSanitizer.sanitizeByFlag(byFlag); + FallbackTreatmentsConfiguration sanitized = FallbackTreatmentsConfiguration.builder() + .global(sanitizedGlobal) + .byFlag(sanitizedByFlag) + .build(); + + // Assert error logs were emitted for invalid entries + Deque errorLogs = mLogPrinter.getLoggedMessages().get(android.util.Log.ERROR); + assertTrue("Expected ERROR logs to be present", errorLogs != null && !errorLogs.isEmpty()); + boolean hasGlobalDiscard = false; + boolean hasNullFlagDiscard = false; + for (String msg : errorLogs) { + if (msg.contains("Discarded global fallback")) { + hasGlobalDiscard = true; + } + if (msg.contains("Discarded treatment for flag 'null_treatment'")) { + hasNullFlagDiscard = true; + } + } + assertTrue("Expected an error about discarded global fallback", hasGlobalDiscard); + assertTrue("Expected an error about discarded treatment for flag 'null_treatment'", hasNullFlagDiscard); + + assertNull(sanitized.getGlobal()); + // Ensure only the valid by-flag entry is preserved + assertEquals(1, sanitized.getByFlag().size()); + assertEquals("on_1-2", sanitized.getByFlag().get(VALID_FLAG).getTreatment()); + } + + @Test + public void validFormatTreatmentIsNotDropped() { + Map byFlag = new HashMap<>(); + byFlag.put("numWithDot", new FallbackTreatment("123.on")); + byFlag.put(VALID_FLAG, new FallbackTreatment("on_1-2")); + + FallbackTreatment sanitizedGlobal = mSanitizer.sanitizeGlobal(new FallbackTreatment("on")); + Map sanitizedByFlag = mSanitizer.sanitizeByFlag(byFlag); + FallbackTreatmentsConfiguration sanitized = FallbackTreatmentsConfiguration.builder() + .global(sanitizedGlobal) + .byFlag(sanitizedByFlag) + .build(); + + assertEquals(2, sanitized.getByFlag().size()); + assertTrue(sanitized.getByFlag().containsKey("numWithDot")); + assertEquals("123.on", sanitized.getByFlag().get("numWithDot").getTreatment()); + assertEquals("on_1-2", sanitized.getByFlag().get(VALID_FLAG).getTreatment()); + assertEquals("on", sanitized.getGlobal().getTreatment()); + + // No ERROR logs expected for valid-only case + Deque errors4 = mLogPrinter.getLoggedMessages().get(android.util.Log.ERROR); + assertTrue(errors4 == null || errors4.isEmpty()); + } +} diff --git a/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java b/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java index 1c80f33c5..bc8ab7410 100644 --- a/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java +++ b/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java @@ -42,7 +42,7 @@ public static SplitClientImpl get(Key key, SplitsStorage splitsStorage) { TreatmentManagerFactory treatmentManagerFactory = new TreatmentManagerFactoryImpl( new KeyValidatorImpl(), new SplitValidatorImpl(), new ImpressionListener.FederatedImpressionListener(mock(DecoratedImpressionListener.class), Collections.emptyList()), false, new AttributesMergerImpl(), telemetryStorage, splitParser, - new FlagSetsFilterImpl(Collections.emptySet()), splitsStorage); + new FlagSetsFilterImpl(Collections.emptySet()), splitsStorage, null); AttributesManager attributesManager = mock(AttributesManager.class); SplitClientImpl c = new SplitClientImpl( diff --git a/src/test/java/io/split/android/client/utils/logger/LogPrinterStub.java b/src/test/java/io/split/android/client/utils/logger/LogPrinterStub.java index a63809dee..e8c53ef3d 100644 --- a/src/test/java/io/split/android/client/utils/logger/LogPrinterStub.java +++ b/src/test/java/io/split/android/client/utils/logger/LogPrinterStub.java @@ -2,43 +2,65 @@ import android.util.Log; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; public class LogPrinterStub implements LogPrinter { private final Set calls = new HashSet<>(); + private final Map> logs = new ConcurrentHashMap<>(); + + public LogPrinterStub() { + // Initialize for all Android log levels: VERBOSE(2) .. ASSERT(7) + for (int level = Log.VERBOSE; level <= Log.ASSERT; level++) { + logs.put(level, new ConcurrentLinkedDeque<>()); + } + } @Override public void v(String tag, String msg, Throwable tr) { + logs.get(Log.VERBOSE).add(msg); calls.add(Log.VERBOSE); } @Override public void d(String tag, String msg, Throwable tr) { + logs.get(Log.DEBUG).add(msg); calls.add(Log.DEBUG); } @Override public void i(String tag, String msg, Throwable tr) { + logs.get(Log.INFO).add(msg); calls.add(Log.INFO); } @Override public void w(String tag, String msg, Throwable tr) { + logs.get(Log.WARN).add(msg); calls.add(Log.WARN); } @Override public void e(String tag, String msg, Throwable tr) { + logs.get(Log.ERROR).add(msg); calls.add(Log.ERROR); } @Override public void wtf(String tag, String msg, Throwable tr) { + logs.get(Log.ASSERT).add(msg); calls.add(Log.ASSERT); } public boolean isCalled(Integer type) { return calls.contains(type); } + + public Map> getLoggedMessages() { + return new HashMap<>(logs); + } } diff --git a/src/test/java/io/split/android/client/validators/TreatmentManagerFactoryImplTest.java b/src/test/java/io/split/android/client/validators/TreatmentManagerFactoryImplTest.java new file mode 100644 index 000000000..dd2201dd4 --- /dev/null +++ b/src/test/java/io/split/android/client/validators/TreatmentManagerFactoryImplTest.java @@ -0,0 +1,46 @@ +package io.split.android.client.validators; + +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; + +import androidx.annotation.NonNull; + +import org.junit.Test; + +import io.split.android.client.FlagSetsFilter; +import io.split.android.client.attributes.AttributesMerger; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; +import io.split.android.client.impressions.ImpressionListener; +import io.split.android.client.storage.splits.SplitsStorage; +import io.split.android.client.telemetry.storage.TelemetryStorage; +import io.split.android.engine.experiments.SplitParser; + +public class TreatmentManagerFactoryImplTest { + @Test + public void instantiateWithNullFallbackTreatmentsConfigDoesNotThrow() { + TreatmentManagerFactoryImpl treatmentManagerFactory = instantiate(null); + + assertNotNull(treatmentManagerFactory); + } + + @Test + public void instantiateWithNullByFactoryFallbackTreatmentsConfigDoesNotThrow() { + TreatmentManagerFactoryImpl treatmentManagerFactory = instantiate(FallbackTreatmentsConfiguration.builder().build()); + + assertNotNull(treatmentManagerFactory); + } + + @NonNull + private static TreatmentManagerFactoryImpl instantiate(FallbackTreatmentsConfiguration fallbackTreatments) { + return new TreatmentManagerFactoryImpl(mock(KeyValidator.class), + mock(SplitValidator.class), + mock(ImpressionListener.FederatedImpressionListener.class), + true, + mock(AttributesMerger.class), + mock(TelemetryStorage.class), + mock(SplitParser.class), + mock(FlagSetsFilter.class), + mock(SplitsStorage.class), + fallbackTreatments); + } +} diff --git a/src/test/java/io/split/android/client/validators/TreatmentManagerFallbackTreatmentsTest.java b/src/test/java/io/split/android/client/validators/TreatmentManagerFallbackTreatmentsTest.java new file mode 100644 index 000000000..1e7990603 --- /dev/null +++ b/src/test/java/io/split/android/client/validators/TreatmentManagerFallbackTreatmentsTest.java @@ -0,0 +1,265 @@ +package io.split.android.client.validators; + +import static org.junit.Assert.assertEquals; +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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import io.split.android.client.TreatmentLabels; +import io.split.android.client.EvaluationResult; +import io.split.android.client.Evaluator; +import io.split.android.client.EvaluatorImpl; +import io.split.android.client.SplitResult; +import io.split.android.client.attributes.AttributesManager; +import io.split.android.client.attributes.AttributesMerger; +import io.split.android.client.dtos.Split; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; +import io.split.android.client.fallback.FallbackTreatment; +import io.split.android.client.fallback.FallbackTreatmentsCalculator; +import io.split.android.client.fallback.FallbackTreatmentsCalculatorImpl; +import io.split.android.client.impressions.DecoratedImpression; +import io.split.android.client.impressions.Impression; +import io.split.android.client.impressions.ImpressionListener; +import io.split.android.client.telemetry.model.Method; +import io.split.android.client.telemetry.storage.TelemetryStorageProducer; +import io.split.android.client.events.ListenableEventsManager; +import io.split.android.client.events.SplitEvent; +import io.split.android.client.FlagSetsFilter; +import io.split.android.client.storage.splits.SplitsStorage; +import io.split.android.engine.experiments.SplitParser; + +public class TreatmentManagerFallbackTreatmentsTest { + + private static final String FLAG = "missing_flag"; + + @Test + public void evaluatorDefinitionNotFoundUsesFallback() { + FallbackTreatmentsConfiguration cfg = FallbackTreatmentsConfiguration.builder() + .global(new FallbackTreatment("FALLBACK_TREATMENT", "{\"k\":1}")) + .build(); + FallbackTreatmentsCalculator calc = new FallbackTreatmentsCalculatorImpl(cfg); + + SplitsStorage splitsStorage = Mockito.mock(SplitsStorage.class); + SplitParser splitParser = Mockito.mock(SplitParser.class); + when(splitsStorage.get(FLAG)).thenReturn(null); // definition not found + when(splitParser.parse(null, "m")).thenReturn(null); + + EvaluatorImpl evaluator = new EvaluatorImpl(splitsStorage, splitParser, calc); + + EvaluationResult res = evaluator.getTreatment("m", null, FLAG, Collections.emptyMap()); + + assertEquals("FALLBACK_TREATMENT", res.getTreatment()); + assertTrue(res.getLabel().startsWith("fallback - ")); + assertEquals("{\"k\":1}", res.getConfigurations()); + } + + @Test + public void evaluatorExceptionUsesFallback() { + FallbackTreatmentsConfiguration cfg = FallbackTreatmentsConfiguration.builder() + .global(new FallbackTreatment("FALLBACK_TREATMENT_2")) + .build(); + FallbackTreatmentsCalculator calc = new FallbackTreatmentsCalculatorImpl(cfg); + + SplitsStorage splitsStorage = Mockito.mock(SplitsStorage.class); + SplitParser splitParser = Mockito.mock(SplitParser.class); + Split dtoSplit = Mockito.mock(Split.class); + when(splitsStorage.get(FLAG)).thenReturn(dtoSplit); + when(splitParser.parse(Mockito.eq(dtoSplit), Mockito.eq("m"))).thenThrow(new RuntimeException("boom")); + + EvaluatorImpl evaluator = new EvaluatorImpl(splitsStorage, splitParser, calc); + + EvaluationResult res = evaluator.getTreatment("m", null, FLAG, Collections.emptyMap()); + + assertEquals("FALLBACK_TREATMENT_2", res.getTreatment()); + assertTrue(res.getLabel().startsWith("fallback - ")); + } + + @Test + public void helperControlTreatmentsPathUsesFallback() { + FallbackTreatmentsConfiguration cfg = FallbackTreatmentsConfiguration.builder() + .global(new FallbackTreatment("FALLBACK_HELPER", "cfg")) + .build(); + FallbackTreatmentsCalculator calc = new FallbackTreatmentsCalculatorImpl(cfg); + + SplitValidator okValidator = new SplitValidatorImpl(); + ValidationMessageLogger logger = new ValidationMessageLoggerImpl(); + + List names = Arrays.asList(" flag_a ", "flag_b"); + Map out = TreatmentManagerHelper.controlTreatmentsForSplitsWithConfig( + okValidator, + logger, + names, + "test", + TreatmentManagerImpl.ResultTransformer::identity, + calc); + + assertEquals(2, out.size()); + assertEquals("FALLBACK_HELPER", out.get("flag_a").treatment()); + assertEquals("cfg", out.get("flag_a").config()); + assertEquals("FALLBACK_HELPER", out.get("flag_b").treatment()); + } + + @Test + public void treatmentManagerGetTreatmentNullStringUsesFallback() { + String flag = "flag_for_null"; + + Evaluator evaluator = mock(Evaluator.class); + TelemetryStorageProducer telemetry = mock(TelemetryStorageProducer.class); + FallbackTreatmentsCalculator fallbackCalc = mock(FallbackTreatmentsCalculator.class); + Mocks m = Mocks.create(evaluator, telemetry, fallbackCalc); + + when(evaluator.getTreatment(eq("m"), eq("b"), eq(flag), any())) + .thenReturn(new EvaluationResult(null, "label", null, null, false)); + + when(fallbackCalc.resolve(flag)).thenReturn(new FallbackTreatment("FALLBACK_TMT")); + + String out = m.manager.getTreatment(flag, null, null, false); + + assertEquals("FALLBACK_TMT", out); + verify(fallbackCalc, times(1)).resolve(flag); + } + + @Test + public void treatmentManagerGetTreatmentExceptionRecordsTelemetryAndUsesFallback() { + String flag = "flag_for_exception"; + + Evaluator evaluator = mock(Evaluator.class); + TelemetryStorageProducer telemetry = mock(TelemetryStorageProducer.class); + FallbackTreatmentsCalculator fallbackCalc = mock(FallbackTreatmentsCalculator.class); + Mocks m = Mocks.create(evaluator, telemetry, fallbackCalc); + + when(evaluator.getTreatment(eq("m"), eq("b"), eq(flag), any())) + .thenReturn(new EvaluationResult(null, "label", null, null, false)); + + when(fallbackCalc.resolve(flag)) + .thenThrow(new RuntimeException("fail once")) + .thenReturn(new FallbackTreatment("FALLBACK_AFTER_EXCEPTION")); + + String out = m.manager.getTreatment(flag, null, null, false); + + assertEquals("FALLBACK_AFTER_EXCEPTION", out); + verify(telemetry, times(1)).recordException(Method.TREATMENT); + verify(fallbackCalc, times(2)).resolve(flag); + } + + @Test + public void treatmentManagerLabelContainsDefinitionNotFoundTriggersNotFoundPath() { + String flag = "flag_contains_def_not_found"; + + Evaluator evaluator = mock(Evaluator.class); + TelemetryStorageProducer telemetry = mock(TelemetryStorageProducer.class); + FallbackTreatmentsCalculator fallbackCalc = mock(FallbackTreatmentsCalculator.class); + Mocks m = Mocks.create(evaluator, telemetry, fallbackCalc); + + String label = "some prefix - " + TreatmentLabels.DEFINITION_NOT_FOUND + " - some suffix"; + when(evaluator.getTreatment(eq("m"), eq("b"), eq(flag), any())) + .thenReturn(new EvaluationResult("on", label, null, null, false)); + + when(m.splitValidator.splitNotFoundMessage(flag)).thenReturn("not found: " + flag); + + // Invoke getTreatmentWithConfig to go through getTreatmentWithConfigWithoutMetrics path + SplitResult result = m.manager.getTreatmentWithConfig(flag, null, null, false); + + // Ensure treatment is the one provided by evaluator and no impressions are logged + assertEquals("on", result.treatment()); + verify(m.impressions, times(0)).log(Mockito.any(DecoratedImpression.class)); + verify(m.impressions, times(0)).log(Mockito.any(Impression.class)); + + // Ensure we logged the not-found warning by requesting the message from SplitValidator + verify(m.splitValidator, times(1)).splitNotFoundMessage(flag); + } + + private static class Mocks { + final TreatmentManagerImpl manager; + final KeyValidator keyValidator; + final SplitValidator splitValidator; + final ImpressionListener.FederatedImpressionListener impressions; + final ListenableEventsManager events; + final AttributesManager attributesManager; + final AttributesMerger attributesMerger; + final FlagSetsFilter flagSetsFilter; + final SplitsStorage splitsStorage; + final SplitFilterValidator flagSetsValidator; + final PropertyValidator propertyValidator; + + private Mocks(TreatmentManagerImpl manager, + KeyValidator keyValidator, + SplitValidator splitValidator, + ImpressionListener.FederatedImpressionListener impressions, + ListenableEventsManager events, + AttributesManager attributesManager, + AttributesMerger attributesMerger, + FlagSetsFilter flagSetsFilter, + SplitsStorage splitsStorage, + SplitFilterValidator flagSetsValidator, + PropertyValidator propertyValidator) { + this.manager = manager; + this.keyValidator = keyValidator; + this.splitValidator = splitValidator; + this.impressions = impressions; + this.events = events; + this.attributesManager = attributesManager; + this.attributesMerger = attributesMerger; + this.flagSetsFilter = flagSetsFilter; + this.splitsStorage = splitsStorage; + this.flagSetsValidator = flagSetsValidator; + this.propertyValidator = propertyValidator; + } + + static Mocks create(Evaluator evaluator, + TelemetryStorageProducer telemetry, + FallbackTreatmentsCalculator fallbackCalc) { + KeyValidator keyValidator = mock(KeyValidator.class); + SplitValidator splitValidator = mock(SplitValidator.class); + ImpressionListener.FederatedImpressionListener impressions = mock(ImpressionListener.FederatedImpressionListener.class); + ListenableEventsManager events = mock(ListenableEventsManager.class); + AttributesManager attributesManager = mock(AttributesManager.class); + AttributesMerger attributesMerger = mock(AttributesMerger.class); + FlagSetsFilter flagSetsFilter = mock(FlagSetsFilter.class); + SplitsStorage splitsStorage = mock(SplitsStorage.class); + ValidationMessageLogger validationLogger = new ValidationMessageLoggerImpl(); + SplitFilterValidator flagSetsValidator = mock(SplitFilterValidator.class); + PropertyValidator propertyValidator = mock(PropertyValidator.class); + + when(events.eventAlreadyTriggered(SplitEvent.SDK_READY)).thenReturn(true); + when(attributesManager.getAllAttributes()).thenReturn(Collections.emptyMap()); + when(attributesMerger.merge(any(), any())).thenReturn(Collections.emptyMap()); + when(splitValidator.validateName(any())).thenReturn(null); + when(keyValidator.validate(any(), any())).thenReturn(null); + + TreatmentManagerImpl manager = new TreatmentManagerImpl( + "m", + "b", + evaluator, + keyValidator, + splitValidator, + impressions, + true, + events, + attributesManager, + attributesMerger, + telemetry, + flagSetsFilter, + splitsStorage, + validationLogger, + flagSetsValidator, + propertyValidator, + fallbackCalc); + + return new Mocks(manager, keyValidator, splitValidator, impressions, events, attributesManager, + attributesMerger, flagSetsFilter, splitsStorage, flagSetsValidator, propertyValidator); + } + } +} diff --git a/src/test/java/io/split/android/client/validators/TreatmentManagerHelperTest.java b/src/test/java/io/split/android/client/validators/TreatmentManagerHelperTest.java index f833f576f..16c938caf 100644 --- a/src/test/java/io/split/android/client/validators/TreatmentManagerHelperTest.java +++ b/src/test/java/io/split/android/client/validators/TreatmentManagerHelperTest.java @@ -12,6 +12,10 @@ import java.util.Map; import io.split.android.client.SplitResult; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; +import io.split.android.client.fallback.FallbackTreatment; +import io.split.android.client.fallback.FallbackTreatmentsCalculator; +import io.split.android.client.fallback.FallbackTreatmentsCalculatorImpl; public class TreatmentManagerHelperTest { @@ -22,7 +26,11 @@ public void controlTreatmentsForSplitsValidatesSplitsWhenValidatorAndLoggerAreNo when(validator.validateName("split2")).thenReturn(new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "message")); - TreatmentManagerHelper.controlTreatmentsForSplitsWithConfig(validator, logger, Arrays.asList("split1", "split2"), "tag", SplitResult::treatment); + FallbackTreatmentsCalculator calc = new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder() + .global(new FallbackTreatment("control")) + .build()); + + TreatmentManagerHelper.controlTreatmentsForSplitsWithConfig(validator, logger, Arrays.asList("split1", "split2"), "tag", SplitResult::treatment, calc); verify(validator).validateName("split1"); verify(validator).validateName("split2"); @@ -36,7 +44,11 @@ public void controlTreatmentsForSplitsWithConfigValidatesSplitsWhenValidatorAndL when(validator.validateName("split2")).thenReturn(new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "message")); - Map result = TreatmentManagerHelper.controlTreatmentsForSplitsWithConfig(validator, logger, Arrays.asList("split1", "split2"), "tag", TreatmentManagerImpl.ResultTransformer::identity); + FallbackTreatmentsCalculator calc = new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder() + .global(new FallbackTreatment("control")) + .build()); + + Map result = TreatmentManagerHelper.controlTreatmentsForSplitsWithConfig(validator, logger, Arrays.asList("split1", "split2"), "tag", TreatmentManagerImpl.ResultTransformer::identity, calc); verify(validator).validateName("split1"); verify(validator).validateName("split2"); @@ -50,7 +62,11 @@ public void controlTreatmentsForSplitsWithConfigOnlyAddsValueForValidSplits() { when(validator.validateName("split2")).thenReturn(new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "message")); - Map result = TreatmentManagerHelper.controlTreatmentsForSplitsWithConfig(validator, logger, Arrays.asList("split1", "split2"), "tag", TreatmentManagerImpl.ResultTransformer::identity); + FallbackTreatmentsCalculator calc = new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder() + .global(new FallbackTreatment("control")) + .build()); + + Map result = TreatmentManagerHelper.controlTreatmentsForSplitsWithConfig(validator, logger, Arrays.asList("split1", "split2"), "tag", TreatmentManagerImpl.ResultTransformer::identity, calc); verify(validator).validateName("split1"); verify(validator).validateName("split2"); @@ -67,7 +83,11 @@ public void controlTreatmentsForSplitsOnlyAddsValuesForValidSplits() { when(validator.validateName("split2")).thenReturn(new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "message")); - Map result = TreatmentManagerHelper.controlTreatmentsForSplitsWithConfig(validator, logger, Arrays.asList("split1", "split2"), "tag", SplitResult::treatment); + FallbackTreatmentsCalculator calc = new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder() + .global(new FallbackTreatment("control")) + .build()); + + Map result = TreatmentManagerHelper.controlTreatmentsForSplitsWithConfig(validator, logger, Arrays.asList("split1", "split2"), "tag", SplitResult::treatment, calc); verify(validator).validateName("split1"); verify(validator).validateName("split2"); diff --git a/src/test/java/io/split/android/engine/splitter/HashConsistencyTest.java b/src/test/java/io/split/android/engine/splitter/HashConsistencyTest.java index 5f62ed0a7..fb0d16f5b 100644 --- a/src/test/java/io/split/android/engine/splitter/HashConsistencyTest.java +++ b/src/test/java/io/split/android/engine/splitter/HashConsistencyTest.java @@ -1,8 +1,8 @@ package io.split.android.engine.splitter; import com.google.common.hash.Hashing; + import org.junit.Assert; -import org.junit.Ignore; import org.junit.Test; import java.io.BufferedReader; @@ -13,7 +13,6 @@ import java.nio.charset.Charset; import io.split.android.client.utils.MurmurHash3; -import io.split.android.engine.splitter.Splitter; @SuppressWarnings({"UnstableApiUsage", "ConstantConditions"}) public class HashConsistencyTest { @@ -24,14 +23,6 @@ public void testLegacyHashAlphaNum() throws IOException { validateFileLegacyHash(file); } - @Test - @Ignore - public void testLegacyHashNonAlphaNum() throws IOException { - URL resource = getClass().getClassLoader().getResource("legacy-hash-sample-data-non-alpha-numeric.csv"); - File file = new File(resource.getFile()); - validateFileLegacyHash(file); - } - @Test public void testMurmur3HashAlphaNum() throws IOException { URL resource = getClass().getClassLoader().getResource("murmur3-sample-data-v2.csv"); diff --git a/src/test/java/io/split/android/engine/splitter/SplitterTest.java b/src/test/java/io/split/android/engine/splitter/SplitterTest.java index 9ab9aa090..32826214b 100644 --- a/src/test/java/io/split/android/engine/splitter/SplitterTest.java +++ b/src/test/java/io/split/android/engine/splitter/SplitterTest.java @@ -2,105 +2,31 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThat; - -import com.google.common.base.Joiner; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import org.apache.commons.lang3.RandomStringUtils; -import org.junit.Ignore; import org.junit.Test; +import org.mockito.Mockito; -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.Random; import io.split.android.client.dtos.Partition; +import io.split.android.client.fallback.FallbackTreatmentsConfiguration; +import io.split.android.client.fallback.FallbackTreatment; +import io.split.android.client.fallback.FallbackTreatmentsCalculator; +import io.split.android.client.fallback.FallbackTreatmentsCalculatorImpl; /** * Test for Splitter. */ public class SplitterTest { - @Ignore - @Test - public void generateData() { - Random r = new Random(); - int minKeyLength = 7; - - for (int j = 0; j < 100; j++) { - int seed = r.nextInt(); - for (int i = 0; i < 1000; i++) { - int keyLength = minKeyLength + r.nextInt(13); - String key = RandomStringUtils.randomAlphanumeric(keyLength); - long hash = Splitter.hash(key, seed, 1); - int bucket = Splitter.bucket(hash); - System.out.println(Joiner.on(',').join(Arrays.asList(seed, key, hash, bucket))); - } - } - - } - - @Ignore - @Test - public void generateNonAlphaNumericData() { - Random r = new Random(); - int minKeyLength = 7; - - for (int j = 0; j < 100; j++) { - int seed = r.nextInt(); - for (int i = 0; i < 1000; i++) { - int keyLength = minKeyLength + r.nextInt(13); - String key = RandomStringUtils.random(keyLength); - long hash = Splitter.hash(key, seed, 1); - int bucket = Splitter.bucket(hash); - System.out.println(Joiner.on(',').join(Arrays.asList(seed, key, hash, bucket))); - } - } - - } - - /** - * Use this utily method when algos changes are required and you need to - * generate another sample file using existing seed and key input from - * another file - * - * @throws IOException - */ - @Ignore - @Test - public void generateDataFromExistingInput() throws IOException { - File file = new File("src/test/resources", "murmur3-sample-data-non-alpha-numeric.csv"); - BufferedReader reader = new BufferedReader(new FileReader(file)); - reader.readLine(); // Header - - File target = new File("src/test/resources", "murmur3-sample-data-non-alpha-numeric-v2.csv"); - BufferedWriter writer = new BufferedWriter(new FileWriter(target)); - - // Writer header. - writer.append("# seed, key, hash, bucket"); - writer.newLine(); - - String line; - while ((line = reader.readLine()) != null) { - String[] parts = line.split(","); - Integer seed = Integer.parseInt(parts[0]); - String key = parts[1]; - long hash = Splitter.hash(key, seed, 1); - int bucket = Splitter.bucket(hash); - writer.append(Joiner.on(',').join(Arrays.asList(seed, key, hash, bucket))); - writer.newLine(); - } - writer.close(); - } - @Test public void works() { List partitions = new ArrayList<>(); @@ -115,7 +41,7 @@ public void works() { for (int i = 0; i < n; i++) { String key = RandomStringUtils.random(20); - String treatment = Splitter.getTreatment(key, 123, partitions, 1); + String treatment = Splitter.getTreatment(key, 123, partitions, 1, new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); treatments[Integer.parseInt(treatment) - 1]++; } @@ -136,7 +62,17 @@ public void ifHundredPercentOneTreatmentWeShortcut() { List partitions = Collections.singletonList(partition); - assertThat(Splitter.getTreatment("13", 15, partitions, 1), is(equalTo("on"))); + assertThat(Splitter.getTreatment("13", 15, partitions, 1, new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())), is(equalTo("on"))); + } + + @Test + public void ifNoPartitionsWeReturnGetValueFromFallbackCalculator() { + FallbackTreatmentsCalculator calculator = Mockito.mock(FallbackTreatmentsCalculator.class); + + when(calculator.resolve(anyString())).thenReturn(new FallbackTreatment("on")); + + assertEquals("on", Splitter.getTreatment("13", 15, Collections.emptyList(), 1, calculator)); + verify(calculator).resolve("13"); } private Partition partition(String treatment, int size) {