diff --git a/pom.xml b/pom.xml index abfdc2d5c..26cb9329a 100644 --- a/pom.xml +++ b/pom.xml @@ -145,6 +145,13 @@ 0.5.10 test + + + org.awaitility + awaitility + 4.2.0 + test + diff --git a/src/main/java/dev/openfeature/sdk/FeatureProvider.java b/src/main/java/dev/openfeature/sdk/FeatureProvider.java index 77e9cd67a..7df56a5f0 100644 --- a/src/main/java/dev/openfeature/sdk/FeatureProvider.java +++ b/src/main/java/dev/openfeature/sdk/FeatureProvider.java @@ -22,4 +22,29 @@ default List getProviderHooks() { ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx); ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx); + + /** + * This method is called before a provider is used to evaluate flags. Providers can overwrite this method, + * if they have special initialization needed prior being called for flag evaluation. + *

+ * It is ok, if the method is expensive as it is executed in the background. All runtime exceptions will be + * caught and logged. + *

+ */ + default void initialize() { + // Intentionally left blank + } + + /** + * This method is called when a new provider is about to be used to evaluate flags, or the SDK is shut down. + * Providers can overwrite this method, if they have special shutdown actions needed. + *

+ * It is ok, if the method is expensive as it is executed in the background. All runtime exceptions will be + * caught and logged. + *

+ */ + default void shutdown() { + // Intentionally left blank + } + } diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java index a2ddc453f..2e921a746 100644 --- a/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java +++ b/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.java @@ -1,27 +1,30 @@ package dev.openfeature.sdk; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; - -import javax.annotation.Nullable; - import dev.openfeature.sdk.internal.AutoCloseableLock; import dev.openfeature.sdk.internal.AutoCloseableReentrantReadWriteLock; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; /** * A global singleton which holds base configuration for the OpenFeature library. * Configuration here will be shared across all {@link Client}s. */ +@Slf4j public class OpenFeatureAPI { // package-private multi-read/single-write lock static AutoCloseableReentrantReadWriteLock hooksLock = new AutoCloseableReentrantReadWriteLock(); static AutoCloseableReentrantReadWriteLock contextLock = new AutoCloseableReentrantReadWriteLock(); - private EvaluationContext evaluationContext; + private final List apiHooks; - private FeatureProvider defaultProvider = new NoOpProvider(); - private final Map providers = new ConcurrentHashMap<>(); - private OpenFeatureAPI() { + private ProviderRepository providerRepository = new ProviderRepository(); + private EvaluationContext evaluationContext; + + protected OpenFeatureAPI() { this.apiHooks = new ArrayList<>(); } @@ -31,6 +34,7 @@ private static class SingletonHolder { /** * Provisions the {@link OpenFeatureAPI} singleton (if needed) and returns it. + * * @return The singleton instance. */ public static OpenFeatureAPI getInstance() { @@ -38,7 +42,7 @@ public static OpenFeatureAPI getInstance() { } public Metadata getProviderMetadata() { - return defaultProvider.getMetadata(); + return getProvider().getMetadata(); } public Metadata getProviderMetadata(String clientName) { @@ -79,41 +83,36 @@ public EvaluationContext getEvaluationContext() { * Set the default provider. */ public void setProvider(FeatureProvider provider) { - if (provider == null) { - throw new IllegalArgumentException("Provider cannot be null"); - } - defaultProvider = provider; + providerRepository.setProvider(provider); } /** * Add a provider for a named client. + * * @param clientName The name of the client. - * @param provider The provider to set. + * @param provider The provider to set. */ public void setProvider(String clientName, FeatureProvider provider) { - if (provider == null) { - throw new IllegalArgumentException("Provider cannot be null"); - } - this.providers.put(clientName, provider); + providerRepository.setProvider(clientName, provider); } /** * Return the default provider. */ public FeatureProvider getProvider() { - return defaultProvider; + return providerRepository.getProvider(); } /** * Fetch a provider for a named client. If not found, return the default. + * * @param name The client name to look for. * @return A named {@link FeatureProvider} */ public FeatureProvider getProvider(String name) { - return Optional.ofNullable(name).map(this.providers::get).orElse(defaultProvider); + return providerRepository.getProvider(name); } - /** * {@inheritDoc} */ @@ -140,4 +139,15 @@ public void clearHooks() { this.apiHooks.clear(); } } + + public void shutdown() { + providerRepository.shutdown(); + } + + /** + * This method is only here for testing as otherwise all tests after the API shutdown test would fail. + */ + final void resetProviderRepository() { + providerRepository = new ProviderRepository(); + } } diff --git a/src/main/java/dev/openfeature/sdk/ProviderRepository.java b/src/main/java/dev/openfeature/sdk/ProviderRepository.java new file mode 100644 index 000000000..5a360eb63 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/ProviderRepository.java @@ -0,0 +1,156 @@ +package dev.openfeature.sdk; + +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.stream.Stream; + +@Slf4j +class ProviderRepository { + + private final Map providers = new ConcurrentHashMap<>(); + private final ExecutorService taskExecutor = Executors.newCachedThreadPool(); + private final Map initializingNamedProviders = new ConcurrentHashMap<>(); + private final AtomicReference defaultProvider = new AtomicReference<>(new NoOpProvider()); + private FeatureProvider initializingDefaultProvider; + + /** + * Return the default provider. + */ + public FeatureProvider getProvider() { + return defaultProvider.get(); + } + + /** + * Fetch a provider for a named client. If not found, return the default. + * + * @param name The client name to look for. + * @return A named {@link FeatureProvider} + */ + public FeatureProvider getProvider(String name) { + return Optional.ofNullable(name).map(this.providers::get).orElse(this.defaultProvider.get()); + } + + /** + * Set the default provider. + */ + public void setProvider(FeatureProvider provider) { + if (provider == null) { + throw new IllegalArgumentException("Provider cannot be null"); + } + initializeProvider(provider); + } + + /** + * Add a provider for a named client. + * + * @param clientName The name of the client. + * @param provider The provider to set. + */ + public void setProvider(String clientName, FeatureProvider provider) { + if (provider == null) { + throw new IllegalArgumentException("Provider cannot be null"); + } + if (clientName == null) { + throw new IllegalArgumentException("clientName cannot be null"); + } + initializeProvider(clientName, provider); + } + + private void initializeProvider(FeatureProvider provider) { + initializingDefaultProvider = provider; + initializeProvider(provider, this::updateDefaultProviderAfterInitialization); + } + + private void initializeProvider(String clientName, FeatureProvider provider) { + initializingNamedProviders.put(clientName, provider); + initializeProvider(provider, newProvider -> updateProviderAfterInit(clientName, newProvider)); + } + + private void initializeProvider(FeatureProvider provider, Consumer afterInitialization) { + taskExecutor.submit(() -> { + try { + if (!isProviderRegistered(provider)) { + provider.initialize(); + } + afterInitialization.accept(provider); + } catch (Exception e) { + log.error("Exception when initializing feature provider {}", provider.getClass().getName(), e); + } + }); + } + + private void updateProviderAfterInit(String clientName, FeatureProvider newProvider) { + Optional + .ofNullable(initializingNamedProviders.get(clientName)) + .filter(initializingProvider -> initializingProvider.equals(newProvider)) + .ifPresent(provider -> updateNamedProviderAfterInitialization(clientName, provider)); + } + + private void updateDefaultProviderAfterInitialization(FeatureProvider initializedProvider) { + Optional + .ofNullable(this.initializingDefaultProvider) + .filter(initializingProvider -> initializingProvider.equals(initializedProvider)) + .ifPresent(this::replaceDefaultProvider); + } + + private void replaceDefaultProvider(FeatureProvider provider) { + FeatureProvider oldProvider = this.defaultProvider.getAndSet(provider); + if (isOldProviderNotBoundByName(oldProvider)) { + shutdownProvider(oldProvider); + } + } + + private boolean isOldProviderNotBoundByName(FeatureProvider oldProvider) { + return !this.providers.containsValue(oldProvider); + } + + private void updateNamedProviderAfterInitialization(String clientName, FeatureProvider initializedProvider) { + Optional + .ofNullable(this.initializingNamedProviders.get(clientName)) + .filter(initializingProvider -> initializingProvider.equals(initializedProvider)) + .ifPresent(provider -> replaceNamedProviderAndShutdownOldOne(clientName, provider)); + } + + private void replaceNamedProviderAndShutdownOldOne(String clientName, FeatureProvider provider) { + FeatureProvider oldProvider = this.providers.put(clientName, provider); + this.initializingNamedProviders.remove(clientName, provider); + if (!isProviderRegistered(oldProvider)) { + shutdownProvider(oldProvider); + } + } + + private boolean isProviderRegistered(FeatureProvider oldProvider) { + return this.providers.containsValue(oldProvider) || this.defaultProvider.get().equals(oldProvider); + } + + private void shutdownProvider(FeatureProvider provider) { + taskExecutor.submit(() -> { + try { + provider.shutdown(); + } catch (Exception e) { + log.error("Exception when shutting down feature provider {}", provider.getClass().getName(), e); + } + }); + } + + /** + * Shutdowns this repository which includes shutting down all FeatureProviders that are registered, + * including the default feature provider. + */ + public void shutdown() { + Stream + .concat(Stream.of(this.defaultProvider.get()), this.providers.values().stream()) + .distinct() + .forEach(this::shutdownProvider); + setProvider(new NoOpProvider()); + this.providers.clear(); + taskExecutor.shutdown(); + } +} diff --git a/src/test/java/dev/openfeature/sdk/ClientProviderMappingTest.java b/src/test/java/dev/openfeature/sdk/ClientProviderMappingTest.java index 654fb335a..8f022a384 100644 --- a/src/test/java/dev/openfeature/sdk/ClientProviderMappingTest.java +++ b/src/test/java/dev/openfeature/sdk/ClientProviderMappingTest.java @@ -1,18 +1,18 @@ package dev.openfeature.sdk; -import io.cucumber.java.eo.Do; +import dev.openfeature.sdk.testutils.FeatureProviderTestUtils; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; + +class ClientProviderMappingTest { -public class ClientProviderMappingTest { @Test void clientProviderTest() { OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider("client1", new DoSomethingProvider()); - api.setProvider("client2", new NoOpProvider()); + FeatureProviderTestUtils.setFeatureProvider("client1", new DoSomethingProvider()); + FeatureProviderTestUtils.setFeatureProvider("client2", new NoOpProvider()); Client c1 = api.getClient("client1"); Client c2 = api.getClient("client2"); diff --git a/src/test/java/dev/openfeature/sdk/DeveloperExperienceTest.java b/src/test/java/dev/openfeature/sdk/DeveloperExperienceTest.java index 600dc7a66..b5e5bedf3 100644 --- a/src/test/java/dev/openfeature/sdk/DeveloperExperienceTest.java +++ b/src/test/java/dev/openfeature/sdk/DeveloperExperienceTest.java @@ -12,6 +12,7 @@ import java.util.Map; import java.util.Optional; +import dev.openfeature.sdk.testutils.FeatureProviderTestUtils; import org.junit.jupiter.api.Test; import dev.openfeature.sdk.fixtures.HookFixtures; @@ -77,7 +78,7 @@ class DeveloperExperienceTest implements HookFixtures { @Test void brokenProvider() { OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new AlwaysBrokenProvider()); + FeatureProviderTestUtils.setFeatureProvider(new AlwaysBrokenProvider()); Client client = api.getClient(); FlagEvaluationDetails retval = client.getBooleanDetails(flagKey, false); assertEquals(ErrorCode.FLAG_NOT_FOUND, retval.getErrorCode()); @@ -87,14 +88,14 @@ class DeveloperExperienceTest implements HookFixtures { } @Test - void providerLockedPerTransaction() throws InterruptedException { + void providerLockedPerTransaction() { class MutatingHook implements Hook { @Override // change the provider during a before hook - this should not impact the evaluation in progress public Optional before(HookContext ctx, Map hints) { - OpenFeatureAPI.getInstance().setProvider(new NoOpProvider()); + FeatureProviderTestUtils.setFeatureProvider(new NoOpProvider()); return Optional.empty(); } } @@ -102,7 +103,7 @@ public Optional before(HookContext ctx, Map hints) { final String defaultValue = "string-value"; final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); final Client client = api.getClient(); - api.setProvider(new DoSomethingProvider()); + FeatureProviderTestUtils.setFeatureProvider(new DoSomethingProvider()); api.addHooks(new MutatingHook()); // if provider is changed during an evaluation transaction it should proceed with the original provider diff --git a/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java b/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java index e246c6d61..e508bdd07 100644 --- a/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java @@ -1,7 +1,6 @@ package dev.openfeature.sdk; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.InstanceOfAssertFactories.optional; +import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; @@ -12,14 +11,12 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import java.io.Serializable; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; - import dev.openfeature.sdk.exceptions.FlagNotFoundError; +import dev.openfeature.sdk.testutils.FeatureProviderTestUtils; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -33,15 +30,19 @@ class FlagEvaluationSpecTest implements HookFixtures { private Logger logger; + private OpenFeatureAPI api; private Client _client() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new NoOpProvider()); + FeatureProviderTestUtils.setFeatureProvider(new NoOpProvider()); return api.getClient(); } + @BeforeEach + void getApiInstance() { + api = OpenFeatureAPI.getInstance(); + } + @AfterEach void reset_ctx() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); api.setEvaluationContext(null); } @@ -61,24 +62,21 @@ private Client _client() { @Specification(number="1.1.2", text="The API MUST provide a function to set the global provider singleton, which accepts an API-conformant provider implementation.") @Test void provider() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); FeatureProvider mockProvider = mock(FeatureProvider.class); - api.setProvider(mockProvider); - assertEquals(mockProvider, api.getProvider()); + FeatureProviderTestUtils.setFeatureProvider(mockProvider); + assertThat(api.getProvider()).isEqualTo(mockProvider); } @Specification(number="1.1.4", text="The API MUST provide a function for retrieving the metadata field of the configured provider.") @Test void provider_metadata() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new DoSomethingProvider()); - assertEquals(DoSomethingProvider.name, api.getProviderMetadata().getName()); + FeatureProviderTestUtils.setFeatureProvider(new DoSomethingProvider()); + assertThat(api.getProviderMetadata().getName()).isEqualTo(DoSomethingProvider.name); } @Specification(number="1.1.3", text="The API MUST provide a function to add hooks which accepts one or more API-conformant hooks, and appends them to the collection of any previously added hooks. When new hooks are added, previously added hooks are not removed.") @Test void hook_addition() { Hook h1 = mock(Hook.class); Hook h2 = mock(Hook.class); - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); api.addHooks(h1); assertEquals(1, api.getHooks().size()); @@ -91,8 +89,7 @@ private Client _client() { @Specification(number="1.1.5", text="The API MUST provide a function for creating a client which accepts the following options: - name (optional): A logical string identifier for the client.") @Test void namedClient() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - Client c = api.getClient("Sir Calls-a-lot"); + assertThatCode(() -> api.getClient("Sir Calls-a-lot")).doesNotThrowAnyException(); // TODO: Doesn't say that you can *get* the client name.. which seems useful? } @@ -112,8 +109,8 @@ private Client _client() { @Specification(number="1.3.1", text="The client MUST provide methods for typed flag evaluation, including boolean, numeric, string, and structure, with parameters flag key (string, required), default value (boolean | number | string | structure, required), evaluation context (optional), and evaluation options (optional), which returns the flag value.") @Specification(number="1.3.2.1", text="The client SHOULD provide functions for floating-point numbers and integers, consistent with language idioms.") @Test void value_flags() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new DoSomethingProvider()); + FeatureProviderTestUtils.setFeatureProvider(new DoSomethingProvider()); + Client c = api.getClient(); String key = "key"; @@ -145,8 +142,7 @@ private Client _client() { @Specification(number="1.4.5", text="In cases of normal execution, the evaluation details structure's variant field MUST contain the value of the variant field in the flag resolution structure returned by the configured provider, if the field is set.") @Specification(number="1.4.6", text="In cases of normal execution, the evaluation details structure's reason field MUST contain the value of the reason field in the flag resolution structure returned by the configured provider, if the field is set.") @Test void detail_flags() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new DoSomethingProvider()); + FeatureProviderTestUtils.setFeatureProvider(new DoSomethingProvider()); Client c = api.getClient(); String key = "key"; @@ -204,8 +200,7 @@ private Client _client() { @Specification(number="1.4.7", text="In cases of abnormal execution, the `evaluation details` structure's `error code` field **MUST** contain an `error code`.") @Specification(number="1.4.12", text="In cases of abnormal execution, the `evaluation details` structure's `error message` field **MAY** contain a string containing additional details about the nature of the error.") @Test void broken_provider() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new AlwaysBrokenProvider()); + FeatureProviderTestUtils.setFeatureProvider(new AlwaysBrokenProvider()); Client c = api.getClient(); assertFalse(c.getBooleanValue("key", false)); FlagEvaluationDetails details = c.getBooleanDetails("key", false); @@ -215,8 +210,7 @@ private Client _client() { @Specification(number="1.4.10", text="In the case of abnormal execution, the client SHOULD log an informative error message.") @Test void log_on_error() throws NotImplementedException { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new AlwaysBrokenProvider()); + FeatureProviderTestUtils.setFeatureProvider(new AlwaysBrokenProvider()); Client c = api.getClient(); FlagEvaluationDetails result = c.getBooleanDetails("test", false); @@ -232,16 +226,14 @@ private Client _client() { Client c = _client(); assertNull(c.getMetadata().getName()); - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new AlwaysBrokenProvider()); + FeatureProviderTestUtils.setFeatureProvider(new AlwaysBrokenProvider()); Client c2 = api.getClient("test"); assertEquals("test", c2.getMetadata().getName()); } @Specification(number="1.4.8", text="In cases of abnormal execution (network failure, unhandled error, etc) the reason field in the evaluation details SHOULD indicate an error.") @Test void reason_is_error_when_there_are_errors() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(new AlwaysBrokenProvider()); + FeatureProviderTestUtils.setFeatureProvider(new AlwaysBrokenProvider()); Client c = api.getClient(); FlagEvaluationDetails result = c.getBooleanDetails("test", false); assertEquals(Reason.ERROR.toString(), result.getReason()); @@ -250,9 +242,8 @@ private Client _client() { @Specification(number="3.2.1", text="The API, Client and invocation MUST have a method for supplying evaluation context.") @Specification(number="3.2.2", text="Evaluation context MUST be merged in the order: API (global; lowest precedence) - client - invocation - before hooks (highest precedence), with duplicate values being overwritten.") @Test void multi_layer_context_merges_correctly() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); DoSomethingProvider provider = new DoSomethingProvider(); - api.setProvider(provider); + FeatureProviderTestUtils.setFeatureProvider(provider); Map attributes = new HashMap<>(); attributes.put("common", new Value("1")); diff --git a/src/test/java/dev/openfeature/sdk/HookSpecTest.java b/src/test/java/dev/openfeature/sdk/HookSpecTest.java index 26a1b49e5..d1daa7056 100644 --- a/src/test/java/dev/openfeature/sdk/HookSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/HookSpecTest.java @@ -21,7 +21,7 @@ import java.util.Map; import java.util.Optional; -import io.cucumber.java.hu.Ha; +import dev.openfeature.sdk.testutils.FeatureProviderTestUtils; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -30,7 +30,7 @@ import dev.openfeature.sdk.fixtures.HookFixtures; import lombok.SneakyThrows; -public class HookSpecTest implements HookFixtures { +class HookSpecTest implements HookFixtures { @AfterEach void emptyApiHooks() { // it's a singleton. Don't pollute each test. @@ -390,7 +390,7 @@ public void finallyAfter(HookContext ctx, Map hints) { InOrder order = inOrder(hook, provider); OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(provider); + FeatureProviderTestUtils.setFeatureProvider(provider); Client client = api.getClient(); client.getBooleanValue("key", false, new ImmutableContext(), FlagEvaluationOptions.builder().hook(hook).build()); @@ -493,7 +493,7 @@ public void finallyAfter(HookContext ctx, Map hints) { .build()); OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(provider); + FeatureProviderTestUtils.setFeatureProvider(provider); Client client = api.getClient(); client.getBooleanValue("key", false, invocationCtx, FlagEvaluationOptions.builder() @@ -551,12 +551,11 @@ public void finallyAfter(HookContext ctx, Map hints) { private Client getClient(FeatureProvider provider) { OpenFeatureAPI api = OpenFeatureAPI.getInstance(); if (provider == null) { - api.setProvider(new NoOpProvider()); + FeatureProviderTestUtils.setFeatureProvider(new NoOpProvider()); } else { - api.setProvider(provider); + FeatureProviderTestUtils.setFeatureProvider(provider); } - Client client = api.getClient(); - return client; + return api.getClient(); } @Specification(number="4.3.1", text="Hooks MUST specify at least one stage.") @@ -565,14 +564,12 @@ private Client getClient(FeatureProvider provider) { @Specification(number="4.3.8.1", text="Instead of finally, finallyAfter SHOULD be used.") @SneakyThrows @Test void doesnt_use_finally() { - try { - Hook.class.getMethod("finally", HookContext.class, Map.class); - fail("Not possible. Finally is a reserved word."); - } catch (NoSuchMethodException e) { - // expected - } + assertThatCode(() -> Hook.class.getMethod("finally", HookContext.class, Map.class)) + .as("Not possible. Finally is a reserved word.") + .isInstanceOf(NoSuchMethodException.class); - Hook.class.getMethod("finallyAfter", HookContext.class, Map.class); + assertThatCode(() -> Hook.class.getMethod("finallyAfter", HookContext.class, Map.class)) + .doesNotThrowAnyException(); } } diff --git a/src/test/java/dev/openfeature/sdk/InitializeBehaviorSpecTest.java b/src/test/java/dev/openfeature/sdk/InitializeBehaviorSpecTest.java new file mode 100644 index 000000000..7061719fa --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/InitializeBehaviorSpecTest.java @@ -0,0 +1,81 @@ +package dev.openfeature.sdk; + +import dev.openfeature.sdk.testutils.exception.TestException; +import org.junit.jupiter.api.*; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.*; + +class InitializeBehaviorSpecTest { + + @BeforeEach + void setupTest() { + OpenFeatureAPI.getInstance().setProvider(new NoOpProvider()); + } + + @Nested + class DefaultProvider { + + @Specification(number = "1.1.2.2", text = "The `provider mutator` function MUST invoke the `initialize` " + + "function on the newly registered provider before using it to resolve flag values.") + @Test + @DisplayName("must call initialize function of the newly registered provider before using it for " + + "flag evaluation") + void mustCallInitializeFunctionOfTheNewlyRegisteredProviderBeforeUsingItForFlagEvaluation() { + FeatureProvider featureProvider = mock(FeatureProvider.class); + + OpenFeatureAPI.getInstance().setProvider(featureProvider); + + verify(featureProvider, timeout(1000)).initialize(); + } + + @Specification(number = "1.4.9", text = "Methods, functions, or operations on the client MUST NOT throw " + + "exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the " + + "`default value` in the event of abnormal execution. Exceptions include functions or methods for " + + "the purposes for configuration or setup.") + @Test + @DisplayName("should catch exception thrown by the provider on initialization") + void shouldCatchExceptionThrownByTheProviderOnInitialization() { + FeatureProvider featureProvider = mock(FeatureProvider.class); + doThrow(TestException.class).when(featureProvider).initialize(); + + assertThatCode(() -> OpenFeatureAPI.getInstance().setProvider(featureProvider)) + .doesNotThrowAnyException(); + + verify(featureProvider, timeout(1000)).initialize(); + } + } + + @Nested + class ProviderForNamedClient { + + @Specification(number = "1.1.2.2", text = "The `provider mutator` function MUST invoke the `initialize`" + + " function on the newly registered provider before using it to resolve flag values.") + @Test + @DisplayName("must call initialize function of the newly registered named provider before using it " + + "for flag evaluation") + void mustCallInitializeFunctionOfTheNewlyRegisteredNamedProviderBeforeUsingItForFlagEvaluation() { + FeatureProvider featureProvider = mock(FeatureProvider.class); + + OpenFeatureAPI.getInstance().setProvider("clientName", featureProvider); + + verify(featureProvider, timeout(1000)).initialize(); + } + + @Specification(number = "1.4.9", text = "Methods, functions, or operations on the client MUST NOT throw " + + "exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the " + + "`default value` in the event of abnormal execution. Exceptions include functions or methods for " + + "the purposes for configuration or setup.") + @Test + @DisplayName("should catch exception thrown by the named client provider on initialization") + void shouldCatchExceptionThrownByTheNamedClientProviderOnInitialization() { + FeatureProvider featureProvider = mock(FeatureProvider.class); + doThrow(TestException.class).when(featureProvider).initialize(); + + assertThatCode(() -> OpenFeatureAPI.getInstance().setProvider("clientName", featureProvider)) + .doesNotThrowAnyException(); + + verify(featureProvider, timeout(1000)).initialize(); + } + } +} diff --git a/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java b/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java index 4428f9ff9..a49bf643c 100644 --- a/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java +++ b/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java @@ -1,26 +1,39 @@ package dev.openfeature.sdk; +import dev.openfeature.sdk.testutils.FeatureProviderTestUtils; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class OpenFeatureAPITest { + + private static final String CLIENT_NAME = "client name"; + + private OpenFeatureAPI api; + + @BeforeEach + void setupTest() { + api = OpenFeatureAPI.getInstance(); + } -public class OpenFeatureAPITest { @Test void namedProviderTest() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); FeatureProvider provider = new NoOpProvider(); - api.setProvider("namedProviderTest", provider); - assertEquals(provider.getMetadata().getName(), api.getProviderMetadata("namedProviderTest").getName()); + FeatureProviderTestUtils.setFeatureProvider("namedProviderTest", provider); + + assertThat(provider.getMetadata().getName()) + .isEqualTo(api.getProviderMetadata("namedProviderTest").getName()); } - @Test void settingDefaultProviderToNullErrors() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - assertThrows(IllegalArgumentException.class, () -> api.setProvider(null)); + @Test + void settingDefaultProviderToNullErrors() { + assertThatCode(() -> api.setProvider(null)).isInstanceOf(IllegalArgumentException.class); } - @Test void settingNamedClientProviderToNullErrors() { - OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - assertThrows(IllegalArgumentException.class, () -> api.setProvider("client-name", null)); + @Test + void settingNamedClientProviderToNullErrors() { + assertThatCode(() -> api.setProvider(CLIENT_NAME, null)).isInstanceOf(IllegalArgumentException.class); } } diff --git a/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java b/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java new file mode 100644 index 000000000..00c7949e6 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/ProviderRepositoryTest.java @@ -0,0 +1,425 @@ +package dev.openfeature.sdk; + +import dev.openfeature.sdk.testutils.exception.TestException; +import lombok.SneakyThrows; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Function; + +import static dev.openfeature.sdk.fixtures.ProviderFixture.*; +import static dev.openfeature.sdk.testutils.stubbing.ConditionStubber.doBlock; +import static dev.openfeature.sdk.testutils.stubbing.ConditionStubber.doDelayResponse; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class ProviderRepositoryTest { + + private static final String CLIENT_NAME = "client name"; + private static final String ANOTHER_CLIENT_NAME = "another client name"; + private static final String FEATURE_KEY = "some key"; + + private final ExecutorService executorService = Executors.newCachedThreadPool(); + + private ProviderRepository providerRepository; + + @BeforeEach + void setupTest() { + providerRepository = new ProviderRepository(); + } + + @Nested + class InitializationBehavior { + + @Nested + class DefaultProvider { + + @Test + @DisplayName("should reject null as default provider") + void shouldRejectNullAsDefaultProvider() { + assertThatCode(() -> providerRepository.setProvider(null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("should have NoOpProvider set as default on initialization") + void shouldHaveNoOpProviderSetAsDefaultOnInitialization() { + assertThat(providerRepository.getProvider()).isInstanceOf(NoOpProvider.class); + } + + @Test + @DisplayName("should immediately return when calling the provider mutator") + void shouldImmediatelyReturnWhenCallingTheProviderMutator() { + FeatureProvider featureProvider = createMockedProvider(); + doDelayResponse(Duration.ofSeconds(10)).when(featureProvider).initialize(); + + await() + .alias("wait for provider mutator to return") + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .until(() -> { + providerRepository.setProvider(featureProvider); + verify(featureProvider, timeout(100)).initialize(); + return true; + }); + + verify(featureProvider).initialize(); + } + + @Test + @DisplayName("should not return set provider if initialize has not yet been finished executing") + void shouldNotReturnSetProviderIfItsInitializeMethodHasNotYetBeenFinishedExecuting() { + CountDownLatch latch = new CountDownLatch(1); + FeatureProvider newProvider = createMockedProvider(); + doBlock(latch).when(newProvider).initialize(); + FeatureProvider oldProvider = providerRepository.getProvider(); + + providerRepository.setProvider(newProvider); + + FeatureProvider providerWhileInitialization = providerRepository.getProvider(); + latch.countDown(); + + assertThat(providerWhileInitialization).isEqualTo(oldProvider); + await() + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(providerRepository.getProvider()).isEqualTo(newProvider)); + verify(newProvider, timeout(100)).initialize(); + } + + @SneakyThrows + @Test + @DisplayName("should discard provider still initializing if a newer has finished before") + void shouldDiscardProviderStillInitializingIfANewerHasFinishedBefore() { + CountDownLatch latch = new CountDownLatch(1); + CountDownLatch testBlockingLatch = new CountDownLatch(1); + FeatureProvider blockedProvider = createBlockedProvider(latch, testBlockingLatch::countDown); + FeatureProvider fastProvider = createUnblockingProvider(latch); + + providerRepository.setProvider(blockedProvider); + providerRepository.setProvider(fastProvider); + + assertThat(testBlockingLatch.await(2, SECONDS)) + .as("blocking provider initialization not completed within 2 seconds") + .isTrue(); + + await() + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(providerRepository.getProvider()).isEqualTo(fastProvider)); + + verify(blockedProvider, timeout(100)).initialize(); + verify(fastProvider, timeout(100)).initialize(); + } + + @Test + @DisplayName("should avoid additional initialization call if provider has been initialized already") + void shouldAvoidAdditionalInitializationCallIfProviderHasBeenInitializedAlready() { + FeatureProvider provider = createMockedProvider(); + setFeatureProvider(CLIENT_NAME, provider); + + setFeatureProvider(provider); + + verify(provider).initialize(); + } + } + + @Nested + class NamedProvider { + + @Test + @DisplayName("should reject null as named provider") + void shouldRejectNullAsNamedProvider() { + assertThatCode(() -> providerRepository.setProvider(CLIENT_NAME, null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("should reject null as client name") + void shouldRejectNullAsDefaultProvider() { + NoOpProvider provider = new NoOpProvider(); + assertThatCode(() -> providerRepository.setProvider(null, provider)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("should immediately return when calling the named client provider mutator") + void shouldImmediatelyReturnWhenCallingTheNamedClientProviderMutator() { + FeatureProvider featureProvider = createMockedProvider(); + doDelayResponse(Duration.ofSeconds(10)).when(featureProvider).initialize(); + + await() + .alias("wait for provider mutator to return") + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .until(() -> { + providerRepository.setProvider("named client", featureProvider); + verify(featureProvider, timeout(1000)).initialize(); + return true; + }); + } + + @Test + @DisplayName("should not return set provider if it's initialization has not yet been finished executing") + void shouldNotReturnSetProviderIfItsInitializeMethodHasNotYetBeenFinishedExecuting() { + CountDownLatch latch = new CountDownLatch(1); + FeatureProvider newProvider = createMockedProvider(); + doBlock(latch).when(newProvider).initialize(); + FeatureProvider oldProvider = createMockedProvider(); + setFeatureProvider(CLIENT_NAME, oldProvider); + + providerRepository.setProvider(CLIENT_NAME, newProvider); + FeatureProvider providerWhileInitialization = getNamedProvider(); + latch.countDown(); + + assertThat(providerWhileInitialization).isEqualTo(oldProvider); + await() + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(getNamedProvider()).isEqualTo(newProvider)); + verify(newProvider, timeout(100)).initialize(); + } + + @SneakyThrows + @Test + @DisplayName("should discard provider still initializing if a newer has finished before") + void shouldDiscardProviderStillInitializingIfANewerHasFinishedBefore() { + String clientName = "clientName"; + CountDownLatch latch = new CountDownLatch(1); + CountDownLatch testBlockingLatch = new CountDownLatch(1); + FeatureProvider blockedProvider = createBlockedProvider(latch, testBlockingLatch::countDown); + FeatureProvider unblockingProvider = createUnblockingProvider(latch); + + providerRepository.setProvider(clientName, blockedProvider); + providerRepository.setProvider(clientName, unblockingProvider); + + assertThat(testBlockingLatch.await(2, SECONDS)) + .as("blocking provider initialization not completed within 2 seconds") + .isTrue(); + + await() + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(providerRepository.getProvider(clientName)) + .isEqualTo(unblockingProvider)); + + verify(blockedProvider, timeout(100)).initialize(); + verify(unblockingProvider, timeout(100)).initialize(); + } + + @Test + @DisplayName("should avoid additional initialization call if provider has been initialized already") + void shouldAvoidAdditionalInitializationCallIfProviderHasBeenInitializedAlready() { + FeatureProvider provider = createMockedProvider(); + setFeatureProvider(provider); + + setFeatureProvider(CLIENT_NAME, provider); + + verify(provider).initialize(); + } + } + } + + @Nested + class ShutdownBehavior { + + @Nested + class DefaultProvider { + + @Test + @DisplayName("should immediately return when calling the provider mutator") + void shouldImmediatelyReturnWhenCallingTheProviderMutator() { + FeatureProvider newProvider = createMockedProvider(); + doDelayResponse(Duration.ofSeconds(10)).when(newProvider).initialize(); + + await() + .alias("wait for provider mutator to return") + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .until(() -> { + providerRepository.setProvider(newProvider); + verify(newProvider, timeout(100)).initialize(); + return true; + }); + + verify(newProvider).initialize(); + } + + @Test + @DisplayName("should use old provider if replacing one has not yet been finished initializing") + void shouldUseOldProviderIfReplacingOneHasNotYetBeenFinishedInitializing() { + CountDownLatch latch = new CountDownLatch(1); + FeatureProvider newProvider = createMockedProvider(); + doBlock(latch).when(newProvider).initialize(); + FeatureProvider oldProvider = createMockedProvider(); + + setFeatureProvider(oldProvider); + providerRepository.setProvider(newProvider); + + providerRepository.getProvider().getBooleanEvaluation("some key", true, null); + latch.countDown(); + + await() + .atMost(Duration.ofSeconds(1)) + .pollDelay(Duration.ofMillis(1)) + .untilAsserted(() -> assertThat(getProvider()).isEqualTo(newProvider)); + verify(oldProvider, timeout(100)).getBooleanEvaluation(any(), any(), any()); + verify(newProvider, never()).getBooleanEvaluation(any(), any(), any()); + } + + @Test + @DisplayName("should not call shutdown if replaced default provider is bound as named provider") + void shouldNotCallShutdownIfReplacedDefaultProviderIsBoundAsNamedProvider() { + FeatureProvider oldProvider = createMockedProvider(); + FeatureProvider newProvider = createMockedProvider(); + setFeatureProvider(oldProvider); + setFeatureProvider(CLIENT_NAME, oldProvider); + + setFeatureProvider(newProvider); + + verify(oldProvider, never()).shutdown(); + } + } + + @Nested + class NamedProvider { + + @Test + @DisplayName("should immediately return when calling the provider mutator") + void shouldImmediatelyReturnWhenCallingTheProviderMutator() { + FeatureProvider newProvider = createMockedProvider(); + doDelayResponse(Duration.ofSeconds(10)).when(newProvider).initialize(); + + Future providerMutation = executorService + .submit(() -> providerRepository.setProvider(CLIENT_NAME, newProvider)); + + await() + .alias("wait for provider mutator to return") + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .until(providerMutation::isDone); + } + + @Test + @DisplayName("should use old provider if replacement one has not yet been finished initializing") + void shouldUseOldProviderIfReplacementHasNotYetBeenFinishedInitializing() { + CountDownLatch latch = new CountDownLatch(1); + FeatureProvider newProvider = createMockedProvider(); + doBlock(latch).when(newProvider).initialize(); + FeatureProvider oldProvider = createMockedProvider(); + + setFeatureProvider(CLIENT_NAME, oldProvider); + providerRepository.setProvider(CLIENT_NAME, newProvider); + + providerRepository.getProvider(CLIENT_NAME).getBooleanEvaluation(FEATURE_KEY, true, null); + latch.countDown(); + + await() + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(getNamedProvider()).isEqualTo(newProvider)); + verify(oldProvider, timeout(100)).getBooleanEvaluation(eq(FEATURE_KEY), any(), any()); + verify(newProvider, never()).getBooleanEvaluation(any(), any(), any()); + } + + @Test + @DisplayName("should not call shutdown if replaced provider is bound to multiple names") + void shouldNotCallShutdownIfReplacedProviderIsBoundToMultipleNames() { + FeatureProvider oldProvider = createMockedProvider(); + FeatureProvider newProvider = createMockedProvider(); + setFeatureProvider(CLIENT_NAME, oldProvider); + setFeatureProvider(ANOTHER_CLIENT_NAME, oldProvider); + + setFeatureProvider(CLIENT_NAME, newProvider); + + verify(oldProvider, never()).shutdown(); + } + + @Test + @DisplayName("should not call shutdown if replaced provider is bound as default provider") + void shouldNotCallShutdownIfReplacedProviderIsBoundAsDefaultProvider() { + FeatureProvider oldProvider = createMockedProvider(); + FeatureProvider newProvider = createMockedProvider(); + setFeatureProvider(oldProvider); + setFeatureProvider(CLIENT_NAME, oldProvider); + + setFeatureProvider(CLIENT_NAME, newProvider); + + verify(oldProvider, never()).shutdown(); + } + + @Test + @DisplayName("should not throw exception if provider throws one on shutdown") + void shouldNotThrowExceptionIfProviderThrowsOneOnShutdown() { + FeatureProvider provider = createMockedProvider(); + doThrow(TestException.class).when(provider).shutdown(); + setFeatureProvider(provider); + + assertThatCode(() -> setFeatureProvider(new NoOpProvider())).doesNotThrowAnyException(); + + verify(provider).shutdown(); + } + } + } + + @Test + @DisplayName("should shutdown all feature providers on shutdown") + void shouldShutdownAllFeatureProvidersOnShutdown() { + FeatureProvider featureProvider1 = createMockedProvider(); + FeatureProvider featureProvider2 = createMockedProvider(); + + setFeatureProvider(featureProvider1); + setFeatureProvider(CLIENT_NAME, featureProvider1); + setFeatureProvider(ANOTHER_CLIENT_NAME, featureProvider2); + + providerRepository.shutdown(); + + await() + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> { + assertThat(providerRepository.getProvider()).isInstanceOf(NoOpProvider.class); + assertThat(providerRepository.getProvider(CLIENT_NAME)).isInstanceOf(NoOpProvider.class); + assertThat(providerRepository.getProvider(ANOTHER_CLIENT_NAME)).isInstanceOf(NoOpProvider.class); + }); + verify(featureProvider1).shutdown(); + verify(featureProvider2).shutdown(); + } + + private FeatureProvider getProvider() { + return providerRepository.getProvider(); + } + + private FeatureProvider getNamedProvider() { + return providerRepository.getProvider(CLIENT_NAME); + } + + private void setFeatureProvider(FeatureProvider provider) { + providerRepository.setProvider(provider); + waitForSettingProviderHasBeenCompleted(ProviderRepository::getProvider, provider); + } + + private void setFeatureProvider(String namedProvider, FeatureProvider provider) { + providerRepository.setProvider(namedProvider, provider); + waitForSettingProviderHasBeenCompleted(repository -> repository.getProvider(namedProvider), provider); + } + + private void waitForSettingProviderHasBeenCompleted( + Function extractor, + FeatureProvider provider) { + await() + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .until(() -> extractor.apply(providerRepository) == provider); + } + +} diff --git a/src/test/java/dev/openfeature/sdk/ShutdownBehaviorSpecTest.java b/src/test/java/dev/openfeature/sdk/ShutdownBehaviorSpecTest.java new file mode 100644 index 000000000..d191c8c42 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/ShutdownBehaviorSpecTest.java @@ -0,0 +1,117 @@ +package dev.openfeature.sdk; + +import dev.openfeature.sdk.fixtures.ProviderFixture; +import dev.openfeature.sdk.testutils.exception.TestException; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static dev.openfeature.sdk.testutils.FeatureProviderTestUtils.setFeatureProvider; +import static org.mockito.Mockito.*; + +class ShutdownBehaviorSpecTest { + + @BeforeEach + void resetFeatureProvider() { + setFeatureProvider(new NoOpProvider()); + } + + @Nested + class DefaultProvider { + + @Specification(number = "1.1.2.3", text = "The `provider mutator` function MUST invoke the `shutdown` function on the previously registered provider once it's no longer being used to resolve flag values.") + @Test + @DisplayName("must invoke shutdown method on previously registered provider once it should not be used for flag evaluation anymore") + void mustInvokeShutdownMethodOnPreviouslyRegisteredProviderOnceItShouldNotBeUsedForFlagEvaluationAnymore() { + FeatureProvider featureProvider = ProviderFixture.createMockedProvider(); + + setFeatureProvider(featureProvider); + setFeatureProvider(new NoOpProvider()); + + verify(featureProvider, timeout(1000)).shutdown(); + } + + @Specification(number = "1.4.9", text = "Methods, functions, or operations on the client MUST NOT throw " + + "exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the " + + "`default value` in the event of abnormal execution. Exceptions include functions or methods for " + + "the purposes for configuration or setup.") + @Test + @DisplayName("should catch exception thrown by the provider on shutdown") + void shouldCatchExceptionThrownByTheProviderOnShutdown() { + FeatureProvider featureProvider = ProviderFixture.createMockedProvider(); + doThrow(TestException.class).when(featureProvider).shutdown(); + + setFeatureProvider(featureProvider); + setFeatureProvider(new NoOpProvider()); + + verify(featureProvider, timeout(1000)).shutdown(); + } + } + + @Nested + class NamedProvider { + + @Specification(number = "1.1.2.3", text = "The `provider mutator` function MUST invoke the `shutdown` function on the previously registered provider once it's no longer being used to resolve flag values.") + @Test + @DisplayName("must invoke shutdown method on previously registered provider once it should not be used for flag evaluation anymore") + void mustInvokeShutdownMethodOnPreviouslyRegisteredProviderOnceItShouldNotBeUsedForFlagEvaluationAnymore() { + String clientName = "clientName"; + FeatureProvider featureProvider = ProviderFixture.createMockedProvider(); + + setFeatureProvider(clientName, featureProvider); + setFeatureProvider(clientName, new NoOpProvider()); + + verify(featureProvider, timeout(1000)).shutdown(); + } + + @Specification(number = "1.4.9", text = "Methods, functions, or operations on the client MUST NOT throw " + + "exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the " + + "`default value` in the event of abnormal execution. Exceptions include functions or methods for " + + "the purposes for configuration or setup.") + @Test + @DisplayName("should catch exception thrown by the named client provider on shutdown") + void shouldCatchExceptionThrownByTheNamedClientProviderOnShutdown() { + String clientName = "clientName"; + FeatureProvider featureProvider = ProviderFixture.createMockedProvider(); + doThrow(TestException.class).when(featureProvider).shutdown(); + + setFeatureProvider(clientName, featureProvider); + setFeatureProvider(clientName, new NoOpProvider()); + + verify(featureProvider, timeout(1000)).shutdown(); + } + } + + @Nested + class General { + + @Specification(number = "1.6.1", text = "The API MUST define a shutdown function which, when called, must call the respective shutdown function on the active provider.") + @Test + @DisplayName("must shutdown all providers on shutting down api") + void mustShutdownAllProvidersOnShuttingDownApi() { + FeatureProvider defaultProvider = ProviderFixture.createMockedProvider(); + FeatureProvider namedProvider = ProviderFixture.createMockedProvider(); + setFeatureProvider(defaultProvider); + setFeatureProvider("clientName", namedProvider); + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + + synchronized (OpenFeatureAPI.class) { + api.shutdown(); + + Awaitility + .await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> { + verify(defaultProvider).shutdown(); + verify(namedProvider).shutdown(); + }); + + api.resetProviderRepository(); + } + } + } +} diff --git a/src/test/java/dev/openfeature/sdk/fixtures/ProviderFixture.java b/src/test/java/dev/openfeature/sdk/fixtures/ProviderFixture.java new file mode 100644 index 000000000..f0b786422 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/fixtures/ProviderFixture.java @@ -0,0 +1,43 @@ +package dev.openfeature.sdk.fixtures; + +import dev.openfeature.sdk.FeatureProvider; +import lombok.experimental.UtilityClass; +import org.mockito.stubbing.Answer; + +import java.util.concurrent.CountDownLatch; + +import static dev.openfeature.sdk.testutils.stubbing.ConditionStubber.doBlock; +import static org.mockito.Mockito.*; + +@UtilityClass +public class ProviderFixture { + + public static FeatureProvider createMockedProvider() { + return mock(FeatureProvider.class); + } + + public static FeatureProvider createBlockedProvider(CountDownLatch latch, Runnable onAnswer) { + FeatureProvider provider = createMockedProvider(); + doBlock(latch, createAnswerExecutingCode(onAnswer)).when(provider).initialize(); + doReturn("blockedProvider").when(provider).toString(); + return provider; + } + + private static Answer createAnswerExecutingCode(Runnable onAnswer) { + return invocation -> { + onAnswer.run(); + return null; + }; + } + + public static FeatureProvider createUnblockingProvider(CountDownLatch latch) { + FeatureProvider provider = createMockedProvider(); + doAnswer(invocation -> { + latch.countDown(); + return null; + }).when(provider).initialize(); + doReturn("unblockingProvider").when(provider).toString(); + return provider; + } + +} diff --git a/src/test/java/dev/openfeature/sdk/testutils/FeatureProviderTestUtils.java b/src/test/java/dev/openfeature/sdk/testutils/FeatureProviderTestUtils.java new file mode 100644 index 000000000..5f8c13dbb --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/FeatureProviderTestUtils.java @@ -0,0 +1,30 @@ +package dev.openfeature.sdk.testutils; + +import java.time.Duration; +import java.util.function.Function; + +import dev.openfeature.sdk.*; +import lombok.experimental.UtilityClass; + +import static org.awaitility.Awaitility.await; + +@UtilityClass +public class FeatureProviderTestUtils { + + public static void setFeatureProvider(FeatureProvider provider) { + OpenFeatureAPI.getInstance().setProvider(provider); + waitForProviderInitializationComplete(OpenFeatureAPI::getProvider, provider); + } + + private static void waitForProviderInitializationComplete(Function extractor, FeatureProvider provider) { + await() + .pollDelay(Duration.ofMillis(1)) + .atMost(Duration.ofSeconds(1)) + .until(() -> extractor.apply(OpenFeatureAPI.getInstance()) == provider); + } + + public static void setFeatureProvider(String namedProvider, FeatureProvider provider) { + OpenFeatureAPI.getInstance().setProvider(namedProvider, provider); + waitForProviderInitializationComplete(api -> api.getProvider(namedProvider), provider); + } +} diff --git a/src/test/java/dev/openfeature/sdk/testutils/exception/TestException.java b/src/test/java/dev/openfeature/sdk/testutils/exception/TestException.java new file mode 100644 index 000000000..c6918b02c --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/exception/TestException.java @@ -0,0 +1,9 @@ +package dev.openfeature.sdk.testutils.exception; + +public class TestException extends RuntimeException { + + @Override + public String getMessage() { + return "don't panic, it's just a test"; + } +} diff --git a/src/test/java/dev/openfeature/sdk/testutils/stubbing/ConditionStubber.java b/src/test/java/dev/openfeature/sdk/testutils/stubbing/ConditionStubber.java new file mode 100644 index 000000000..11cf26495 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/testutils/stubbing/ConditionStubber.java @@ -0,0 +1,37 @@ +package dev.openfeature.sdk.testutils.stubbing; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; + +import lombok.experimental.UtilityClass; +import org.mockito.stubbing.*; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.mockito.Mockito.doAnswer; + +@UtilityClass +public class ConditionStubber { + + @SuppressWarnings("java:S2925") + public static Stubber doDelayResponse(Duration duration) { + return doAnswer(invocation -> { + MILLISECONDS.sleep(duration.toMillis()); + return null; + }); + } + + public static Stubber doBlock(CountDownLatch latch) { + return doAnswer(invocation -> { + latch.await(); + return null; + }); + } + + public static Stubber doBlock(CountDownLatch latch, Answer answer) { + return doAnswer(invocation -> { + latch.await(); + return answer.answer(invocation); + }); + } + +}