Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.launchdarkly.sdk.android;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

import android.app.Application;
Expand All @@ -18,6 +20,7 @@
import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult;
import com.launchdarkly.sdk.android.integrations.Plugin;
import com.launchdarkly.sdk.android.integrations.PluginMetadata;
import com.launchdarkly.sdk.android.integrations.RegistrationCompleteResult;
import com.launchdarkly.sdk.android.integrations.TrackSeriesContext;

import org.junit.Before;
Expand Down Expand Up @@ -175,6 +178,245 @@ public void identifyHooksRunForEachEnvironment() throws Exception {
}
}

@Test
public void configuredPluginThatFailsToRegisterContributesNoHooks() throws Exception {
MockHook testHook = new MockHook();
MockPlugin testPlugin = new MockPlugin(Collections.singletonList(testHook), false, true);

try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testPlugin)), ldContext, 1)) {
assertEquals(1, testPlugin.registerCalls.size());

// The hooks go live only once register has succeeded, so this plugin contributes none.
ldClient.boolVariation("test-flag", false);
assertEquals(0, testHook.beforeEvaluationCalls.size());
assertEquals(0, testHook.afterEvaluationCalls.size());

assertEquals(1, testPlugin.onPluginsReadyCalls.size());
RegistrationCompleteResult result =
(RegistrationCompleteResult) testPlugin.onPluginsReadyCalls.get(0).get("result");
assertTrue(result instanceof RegistrationCompleteResult.Failure);

logging.assertErrorLogged("Exception thrown registering plugin");
}
}

@Test
public void configuredPluginWhoseGetHooksThrowsIsNotRegistered() throws Exception {
MockHook testHook = new MockHook();
MockPlugin testPlugin = new MockPlugin(Collections.singletonList(testHook), true, false);

try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(testPlugin)), ldContext, 1)) {
// The logged message says the plugin will not be registered, and it is not.
assertEquals(0, testPlugin.registerCalls.size());

ldClient.boolVariation("test-flag", false);
assertEquals(0, testHook.beforeEvaluationCalls.size());

logging.assertErrorLogged("Unable to get hooks");
}
}

@Test
public void configuredPluginHooksDoNotObserveAnotherPluginsRegister() throws Exception {
MockHook firstHook = new MockHook();
MockPlugin firstPlugin = new MockPlugin(Collections.singletonList(firstHook));
// Registers after the first plugin, and evaluates a flag while doing so.
EvaluateOnRegisterPlugin secondPlugin = new EvaluateOnRegisterPlugin(new MockHook());

try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(firstPlugin, secondPlugin)), ldContext, 1)) {
assertEquals(1, secondPlugin.registerCalls.size());

// Hooks are activated only once every plugin has registered, so the first plugin's hooks
// did not observe the evaluation the second plugin made while registering.
assertEquals(0, firstHook.beforeEvaluationCalls.size());

ldClient.boolVariation("test-flag", false);
assertEquals(1, firstHook.beforeEvaluationCalls.size());

logging.assertNoErrorsLogged();
}
}

@Test
public void configuredPluginFailureDoesNotPreventOtherPlugins() throws Exception {
MockHook goodHook = new MockHook();
MockPlugin badPlugin = new MockPlugin(Collections.emptyList(), false, true);
MockPlugin goodPlugin = new MockPlugin(Collections.singletonList(goodHook));

try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(List.of(badPlugin, goodPlugin)), ldContext, 1)) {
assertEquals(1, goodPlugin.registerCalls.size());

ldClient.boolVariation("test-flag", false);
assertEquals(1, goodHook.beforeEvaluationCalls.size());

logging.assertErrorLogged("Exception thrown registering plugin");
}
}

@Test
public void registerPluginPassesClientAndEnvironmentMetadata() throws Exception {
MockPlugin testPlugin = new MockPlugin(Collections.emptyList());

try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(null), ldContext, 1)) {
ldClient.registerPlugin(testPlugin);

assertEquals(1, testPlugin.getHooksCalls.size());
assertEquals(1, testPlugin.registerCalls.size());
assertEquals(ldClient, testPlugin.registerCalls.get(0).get("client"));

EnvironmentMetadata metadata = (EnvironmentMetadata) testPlugin.registerCalls.get(0).get("environmentMetadata");
assertEquals(mobileKey, metadata.getCredential());
assertEquals("AndroidClient", metadata.getSdkMetadata().getName());

// The same environment description a plugin configured up front would have been given.
assertEquals(metadata, testPlugin.getHooksCalls.get(0).get("environmentMetadata"));

logging.assertNoErrorsLogged();
}
}

@Test
public void registerPluginActivatesBundledHooks() throws Exception {
MockHook testHook = new MockHook();
MockPlugin testPlugin = new MockPlugin(Collections.singletonList(testHook));

try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(null), ldContext, 1)) {
ldClient.registerPlugin(testPlugin);

ldClient.boolVariation("test-flag", false);
assertEquals(1, testHook.beforeEvaluationCalls.size());
assertEquals(1, testHook.afterEvaluationCalls.size());

ldClient.identify(LDContext.create("newUserKey")).get();
// Only the identify made after registration: the implicit one during init predates the plugin.
assertEquals(1, testHook.beforeIdentifyCalls.size());
assertEquals(1, testHook.afterIdentifyCalls.size());

ldClient.track("test-event");
assertEquals(1, testHook.afterTrackCalls.size());

logging.assertNoErrorsLogged();
}
}

@Test
public void registerPluginDoesNotRunTheRegisteringPluginsOwnHooks() throws Exception {
MockHook testHook = new MockHook();
EvaluateOnRegisterPlugin testPlugin = new EvaluateOnRegisterPlugin(testHook);

try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(null), ldContext, 1)) {
ldClient.registerPlugin(testPlugin);

// The plugin evaluated a flag from inside register, but its own hooks only go live once
// register has returned, as they do for a plugin configured up front.
assertEquals(1, testPlugin.registerCalls.size());
assertEquals(0, testHook.beforeEvaluationCalls.size());
assertEquals(0, testHook.afterEvaluationCalls.size());

// They do run for evaluations made once registration has completed.
ldClient.boolVariation("test-flag", false);
assertEquals(1, testHook.beforeEvaluationCalls.size());
assertEquals(1, testHook.afterEvaluationCalls.size());

logging.assertNoErrorsLogged();
}
}

@Test
public void registerPluginDoesNotCallOnPluginsReady() throws Exception {
MockPlugin testPlugin = new MockPlugin(Collections.emptyList());

try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(null), ldContext, 1)) {
ldClient.registerPlugin(testPlugin);

// onPluginsReady reports on a batch of plugins registered together, so registering a
// single plugin on its own has nothing to report and does not call it.
assertEquals(1, testPlugin.registerCalls.size());
assertEquals(0, testPlugin.onPluginsReadyCalls.size());

logging.assertNoErrorsLogged();
}
}

@Test
public void registerPluginDoesNotRegisterPluginWhoseGetHooksThrows() throws Exception {
MockHook testHook = new MockHook();
MockPlugin testPlugin = new MockPlugin(Collections.singletonList(testHook), true, false);

try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(null), ldContext, 1)) {
ldClient.registerPlugin(testPlugin);

assertEquals(0, testPlugin.registerCalls.size());
assertEquals(0, testPlugin.onPluginsReadyCalls.size());

ldClient.boolVariation("test-flag", false);
assertEquals(0, testHook.beforeEvaluationCalls.size());

logging.assertErrorLogged("Unable to get hooks");
}
}

@Test
public void registerPluginToleratesRegisterThrowing() throws Exception {
MockHook testHook = new MockHook();
MockPlugin testPlugin = new MockPlugin(Collections.singletonList(testHook), false, true);

try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(null), ldContext, 1)) {
// The exception is logged rather than propagated.
ldClient.registerPlugin(testPlugin);
assertEquals(1, testPlugin.registerCalls.size());

// A plugin that failed to register contributes no hooks, as one configured up front
// whose register throws now also does not.
ldClient.boolVariation("test-flag", false);
assertEquals(0, testHook.beforeEvaluationCalls.size());
assertEquals(0, testHook.afterEvaluationCalls.size());

// The failure is reported in the log alone, since this path does not call
// onPluginsReady.
assertEquals(0, testPlugin.onPluginsReadyCalls.size());

logging.assertErrorLogged("Exception thrown registering plugin");
}
}

@Test
public void registerPluginRejectsNullPlugin() throws Exception {
try (LDClient ldClient = LDClient.init(application, makeOfflineConfig(null), ldContext, 1)) {
assertThrows(NullPointerException.class, () -> ldClient.registerPlugin(null));
}
}

@Test
public void registerPluginAppliesOnlyToTheClientItIsCalledOn() throws Exception {
MockHook testHook = new MockHook();
MockPlugin testPlugin = new MockPlugin(Collections.singletonList(testHook));

LDConfig config = new LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Disabled)
.mobileKey(mobileKey)
.secondaryMobileKeys(Map.of("secondaryEnvironment", secondaryMobileKey))
.offline(true)
.events(Components.noEvents())
.logAdapter(logging.logAdapter)
.build();

try (LDClient ldClient = LDClient.init(application, config, ldContext, 10)) {
ldClient.registerPlugin(testPlugin);

assertEquals(1, testPlugin.registerCalls.size());
assertEquals(ldClient, testPlugin.registerCalls.get(0).get("client"));

ldClient.boolVariation("test-flag", false);
assertEquals(1, testHook.beforeEvaluationCalls.size());

// The other environment has its own client, which this plugin was not registered with.
LDClient.getForMobileKey("secondaryEnvironment").boolVariation("test-flag", false);
assertEquals(1, testHook.beforeEvaluationCalls.size());

logging.assertNoErrorsLogged();
}
}

private LDConfig makeOfflineConfig(List<Plugin> plugins) {
LDConfig.Builder builder = new LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Disabled)
.mobileKey(mobileKey)
Expand All @@ -192,12 +434,21 @@ private LDConfig makeOfflineConfig(List<Plugin> plugins) {
private static class MockPlugin extends Plugin {

private final List<Hook> hooks;
private final boolean throwOnGetHooks;
private final boolean throwOnRegister;

public final List<Map<String, Object>> getHooksCalls = new ArrayList<>();
public final List<Map<String, Object>> registerCalls = new ArrayList<>();
public final List<Map<String, Object>> onPluginsReadyCalls = new ArrayList<>();

public MockPlugin(List<Hook> hooks) {
this(hooks, false, false);
}

public MockPlugin(List<Hook> hooks, boolean throwOnGetHooks, boolean throwOnRegister) {
this.hooks = hooks;
this.throwOnGetHooks = throwOnGetHooks;
this.throwOnRegister = throwOnRegister;
}

@NonNull
Expand Down Expand Up @@ -230,6 +481,9 @@ public void register(LDClient client, EnvironmentMetadata metadata) {
"client", client,
"environmentMetadata", metadata
));
if (throwOnRegister) {
throw new RuntimeException("register failed for mock-plugin-name");
}
}

@NonNull
Expand All @@ -238,8 +492,61 @@ public List<Hook> getHooks(EnvironmentMetadata metadata) {
getHooksCalls.add(Map.of(
"environmentMetadata", metadata
));
if (throwOnGetHooks) {
throw new RuntimeException("getHooks failed for mock-plugin-name");
}
return this.hooks;
}

// Overridden despite the deprecation so tests can assert both that the configured-plugin
// path still calls it and that registerPlugin does not.
@SuppressWarnings("deprecation")
@Override
public void onPluginsReady(RegistrationCompleteResult result, EnvironmentMetadata metadata) {
onPluginsReadyCalls.add(Map.of(
"result", result,
"environmentMetadata", metadata
));
}
}

/**
* Evaluates a flag from inside {@code register}, so a test can tell whether the plugin's own hooks were live at
* that point.
*/
private static class EvaluateOnRegisterPlugin extends Plugin {

private final Hook hook;

public final List<Map<String, Object>> registerCalls = new ArrayList<>();

public EvaluateOnRegisterPlugin(Hook hook) {
this.hook = hook;
}

@NonNull
@Override
public PluginMetadata getMetadata() {
return new PluginMetadata() {
@NonNull
@Override
public String getName() {
return "evaluate-on-register-plugin";
}
};
}

@Override
public void register(LDClient client, EnvironmentMetadata metadata) {
registerCalls.add(Map.of("client", client, "environmentMetadata", metadata));
client.boolVariation("test-flag", false);
}

@NonNull
@Override
public List<Hook> getHooks(EnvironmentMetadata metadata) {
return Collections.singletonList(hook);
}
}

private static class MockHook extends Hook {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import com.launchdarkly.sdk.android.integrations.TrackSeriesContext;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -73,8 +74,25 @@ private String getHookName(Hook hook) {
* @param hook the hook to add
*/
public synchronized void addHook(Hook hook) {
addHooks(Collections.singletonList(hook));
}

/**
* Adds hooks, which the next series to begin will run. A series already under way runs the hooks it began with.
* <p>
* The hooks become visible in a single step, so a series beginning on another thread runs either all of them or
* none of them, never part of the group. Adding the hooks a plugin contributes this way, rather than with repeated
* {@link #addHook(Hook)} calls, keeps hooks that are meant to work as a set from running half applied.
*
* @param hooksToAdd the hooks to add
*/
public synchronized void addHooks(Collection<Hook> hooksToAdd) {
if (hooksToAdd.isEmpty()) {
return;
}

List<Hook> updated = new ArrayList<>(hooks);
updated.add(hook);
updated.addAll(hooksToAdd);
hooks = Collections.unmodifiableList(updated);
}

Expand Down
Loading
Loading