diff --git a/CHANGELOG.md b/CHANGELOG.md index 7506141ee..52da30952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,10 @@ This is a major rewrite that introduces a cleaner API design, adds new features, - The Redis integration is no longer built into the main SDK library. See: https://github.com/launchdarkly/java-server-sdk-redis - The deprecated New Relic integration has been removed. +## [4.14.3] - 2020-09-03 +### Fixed: +- Bump SnakeYAML from 1.19 to 1.26 to address CVE-2017-18640. The SDK only parses YAML if the application has configured the SDK with a flag data file, so it's unlikely this CVE would affect SDK usage as it would require configuration and access to a local file. + ## [4.14.2] - 2020-09-01 ### Fixed: - Updated the version of OkHttp contained within the SDK from 3.12.10 to 3.14.9, to address multiple [known issues](https://square.github.io/okhttp/changelog_3x/) including an incompatibility with OpenJDK 8.0.252 under some conditions. ([#204](https://github.com/launchdarkly/java-server-sdk/issues/204)) diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index 772a332d7..45f49cb09 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -96,9 +96,10 @@ private void poll() { } else { if (dataSourceUpdates.init(allData.toFullDataSet())) { dataSourceUpdates.updateStatus(State.VALID, null); - logger.info("Initialized LaunchDarkly client."); - initialized.getAndSet(true); - initFuture.complete(null); + if (!initialized.getAndSet(true)) { + logger.info("Initialized LaunchDarkly client."); + initFuture.complete(null); + } } } } catch (HttpErrorException e) { diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java index 40683fbdb..a6081eb98 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java @@ -6,8 +6,12 @@ * The file data source allows you to use local files as a source of feature flag state. This would * typically be used in a test environment, to operate using a predetermined feature flag state * without an actual LaunchDarkly connection. See {@link #dataSource()} for details. - * + *

+ * This is different from {@link TestData}, which allows you to simulate flag configurations + * programmatically rather than using a file. + * * @since 4.12.0 + * @see TestData */ public abstract class FileData { /** diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java new file mode 100644 index 000000000..0ddc9b1dd --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java @@ -0,0 +1,691 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedSet; +import com.google.common.collect.Iterables; +import com.launchdarkly.sdk.ArrayBuilder; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Future; + +import static java.util.concurrent.CompletableFuture.completedFuture; + +/** + * A mechanism for providing dynamically updatable feature flag state in a simplified form to an SDK + * client in test scenarios. + *

+ * Unlike {@link FileData}, this mechanism does not use any external resources. It provides only + * the data that the application has put into it using the {@link #update(FlagBuilder)} method. + * + *


+ *     TestData td = TestData.dataSource();
+ *     td.update(testData.flag("flag-key-1").booleanFlag().variationForAllUsers(true));
+ *     
+ *     LDConfig config = new LDConfig.Builder()
+ *         .dataSource(td)
+ *         .build();
+ *     LDClient client = new LDClient(sdkKey, config);
+ *     
+ *     // flags can be updated at any time:
+ *     td.update(testData.flag("flag-key-2")
+ *         .variationForUser("some-user-key", true)
+ *         .fallthroughVariation(false));
+ * 
+ * + * The above example uses a simple boolean flag, but more complex configurations are possible using + * the methods of the {@link FlagBuilder} that is returned by {@link #flag(String)}. {@link FlagBuilder} + * supports many of the ways a flag can be configured on the LaunchDarkly dashboard, but does not + * currently support 1. rule operators other than "in" and "not in", or 2. percentage rollouts. + *

+ * If the same {@code TestData} instance is used to configure multiple {@code LDClient} instances, + * any changes made to the data will propagate to all of the {@code LDClient}s. + * + * @since 5.1.0 + * @see FileData + */ +public final class TestData implements DataSourceFactory { + private final Object lock = new Object(); + private final Map currentFlags = new HashMap<>(); + private final Map currentBuilders = new HashMap<>(); + private final List instances = new CopyOnWriteArrayList<>(); + + /** + * Creates a new instance of the test data source. + *

+ * See {@link TestData} for details. + * + * @return a new configurable test data source + */ + public static TestData dataSource() { + return new TestData(); + } + + private TestData() {} + + /** + * Creates or copies a {@link FlagBuilder} for building a test flag configuration. + *

+ * If this flag key has already been defined in this {@code TestData} instance, then the builder + * starts with the same configuration that was last provided for this flag. + *

+ * Otherwise, it starts with a new default configuration in which the flag has {@code true} and + * {@code false} variations, is {@code true} for all users when targeting is turned on and + * {@code false} otherwise, and currently has targeting turned on. You can change any of those + * properties, and provide more complex behavior, using the {@link FlagBuilder} methods. + *

+ * Once you have set the desired configuration, pass the builder to {@link #update(FlagBuilder)}. + * + * @param key the flag key + * @return a flag configuration builder + * @see #update(FlagBuilder) + */ + public FlagBuilder flag(String key) { + FlagBuilder existingBuilder; + synchronized (lock) { + existingBuilder = currentBuilders.get(key); + } + if (existingBuilder != null) { + return new FlagBuilder(existingBuilder); + } + return new FlagBuilder(key).booleanFlag(); + } + + /** + * Updates the test data with the specified flag configuration. + *

+ * This has the same effect as if a flag were added or modified on the LaunchDarkly dashboard. + * It immediately propagates the flag change to any {@code LDClient} instance(s) that you have + * already configured to use this {@code TestData}. If no {@code LDClient} has been started yet, + * it simply adds this flag to the test data which will be provided to any {@code LDClient} that + * you subsequently configure. + *

+ * Any subsequent changes to this {@link FlagBuilder} instance do not affect the test data, + * unless you call {@link #update(FlagBuilder)} again. + * + * @param flagBuilder a flag configuration builder + * @return the same {@code TestData} instance + * @see #flag(String) + */ + public TestData update(FlagBuilder flagBuilder) { + String key = flagBuilder.key; + FlagBuilder clonedBuilder = new FlagBuilder(flagBuilder); + ItemDescriptor newItem = null; + + synchronized (lock) { + ItemDescriptor oldItem = currentFlags.get(key); + int oldVersion = oldItem == null ? 0 : oldItem.getVersion(); + newItem = flagBuilder.createFlag(oldVersion + 1); + currentFlags.put(key, newItem); + currentBuilders.put(key, clonedBuilder); + } + + for (DataSourceImpl instance: instances) { + instance.updates.upsert(DataModel.FEATURES, key, newItem); + } + + return this; + } + + /** + * Simulates a change in the data source status. + *

+ * Use this if you want to test the behavior of application code that uses + * {@link com.launchdarkly.sdk.server.LDClient#getDataSourceStatusProvider()} to track whether the data + * source is having problems (for example, a network failure interruptsingthe streaming connection). It + * does not actually stop the {@code TestData} data source from working, so even if you have simulated + * an outage, calling {@link #update(FlagBuilder)} will still send updates. + * + * @param newState one of the constants defined by {@link com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State} + * @param newError an {@link com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo} instance, + * or null + * @return the same {@code TestData} instance + */ + public TestData updateStatus(DataSourceStatusProvider.State newState, DataSourceStatusProvider.ErrorInfo newError) { + for (DataSourceImpl instance: instances) { + instance.updates.updateStatus(newState, newError); + } + return this; + } + + /** + * Called internally by the SDK to associate this test data source with an {@code LDClient} instance. + * You do not need to call this method. + */ + @Override + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { + DataSourceImpl instance = new DataSourceImpl(dataSourceUpdates); + synchronized (lock) { + instances.add(instance); + } + return instance; + } + + private FullDataSet makeInitData() { + ImmutableMap copiedData; + synchronized (lock) { + copiedData = ImmutableMap.copyOf(currentFlags); + } + return new FullDataSet<>(ImmutableMap.of(DataModel.FEATURES, new KeyedItems<>(copiedData.entrySet())).entrySet()); + } + + private void closedInstance(DataSourceImpl instance) { + synchronized (lock) { + instances.remove(instance); + } + } + + /** + * A builder for feature flag configurations to be used with {@link TestData}. + * + * @see TestData#flag(String) + * @see TestData#update(FlagBuilder) + */ + public static final class FlagBuilder { + private static final int TRUE_VARIATION_FOR_BOOLEAN = 0; + private static final int FALSE_VARIATION_FOR_BOOLEAN = 1; + + final String key; + int offVariation; + boolean on; + int fallthroughVariation; + CopyOnWriteArrayList variations; + Map> targets; + List rules; + + private FlagBuilder(String key) { + this.key = key; + this.on = true; + this.variations = new CopyOnWriteArrayList<>(); + } + + private FlagBuilder(FlagBuilder from) { + this.key = from.key; + this.offVariation = from.offVariation; + this.on = from.on; + this.fallthroughVariation = from.fallthroughVariation; + this.variations = new CopyOnWriteArrayList<>(from.variations); + this.targets = from.targets == null ? null : new HashMap<>(from.targets); + } + + private boolean isBooleanFlag() { + return variations.size() == 2 && + variations.get(TRUE_VARIATION_FOR_BOOLEAN).equals(LDValue.of(true)) && + variations.get(FALSE_VARIATION_FOR_BOOLEAN).equals(LDValue.of(false)); + } + + /** + * A shortcut for setting the flag to use the standard boolean configuration. + *

+ * This is the default for all new flags created with {@link TestData#flag(String)}. The flag + * will have two variations, {@code true} and {@code false} (in that order); it will return + * {@code false} whenever targeting is off, and {@code true} when targeting is on if no other + * settings specify otherwise. + * + * @return the builder + */ + public FlagBuilder booleanFlag() { + if (isBooleanFlag()) { + return this; + } + return variations(LDValue.of(true), LDValue.of(false)) + .fallthroughVariation(TRUE_VARIATION_FOR_BOOLEAN) + .offVariation(FALSE_VARIATION_FOR_BOOLEAN); + } + + /** + * Sets targeting to be on or off for this flag. + *

+ * The effect of this depends on the rest of the flag configuration, just as it does on the + * real LaunchDarkly dashboard. In the default configuration that you get from calling + * {@link TestData#flag(String)} with a new flag key, the flag will return {@code false} + * whenever targeting is off, and {@code true} when targeting is on. + * + * @param on true if targeting should be on + * @return the builder + */ + public FlagBuilder on(boolean on) { + this.on = on; + return this; + } + + /** + * Specifies the fallthrough variation for a boolean flag. The fallthrough is the value + * that is returned if targeting is on and the user was not matched by a more specific + * target or rule. + *

+ * If the flag was previously configured with other variations, this also changes it to a + * boolean flag. + * + * @param value true if the flag should return true by default when targeting is on + * @return the builder + */ + public FlagBuilder fallthroughVariation(boolean value) { + return this.booleanFlag().fallthroughVariation(variationForBoolean(value)); + } + + /** + * Specifies the index of the fallthrough variation. The fallthrough is the variation + * that is returned if targeting is on and the user was not matched by a more specific + * target or rule. + * + * @param variationIndex the desired fallthrough variation: 0 for the first, 1 for the second, etc. + * @return the builder + */ + public FlagBuilder fallthroughVariation(int variationIndex) { + this.fallthroughVariation = variationIndex; + return this; + } + + /** + * Specifies the off variation for a boolean flag. This is the variation that is returned + * whenever targeting is off. + * + * @param value true if the flag should return true when targeting is off + * @return the builder + */ + public FlagBuilder offVariation(boolean value) { + return this.booleanFlag().offVariation(variationForBoolean(value)); + } + + /** + * Specifies the index of the off variation. This is the variation that is returned + * whenever targeting is off. + * + * @param variationIndex the desired off variation: 0 for the first, 1 for the second, etc. + * @return the builder + */ + public FlagBuilder offVariation(int variationIndex) { + this.offVariation = variationIndex; + return this; + } + + /** + * Sets the flag to always return the specified boolean variation for all users. + *

+ * VariationForAllUsers sets the flag to return the specified boolean variation by default for all users. + *

+ * Targeting is switched on, any existing targets or rules are removed, and the flag's variations are + * set to true and false. The fallthrough variation is set to the specified value. The off variation is + * left unchanged. + * + * @param variation the desired true/false variation to be returned for all users + * @return the builder + */ + public FlagBuilder variationForAllUsers(boolean variation) { + return booleanFlag().variationForAllUsers(variationForBoolean(variation)); + } + + /** + * Sets the flag to always return the specified variation for all users. + *

+ * The variation is specified by number, out of whatever variation values have already been + * defined. Targeting is switched on, and any existing targets or rules are removed. The fallthrough + * variation is set to the specified value. The off variation is left unchanged. + * + * @param variationIndex the desired variation: 0 for the first, 1 for the second, etc. + * @return the builder + */ + public FlagBuilder variationForAllUsers(int variationIndex) { + return on(true).clearRules().clearUserTargets().fallthroughVariation(variationIndex); + } + + /** + * Sets the flag to always return the specified variation value for all users. + *

+ * The value may be of any JSON type, as defined by {@link LDValue}. This method changes the + * flag to have only a single variation, which is this value, and to return the same + * variation regardless of whether targeting is on or off. Any existing targets or rules + * are removed. + * + * @param value the desired value to be returned for all users + * @return the builder + */ + public FlagBuilder valueForAllUsers(LDValue value) { + variations.clear(); + variations.add(value); + return variationForAllUsers(0); + } + + /** + * Sets the flag to return the specified boolean variation for a specific user key when + * targeting is on. + *

+ * This has no effect when targeting is turned off for the flag. + *

+ * If the flag was not already a boolean flag, this also changes it to a boolean flag. + * + * @param userKey a user key + * @param variation the desired true/false variation to be returned for this user when + * targeting is on + * @return the builder + */ + public FlagBuilder variationForUser(String userKey, boolean variation) { + return booleanFlag().variationForUser(userKey, variationForBoolean(variation)); + } + + /** + * Sets the flag to return the specified variation for a specific user key when targeting + * is on. + *

+ * This has no effect when targeting is turned off for the flag. + *

+ * The variation is specified by number, out of whatever variation values have already been + * defined. + * + * @param userKey a user key + * @param variationIndex the desired variation to be returned for this user when targeting is on: + * 0 for the first, 1 for the second, etc. + * @return the builder + */ + public FlagBuilder variationForUser(String userKey, int variationIndex) { + if (targets == null) { + targets = new TreeMap<>(); // TreeMap keeps variations in order for test determinacy + } + for (int i = 0; i < variations.size(); i++) { + ImmutableSet keys = targets.get(i); + if (i == variationIndex) { + if (keys == null) { + targets.put(i, ImmutableSortedSet.of(userKey)); + } else if (!keys.contains(userKey)) { + targets.put(i, ImmutableSortedSet.naturalOrder().addAll(keys).add(userKey).build()); + } + } else { + if (keys != null && keys.contains(userKey)) { + targets.put(i, ImmutableSortedSet.copyOf(Iterables.filter(keys, k -> !k.equals(userKey)))); + } + } + } + // Note, we use ImmutableSortedSet just to make the output determinate for our own testing + return this; + } + + /** + * Changes the allowable variation values for the flag. + *

+ * The value may be of any JSON type, as defined by {@link LDValue}. For instance, a boolean flag + * normally has {@code LDValue.of(true), LDValue.of(false)}; a string-valued flag might have + * {@code LDValue.of("red"), LDValue.of("green")}; etc. + * + * @param values the desired variations + * @return the builder + */ + public FlagBuilder variations(LDValue... values) { + variations.clear(); + for (LDValue v: values) { + variations.add(v); + } + return this; + } + + /** + * Starts defining a flag rule, using the "is one of" operator. + *

+ * For example, this creates a rule that returns {@code true} if the name is "Patsy" or "Edina": + * + *


+     *     testData.flag("flag")
+     *         .ifMatch(UserAttribute.NAME, LDValue.of("Patsy"), LDValue.of("Edina"))
+     *         .thenReturn(true));
+     * 
+ * + * @param attribute the user attribute to match against + * @param values values to compare to + * @return a {@link FlagRuleBuilder}; call {@link FlagRuleBuilder#thenReturn(boolean)} or + * {@link FlagRuleBuilder#thenReturn(int)} to finish the rule, or add more tests with another + * method like {@link FlagRuleBuilder#andMatch(UserAttribute, LDValue...)} + */ + public FlagRuleBuilder ifMatch(UserAttribute attribute, LDValue... values) { + return new FlagRuleBuilder().andMatch(attribute, values); + } + + /** + * Starts defining a flag rule, using the "is not one of" operator. + *

+ * For example, this creates a rule that returns {@code true} if the name is neither "Saffron" nor "Bubble": + * + *


+     *     testData.flag("flag")
+     *         .ifNotMatch(UserAttribute.NAME, LDValue.of("Saffron"), LDValue.of("Bubble"))
+     *         .thenReturn(true));
+     * 
+ + * @param attribute the user attribute to match against + * @param values values to compare to + * @return a {@link FlagRuleBuilder}; call {@link FlagRuleBuilder#thenReturn(boolean)} or + * {@link FlagRuleBuilder#thenReturn(int)} to finish the rule, or add more tests with another + * method like {@link FlagRuleBuilder#andMatch(UserAttribute, LDValue...)} + */ + public FlagRuleBuilder ifNotMatch(UserAttribute attribute, LDValue... values) { + return new FlagRuleBuilder().andNotMatch(attribute, values); + } + + /** + * Removes any existing rules from the flag. This undoes the effect of methods like + * {@link #ifMatch(UserAttribute, LDValue...)}. + * + * @return the same builder + */ + public FlagBuilder clearRules() { + rules = null; + return this; + } + + /** + * Removes any existing user targets from the flag. This undoes the effect of methods like + * {@link #variationForUser(String, boolean)}. + * + * @return the same builder + */ + public FlagBuilder clearUserTargets() { + targets = null; + return this; + } + + ItemDescriptor createFlag(int version) { + ObjectBuilder builder = LDValue.buildObject() + .put("key", key) + .put("version", version) + .put("on", on) + .put("offVariation", offVariation) + .put("fallthrough", LDValue.buildObject().put("variation", fallthroughVariation).build()); + ArrayBuilder jsonVariations = LDValue.buildArray(); + for (LDValue v: variations) { + jsonVariations.add(v); + } + builder.put("variations", jsonVariations.build()); + + if (targets != null) { + ArrayBuilder jsonTargets = LDValue.buildArray(); + for (Map.Entry> e: targets.entrySet()) { + jsonTargets.add(LDValue.buildObject() + .put("variation", e.getKey().intValue()) + .put("values", LDValue.Convert.String.arrayFrom(e.getValue())) + .build()); + } + builder.put("targets", jsonTargets.build()); + } + + if (rules != null) { + ArrayBuilder jsonRules = LDValue.buildArray(); + int ri = 0; + for (FlagRuleBuilder r: rules) { + ArrayBuilder jsonClauses = LDValue.buildArray(); + for (Clause c: r.clauses) { + ArrayBuilder jsonValues = LDValue.buildArray(); + for (LDValue v: c.values) { + jsonValues.add(v); + } + jsonClauses.add(LDValue.buildObject() + .put("attribute", c.attribute.getName()) + .put("op", c.operator) + .put("values", jsonValues.build()) + .put("negate", c.negate) + .build()); + } + jsonRules.add(LDValue.buildObject() + .put("id", "rule" + ri) + .put("variation", r.variation) + .put("clauses", jsonClauses.build()) + .build()); + ri++; + } + builder.put("rules", jsonRules.build()); + } + + String json = builder.build().toJsonString(); + return DataModel.FEATURES.deserialize(json); + } + + private static int variationForBoolean(boolean value) { + return value ? TRUE_VARIATION_FOR_BOOLEAN : FALSE_VARIATION_FOR_BOOLEAN; + } + + /** + * A builder for feature flag rules to be used with {@link FlagBuilder}. + *

+ * In the LaunchDarkly model, a flag can have any number of rules, and a rule can have any number of + * clauses. A clause is an individual test such as "name is 'X'". A rule matches a user if all of the + * rule's clauses match the user. + *

+ * To start defining a rule, use one of the flag builder's matching methods such as + * {@link FlagBuilder#ifMatch(UserAttribute, LDValue...)}. This defines the first clause for the rule. + * Optionally, you may add more clauses with the rule builder's methods such as + * {@link #andMatch(UserAttribute, LDValue...)}. Finally, call {@link #thenReturn(boolean)} or + * {@link #thenReturn(int)} to finish defining the rule. + */ + public final class FlagRuleBuilder { + final List clauses = new ArrayList<>(); + int variation; + + /** + * Adds another clause, using the "is one of" operator. + *

+ * For example, this creates a rule that returns {@code true} if the name is "Patsy" and the + * country is "gb": + * + *


+       *     testData.flag("flag")
+       *         .ifMatch(UserAttribute.NAME, LDValue.of("Patsy"))
+       *         .andMatch(UserAttribute.COUNTRY, LDValue.of("gb"))
+       *         .thenReturn(true));
+       * 
+ * + * @param attribute the user attribute to match against + * @param values values to compare to + * @return the rule builder + */ + public FlagRuleBuilder andMatch(UserAttribute attribute, LDValue... values) { + clauses.add(new Clause(attribute, "in", values, false)); + return this; + } + + /** + * Adds another clause, using the "is not one of" operator. + *

+ * For example, this creates a rule that returns {@code true} if the name is "Patsy" and the + * country is not "gb": + * + *


+       *     testData.flag("flag")
+       *         .ifMatch(UserAttribute.NAME, LDValue.of("Patsy"))
+       *         .andNotMatch(UserAttribute.COUNTRY, LDValue.of("gb"))
+       *         .thenReturn(true));
+       * 
+ * + * @param attribute the user attribute to match against + * @param values values to compare to + * @return the rule builder + */ + public FlagRuleBuilder andNotMatch(UserAttribute attribute, LDValue... values) { + clauses.add(new Clause(attribute, "in", values, true)); + return this; + } + + /** + * Finishes defining the rule, specifying the result value as a boolean. + * + * @param variation the value to return if the rule matches the user + * @return the flag builder + */ + public FlagBuilder thenReturn(boolean variation) { + FlagBuilder.this.booleanFlag(); + return thenReturn(variationForBoolean(variation)); + } + + /** + * Finishes defining the rule, specifying the result as a variation index. + * + * @param variationIndex the variation to return if the rule matches the user: 0 for the first, 1 + * for the second, etc. + * @return the flag builder + */ + public FlagBuilder thenReturn(int variationIndex) { + this.variation = variationIndex; + if (FlagBuilder.this.rules == null) { + FlagBuilder.this.rules = new ArrayList<>(); + } + FlagBuilder.this.rules.add(this); + return FlagBuilder.this; + } + } + + private static final class Clause { + final UserAttribute attribute; + final String operator; + final LDValue[] values; + final boolean negate; + + Clause(UserAttribute attribute, String operator, LDValue[] values, boolean negate) { + this.attribute = attribute; + this.operator = operator; + this.values = values; + this.negate = negate; + } + } + } + + private final class DataSourceImpl implements DataSource { + final DataSourceUpdates updates; + + DataSourceImpl(DataSourceUpdates updates) { + this.updates = updates; + } + + @Override + public Future start() { + updates.init(makeInitData()); + updates.updateStatus(State.VALID, null); + return completedFuture(null); + } + + @Override + public boolean isInitialized() { + return true; + } + + @Override + public void close() throws IOException { + closedInstance(this); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index 0b3c7592e..64331c0d2 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -2,13 +2,10 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.DataModel.FeatureFlag; -import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; -import com.launchdarkly.sdk.server.TestComponents.DataSourceFactoryThatExposesUpdater; import com.launchdarkly.sdk.server.TestComponents.DataStoreFactoryThatExposesUpdater; import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; +import com.launchdarkly.sdk.server.integrations.TestData; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; -import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; @@ -23,9 +20,6 @@ import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; -import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; -import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; -import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; import static com.launchdarkly.sdk.server.TestComponents.specificPersistentDataStore; import static com.launchdarkly.sdk.server.TestUtil.awaitValue; import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; @@ -53,13 +47,10 @@ public class LDClientListenersTest extends EasyMockSupport { @Test public void clientSendsFlagChangeEvents() throws Exception { String flagKey = "flagkey"; - DataStore testDataStore = initedDataStore(); - DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, - flagBuilder(flagKey).version(1).build()); - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + TestData testData = TestData.dataSource(); + testData.update(testData.flag(flagKey).on(true)); LDConfig config = new LDConfig.Builder() - .dataStore(specificDataStore(testDataStore)) - .dataSource(updatableSource) + .dataSource(testData) .events(Components.noEvents()) .build(); @@ -74,7 +65,7 @@ public void clientSendsFlagChangeEvents() throws Exception { expectNoMoreValues(eventSink1, Duration.ofMillis(100)); expectNoMoreValues(eventSink2, Duration.ofMillis(100)); - updatableSource.updateFlag(flagBuilder(flagKey).version(2).build()); + testData.update(testData.flag(flagKey).on(false)); FlagChangeEvent event1 = awaitValue(eventSink1, Duration.ofSeconds(1)); FlagChangeEvent event2 = awaitValue(eventSink2, Duration.ofSeconds(1)); @@ -85,7 +76,7 @@ public void clientSendsFlagChangeEvents() throws Exception { client.getFlagTracker().removeFlagChangeListener(listener1); - updatableSource.updateFlag(flagBuilder(flagKey).version(3).build()); + testData.update(testData.flag(flagKey).on(true)); FlagChangeEvent event3 = awaitValue(eventSink2, Duration.ofSeconds(1)); assertThat(event3.getKey(), equalTo(flagKey)); @@ -99,16 +90,12 @@ public void clientSendsFlagValueChangeEvents() throws Exception { String flagKey = "important-flag"; LDUser user = new LDUser("important-user"); LDUser otherUser = new LDUser("unimportant-user"); - DataStore testDataStore = initedDataStore(); - - FeatureFlag alwaysFalseFlag = flagBuilder(flagKey).version(1).on(true).variations(false, true) - .fallthroughVariation(0).build(); - DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, alwaysFalseFlag); + + TestData testData = TestData.dataSource(); + testData.update(testData.flag(flagKey).on(false)); - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); LDConfig config = new LDConfig.Builder() - .dataStore(specificDataStore(testDataStore)) - .dataSource(updatableSource) + .dataSource(testData) .events(Components.noEvents()) .build(); @@ -126,9 +113,10 @@ public void clientSendsFlagValueChangeEvents() throws Exception { expectNoMoreValues(eventSink3, Duration.ofMillis(100)); // make the flag true for the first user only, and broadcast a flag change event - FeatureFlag flagIsTrueForMyUserOnly = flagBuilder(flagKey).version(2).on(true).variations(false, true) - .targets(ModelBuilders.target(1, user.getKey())).fallthroughVariation(0).build(); - updatableSource.updateFlag(flagIsTrueForMyUserOnly); + testData.update(testData.flag(flagKey) + .on(true) + .variationForUser(user.getKey(), true) + .fallthroughVariation(false)); // eventSink1 receives a value change event FlagValueChangeEvent event1 = awaitValue(eventSink1, Duration.ofSeconds(1)); @@ -147,22 +135,22 @@ public void clientSendsFlagValueChangeEvents() throws Exception { @Test public void dataSourceStatusProviderReturnsLatestStatus() throws Exception { - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); + TestData testData = TestData.dataSource(); LDConfig config = new LDConfig.Builder() - .dataSource(updatableSource) + .dataSource(testData) .events(Components.noEvents()) .build(); Instant timeBeforeStarting = Instant.now(); try (LDClient client = new LDClient(SDK_KEY, config)) { DataSourceStatusProvider.Status initialStatus = client.getDataSourceStatusProvider().getStatus(); - assertThat(initialStatus.getState(), equalTo(DataSourceStatusProvider.State.INITIALIZING)); + assertThat(initialStatus.getState(), equalTo(DataSourceStatusProvider.State.VALID)); assertThat(initialStatus.getStateSince(), greaterThanOrEqualTo(timeBeforeStarting)); assertThat(initialStatus.getLastError(), nullValue()); DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, 401, null, Instant.now()); - updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); + testData.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); DataSourceStatusProvider.Status newStatus = client.getDataSourceStatusProvider().getStatus(); assertThat(newStatus.getState(), equalTo(DataSourceStatusProvider.State.OFF)); @@ -173,9 +161,9 @@ public void dataSourceStatusProviderReturnsLatestStatus() throws Exception { @Test public void dataSourceStatusProviderSendsStatusUpdates() throws Exception { - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); + TestData testData = TestData.dataSource(); LDConfig config = new LDConfig.Builder() - .dataSource(updatableSource) + .dataSource(testData) .events(Components.noEvents()) .build(); @@ -185,7 +173,7 @@ public void dataSourceStatusProviderSendsStatusUpdates() throws Exception { DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, 401, null, Instant.now()); - updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); + testData.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); DataSourceStatusProvider.Status newStatus = statuses.take(); assertThat(newStatus.getState(), equalTo(DataSourceStatusProvider.State.OFF)); @@ -264,13 +252,10 @@ public void eventsAreDispatchedOnTaskThread() throws Exception { int desiredPriority = Thread.MAX_PRIORITY - 1; BlockingQueue capturedThreads = new LinkedBlockingQueue<>(); - DataStore testDataStore = initedDataStore(); - DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, - flagBuilder("flagkey").version(1).build()); - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + TestData testData = TestData.dataSource(); + testData.update(testData.flag("flagkey").on(true)); LDConfig config = new LDConfig.Builder() - .dataStore(specificDataStore(testDataStore)) - .dataSource(updatableSource) + .dataSource(testData) .events(Components.noEvents()) .threadPriority(desiredPriority) .build(); @@ -280,7 +265,7 @@ public void eventsAreDispatchedOnTaskThread() throws Exception { capturedThreads.add(Thread.currentThread()); }); - updatableSource.updateFlag(flagBuilder("flagkey").version(2).build()); + testData.update(testData.flag("flagkey").on(false)); Thread handlerThread = capturedThreads.take(); diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 125d7320f..04be2a497 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -2,7 +2,6 @@ import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.sdk.UserAttribute; -import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; @@ -41,8 +40,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; -import static com.launchdarkly.sdk.server.DataModel.FEATURES; - @SuppressWarnings("javadoc") public class TestComponents { static ScheduledExecutorService sharedExecutor = Executors.newSingleThreadScheduledExecutor(); @@ -55,10 +52,6 @@ public static ClientContext clientContext(final String sdkKey, final LDConfig co return new ClientContextImpl(sdkKey, config, sharedExecutor, diagnosticAccumulator); } - public static DataSourceFactory dataSourceWithData(FullDataSet data) { - return (context, dataSourceUpdates) -> new DataSourceWithData(data, dataSourceUpdates); - } - public static DataStore dataStoreThatThrowsException(RuntimeException e) { return new DataStoreThatThrowsException(e); } @@ -139,25 +132,6 @@ public void flush() { } } - public static class DataSourceFactoryThatExposesUpdater implements DataSourceFactory { - private final FullDataSet initialData; - DataSourceUpdates dataSourceUpdates; - - public DataSourceFactoryThatExposesUpdater(FullDataSet initialData) { - this.initialData = initialData; - } - - @Override - public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { - this.dataSourceUpdates = dataSourceUpdates; - return dataSourceWithData(initialData).createDataSource(context, dataSourceUpdates); - } - - public void updateFlag(FeatureFlag flag) { - dataSourceUpdates.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); - } - } - private static class DataSourceThatNeverInitializes implements DataSource { public Future start() { return new CompletableFuture<>(); @@ -171,28 +145,6 @@ public void close() throws IOException { } }; - private static class DataSourceWithData implements DataSource { - private final FullDataSet data; - private final DataSourceUpdates dataSourceUpdates; - - DataSourceWithData(FullDataSet data, DataSourceUpdates dataSourceUpdates) { - this.data = data; - this.dataSourceUpdates = dataSourceUpdates; - } - - public Future start() { - dataSourceUpdates.init(data); - return CompletableFuture.completedFuture(null); - } - - public boolean isInitialized() { - return true; - } - - public void close() throws IOException { - } - } - public static class MockDataSourceUpdates implements DataSourceUpdates { private final DataSourceUpdatesImpl wrappedInstance; private final DataStoreStatusProvider dataStoreStatusProvider; diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java new file mode 100644 index 000000000..407c535c0 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java @@ -0,0 +1,355 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +import org.junit.Test; + +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.function.Function; + +import static com.google.common.collect.Iterables.get; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.iterableWithSize; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; + +@SuppressWarnings("javadoc") +public class TestDataTest { + private static final LDValue[] THREE_STRING_VALUES = + new LDValue[] { LDValue.of("red"), LDValue.of("green"), LDValue.of("blue") }; + + private CapturingDataSourceUpdates updates = new CapturingDataSourceUpdates(); + + @Test + public void initializesWithEmptyData() throws Exception { + TestData td = TestData.dataSource(); + DataSource ds = td.createDataSource(null, updates); + Future started = ds.start(); + + assertThat(started.isDone(), is(true)); + assertThat(updates.valid, is(true)); + + assertThat(updates.inits.size(), equalTo(1)); + FullDataSet data = updates.inits.take(); + assertThat(data.getData(), iterableWithSize(1)); + assertThat(get(data.getData(), 0).getKey(), equalTo(DataModel.FEATURES)); + assertThat(get(data.getData(), 0).getValue().getItems(), emptyIterable()); + } + + @Test + public void initializesWithFlags() throws Exception { + TestData td = TestData.dataSource(); + + td.update(td.flag("flag1").on(true)) + .update(td.flag("flag2").on(false)); + + DataSource ds = td.createDataSource(null, updates); + Future started = ds.start(); + + assertThat(started.isDone(), is(true)); + assertThat(updates.valid, is(true)); + + assertThat(updates.inits.size(), equalTo(1)); + FullDataSet data = updates.inits.take(); + assertThat(data.getData(), iterableWithSize(1)); + assertThat(get(data.getData(), 0).getKey(), equalTo(DataModel.FEATURES)); + assertThat(get(data.getData(), 0).getValue().getItems(), iterableWithSize(2)); + + Map flags = ImmutableMap.copyOf(get(data.getData(), 0).getValue().getItems()); + ItemDescriptor flag1 = flags.get("flag1"); + ItemDescriptor flag2 = flags.get("flag2"); + assertThat(flag1, not(nullValue())); + assertThat(flag2, not(nullValue())); + assertThat(flag1.getVersion(), equalTo(1)); + assertThat(flag2.getVersion(), equalTo(1)); + assertThat(flagJson(flag1).get("on").booleanValue(), is(true)); + assertThat(flagJson(flag2).get("on").booleanValue(), is(false)); + } + + @Test + public void addsFlag() throws Exception { + TestData td = TestData.dataSource(); + DataSource ds = td.createDataSource(null, updates); + Future started = ds.start(); + + assertThat(started.isDone(), is(true)); + assertThat(updates.valid, is(true)); + + td.update(td.flag("flag1").on(true)); + + assertThat(updates.upserts.size(), equalTo(1)); + UpsertParams up = updates.upserts.take(); + assertThat(up.kind, is(DataModel.FEATURES)); + assertThat(up.key, equalTo("flag1")); + ItemDescriptor flag1 = up.item; + assertThat(flag1.getVersion(), equalTo(1)); + assertThat(flagJson(flag1).get("on").booleanValue(), is(true)); + } + + @Test + public void updatesFlag() throws Exception { + TestData td = TestData.dataSource(); + td.update(td.flag("flag1").on(false)); + + DataSource ds = td.createDataSource(null, updates); + Future started = ds.start(); + + assertThat(started.isDone(), is(true)); + assertThat(updates.valid, is(true)); + + td.update(td.flag("flag1").on(true)); + + assertThat(updates.upserts.size(), equalTo(1)); + UpsertParams up = updates.upserts.take(); + assertThat(up.kind, is(DataModel.FEATURES)); + assertThat(up.key, equalTo("flag1")); + ItemDescriptor flag1 = up.item; + assertThat(flag1.getVersion(), equalTo(2)); + assertThat(flagJson(flag1).get("on").booleanValue(), is(true)); + } + + @Test + public void flagConfigSimpleBoolean() throws Exception { + String basicProps = "\"variations\":[true,false],\"offVariation\":1"; + String onProps = basicProps + ",\"on\":true"; + String offProps = basicProps + ",\"on\":false"; + String fallthroughTrue = ",\"fallthrough\":{\"variation\":0}"; + String fallthroughFalse = ",\"fallthrough\":{\"variation\":1}"; + + verifyFlag(f -> f, onProps + fallthroughTrue); + verifyFlag(f -> f.booleanFlag(), onProps + fallthroughTrue); + verifyFlag(f -> f.on(true), onProps + fallthroughTrue); + verifyFlag(f -> f.on(false), offProps + fallthroughTrue); + verifyFlag(f -> f.variationForAllUsers(false), onProps + fallthroughFalse); + verifyFlag(f -> f.variationForAllUsers(true), onProps + fallthroughTrue); + + verifyFlag( + f -> f.fallthroughVariation(true).offVariation(false), + onProps + fallthroughTrue + ); + + verifyFlag( + f -> f.fallthroughVariation(false).offVariation(true), + "\"variations\":[true,false],\"on\":true,\"offVariation\":0,\"fallthrough\":{\"variation\":1}" + ); + } + + @Test + public void usingBooleanConfigMethodsForcesFlagToBeBoolean() throws Exception { + String booleanProps = "\"on\":true" + + ",\"variations\":[true,false],\"offVariation\":1,\"fallthrough\":{\"variation\":0}"; + + verifyFlag( + f -> f.variations(LDValue.of(1), LDValue.of(2)) + .booleanFlag(), + booleanProps + ); + verifyFlag( + f -> f.variations(LDValue.of(true), LDValue.of(2)) + .booleanFlag(), + booleanProps + ); + verifyFlag( + f -> f.booleanFlag(), + booleanProps + ); + } + + @Test + public void flagConfigStringVariations() throws Exception { + String basicProps = "\"variations\":[\"red\",\"green\",\"blue\"],\"on\":true" + + ",\"offVariation\":0,\"fallthrough\":{\"variation\":2}"; + + verifyFlag( + f -> f.variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2), + basicProps + ); + } + + @Test + public void userTargets() throws Exception { + String booleanFlagBasicProps = "\"on\":true,\"variations\":[true,false]" + + ",\"offVariation\":1,\"fallthrough\":{\"variation\":0}"; + verifyFlag( + f -> f.variationForUser("a", true).variationForUser("b", true), + booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"a\",\"b\"]}]" + ); + verifyFlag( + f -> f.variationForUser("a", true).variationForUser("a", true), + booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"a\"]}]" + ); + verifyFlag( + f -> f.variationForUser("a", false).variationForUser("b", true).variationForUser("c", false), + booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"b\"]}" + + ",{\"variation\":1,\"values\":[\"a\",\"c\"]}]" + ); + verifyFlag( + f -> f.variationForUser("a", true).variationForUser("b", true).variationForUser("a", false), + booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"b\"]}" + + ",{\"variation\":1,\"values\":[\"a\"]}]" + ); + + String stringFlagBasicProps = "\"variations\":[\"red\",\"green\",\"blue\"],\"on\":true" + + ",\"offVariation\":0,\"fallthrough\":{\"variation\":2}"; + verifyFlag( + f -> f.variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2) + .variationForUser("a", 2).variationForUser("b", 2), + stringFlagBasicProps + ",\"targets\":[{\"variation\":2,\"values\":[\"a\",\"b\"]}]" + ); + verifyFlag( + f -> f.variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2) + .variationForUser("a", 2).variationForUser("b", 1).variationForUser("c", 2), + stringFlagBasicProps + ",\"targets\":[{\"variation\":1,\"values\":[\"b\"]}" + + ",{\"variation\":2,\"values\":[\"a\",\"c\"]}]" + ); + } + + @Test + public void flagRules() throws Exception { + String basicProps = "\"variations\":[true,false]" + + ",\"on\":true,\"offVariation\":1,\"fallthrough\":{\"variation\":0}"; + + // match that returns variation 0/true + String matchReturnsVariation0 = basicProps + + ",\"rules\":[{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}" + + "]}]"; + verifyFlag( + f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true), + matchReturnsVariation0 + ); + verifyFlag( + f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(0), + matchReturnsVariation0 + ); + + // match that returns variation 1/false + String matchReturnsVariation1 = basicProps + + ",\"rules\":[{\"id\":\"rule0\",\"variation\":1,\"trackEvents\":false,\"clauses\":[" + + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}" + + "]}]"; + verifyFlag( + f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(false), + matchReturnsVariation1 + ); + verifyFlag( + f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(1), + matchReturnsVariation1 + ); + + // negated match + verifyFlag( + f -> f.ifNotMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true), + basicProps + ",\"rules\":[{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":true}" + + "]}]" + ); + + // multiple clauses + verifyFlag( + f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")) + .andMatch(UserAttribute.COUNTRY, LDValue.of("gb")) + .thenReturn(true), + basicProps + ",\"rules\":[{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}," + + "{\"attribute\":\"country\",\"op\":\"in\",\"values\":[\"gb\"],\"negate\":false}" + + "]}]" + ); + + // multiple rules + verifyFlag( + f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true) + .ifMatch(UserAttribute.NAME, LDValue.of("Mina")).thenReturn(true), + basicProps + ",\"rules\":[" + + "{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}" + + "]}," + + "{\"id\":\"rule1\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Mina\"],\"negate\":false}" + + "]}" + + "]" + ); + + } + + private void verifyFlag( + Function configureFlag, + String expectedProps + ) throws Exception { + String expectedJson = "{\"key\":\"flagkey\",\"version\":1," + expectedProps + + ",\"clientSide\":false,\"deleted\":false,\"trackEvents\":false,\"trackEventsFallthrough\":false}"; + + TestData td = TestData.dataSource(); + + DataSource ds = td.createDataSource(null, updates); + ds.start(); + + td.update(configureFlag.apply(td.flag("flagkey"))); + + assertThat(updates.upserts.size(), equalTo(1)); + UpsertParams up = updates.upserts.take(); + ItemDescriptor flag = up.item; + assertThat(flagJson(flag), equalTo(LDValue.parse(expectedJson))); + } + + private static LDValue flagJson(ItemDescriptor flag) { + return LDValue.parse(DataModel.FEATURES.serialize(flag)); + } + + private static class UpsertParams { + final DataKind kind; + final String key; + final ItemDescriptor item; + + UpsertParams(DataKind kind, String key, ItemDescriptor item) { + this.kind = kind; + this.key = key; + this.item = item; + } + } + + private static class CapturingDataSourceUpdates implements DataSourceUpdates { + BlockingQueue> inits = new LinkedBlockingQueue<>(); + BlockingQueue upserts = new LinkedBlockingQueue<>(); + boolean valid; + + @Override + public boolean init(FullDataSet allData) { + inits.add(allData); + return true; + } + + @Override + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + upserts.add(new UpsertParams(kind, key, item)); + return true; + } + + @Override + public DataStoreStatusProvider getDataStoreStatusProvider() { + return null; + } + + @Override + public void updateStatus(State newState, ErrorInfo newError) { + valid = newState == State.VALID; + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java new file mode 100644 index 000000000..452b82c17 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java @@ -0,0 +1,127 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; + +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +@SuppressWarnings("javadoc") +public class TestDataWithClientTest { + private static final String SDK_KEY = "sdk-key"; + + private TestData td = TestData.dataSource(); + private LDConfig config = new LDConfig.Builder() + .dataSource(td) + .events(Components.noEvents()) + .build(); + + @Test + public void initializesWithEmptyData() throws Exception { + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.isInitialized(), is(true)); + } + } + + @Test + public void initializesWithFlag() throws Exception { + td.update(td.flag("flag").on(true)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.boolVariation("flag", new LDUser("user"), false), is(true)); + } + } + + @Test + public void updatesFlag() throws Exception { + td.update(td.flag("flag").on(false)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.boolVariation("flag", new LDUser("user"), false), is(false)); + + td.update(td.flag("flag").on(true)); + + assertThat(client.boolVariation("flag", new LDUser("user"), false), is(true)); + } + } + + @Test + public void usesTargets() throws Exception { + td.update(td.flag("flag").fallthroughVariation(false).variationForUser("user1", true)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.boolVariation("flag", new LDUser("user1"), false), is(true)); + assertThat(client.boolVariation("flag", new LDUser("user2"), false), is(false)); + } + } + + @Test + public void usesRules() throws Exception { + td.update(td.flag("flag").fallthroughVariation(false) + .ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true) + .ifMatch(UserAttribute.NAME, LDValue.of("Mina")).thenReturn(true)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.boolVariation("flag", new LDUser.Builder("user1").name("Lucy").build(), false), is(true)); + assertThat(client.boolVariation("flag", new LDUser.Builder("user2").name("Mina").build(), false), is(true)); + assertThat(client.boolVariation("flag", new LDUser.Builder("user3").name("Quincy").build(), false), is(false)); + } + } + + @Test + public void nonBooleanFlags() throws Exception { + td.update(td.flag("flag").variations(LDValue.of("red"), LDValue.of("green"), LDValue.of("blue")) + .offVariation(0).fallthroughVariation(2) + .variationForUser("user1", 1) + .ifMatch(UserAttribute.NAME, LDValue.of("Mina")).thenReturn(1)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.stringVariation("flag", new LDUser.Builder("user1").name("Lucy").build(), ""), equalTo("green")); + assertThat(client.stringVariation("flag", new LDUser.Builder("user2").name("Mina").build(), ""), equalTo("green")); + assertThat(client.stringVariation("flag", new LDUser.Builder("user3").name("Quincy").build(), ""), equalTo("blue")); + + td.update(td.flag("flag").on(false)); + + assertThat(client.stringVariation("flag", new LDUser.Builder("user1").name("Lucy").build(), ""), equalTo("red")); + } + } + + @Test + public void canUpdateStatus() throws Exception { + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.getDataSourceStatusProvider().getStatus().getState(), equalTo(State.VALID)); + + ErrorInfo ei = ErrorInfo.fromHttpError(500); + td.updateStatus(State.INTERRUPTED, ei); + + assertThat(client.getDataSourceStatusProvider().getStatus().getState(), equalTo(State.INTERRUPTED)); + assertThat(client.getDataSourceStatusProvider().getStatus().getLastError(), equalTo(ei)); + } + } + + @Test + public void dataSourcePropagatesToMultipleClients() throws Exception { + td.update(td.flag("flag").on(true)); + + try (LDClient client1 = new LDClient(SDK_KEY, config)) { + try (LDClient client2 = new LDClient(SDK_KEY, config)) { + assertThat(client1.boolVariation("flag", new LDUser("user"), false), is(true)); + assertThat(client2.boolVariation("flag", new LDUser("user"), false), is(true)); + + td.update(td.flag("flag").on(false)); + + assertThat(client1.boolVariation("flag", new LDUser("user"), false), is(false)); + assertThat(client2.boolVariation("flag", new LDUser("user"), false), is(false)); + } + } + } +}