diff --git a/.gitmodules b/.gitmodules index e439dea5d..619d4fff1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,7 +4,7 @@ [submodule "providers/flagd/test-harness"] path = providers/flagd/test-harness url = https://github.com/open-feature/test-harness.git - branch = v0.5.21 + branch = v1.1.1 [submodule "providers/flagd/spec"] path = providers/flagd/spec url = https://github.com/open-feature/spec.git diff --git a/pom.xml b/pom.xml index 7372429cc..6efd7c428 100644 --- a/pom.xml +++ b/pom.xml @@ -200,6 +200,13 @@ test + + io.cucumber + cucumber-picocontainer + 7.20.1 + test + + org.awaitility awaitility diff --git a/providers/flagd/pom.xml b/providers/flagd/pom.xml index 90f50d2ce..15a9d779f 100644 --- a/providers/flagd/pom.xml +++ b/providers/flagd/pom.xml @@ -149,7 +149,20 @@ 1.20.4 test + + org.testcontainers + toxiproxy + 1.20.4 + test + + + + org.slf4j + slf4j-simple + 2.0.16 + test + diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdProvider.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdProvider.java index 1e9c30882..bbf7674f1 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdProvider.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdProvider.java @@ -1,7 +1,8 @@ package dev.openfeature.contrib.providers.flagd; import dev.openfeature.contrib.providers.flagd.resolver.Resolver; -import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionEvent; +import dev.openfeature.contrib.providers.flagd.resolver.common.FlagdProviderEvent; +import dev.openfeature.contrib.providers.flagd.resolver.common.Util; import dev.openfeature.contrib.providers.flagd.resolver.grpc.GrpcResolver; import dev.openfeature.contrib.providers.flagd.resolver.grpc.cache.Cache; import dev.openfeature.contrib.providers.flagd.resolver.process.InProcessResolver; @@ -12,12 +13,17 @@ import dev.openfeature.sdk.ImmutableStructure; import dev.openfeature.sdk.Metadata; import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; import dev.openfeature.sdk.ProviderEventDetails; import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.Value; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import lombok.extern.slf4j.Slf4j; @@ -30,11 +36,31 @@ public class FlagdProvider extends EventProvider { private Function contextEnricher; private static final String FLAGD_PROVIDER = "flagd"; private final Resolver flagResolver; - private volatile boolean initialized = false; - private volatile boolean connected = false; - private volatile Structure syncMetadata = new ImmutableStructure(); - private volatile EvaluationContext enrichedContext = new ImmutableContext(); private final List hooks = new ArrayList<>(); + private final EventsLock eventsLock = new EventsLock(); + + /** + * An executor service responsible for emitting + * {@link ProviderEvent#PROVIDER_ERROR} after the provider went + * {@link ProviderEvent#PROVIDER_STALE} for {@link #gracePeriod} seconds. + */ + private final ScheduledExecutorService errorExecutor; + + /** + * A scheduled task for emitting {@link ProviderEvent#PROVIDER_ERROR}. + */ + private ScheduledFuture errorTask; + + /** + * The grace period in milliseconds to wait after + * {@link ProviderEvent#PROVIDER_STALE} before emitting a + * {@link ProviderEvent#PROVIDER_ERROR}. + */ + private final long gracePeriod; + /** + * The deadline in milliseconds for GRPC operations. + */ + private final long deadline; protected final void finalize() { // DO NOT REMOVE, spotbugs: CT_CONSTRUCTOR_THROW @@ -55,11 +81,11 @@ public FlagdProvider() { public FlagdProvider(final FlagdOptions options) { switch (options.getResolverType().asString()) { case Config.RESOLVER_IN_PROCESS: - this.flagResolver = new InProcessResolver(options, this::isConnected, this::onConnectionEvent); + this.flagResolver = new InProcessResolver(options, this::onProviderEvent); break; case Config.RESOLVER_RPC: this.flagResolver = new GrpcResolver( - options, new Cache(options.getCacheType(), options.getMaxCacheSize()), this::onConnectionEvent); + options, new Cache(options.getCacheType(), options.getMaxCacheSize()), this::onProviderEvent); break; default: throw new IllegalStateException( @@ -67,6 +93,22 @@ public FlagdProvider(final FlagdOptions options) { } hooks.add(new SyncMetadataHook(this::getEnrichedContext)); contextEnricher = options.getContextEnricher(); + errorExecutor = Executors.newSingleThreadScheduledExecutor(); + gracePeriod = options.getRetryGracePeriod(); + deadline = options.getDeadline(); + } + + /** + * Internal constructor for test cases. + * DO NOT MAKE PUBLIC + */ + FlagdProvider(Resolver resolver, boolean initialized) { + this.flagResolver = resolver; + deadline = Config.DEFAULT_DEADLINE; + gracePeriod = Config.DEFAULT_STREAM_RETRY_GRACE_PERIOD; + hooks.add(new SyncMetadataHook(this::getEnrichedContext)); + errorExecutor = Executors.newSingleThreadScheduledExecutor(); + this.eventsLock.initialized = initialized; } @Override @@ -75,27 +117,39 @@ public List getProviderHooks() { } @Override - public synchronized void initialize(EvaluationContext evaluationContext) throws Exception { - if (this.initialized) { - return; - } + public void initialize(EvaluationContext evaluationContext) throws Exception { + synchronized (eventsLock) { + if (eventsLock.initialized) { + return; + } - this.flagResolver.init(); - this.initialized = this.connected = true; + flagResolver.init(); + } + // block till ready - this works with deadline fine for rpc, but with in_process + // we also need to take parsing into the equation + // TODO: evaluate where we are losing time, so we can remove this magic number - + // follow up + // wait outside of the synchonrization or we'll deadlock + Util.busyWaitAndCheck(this.deadline * 2, () -> eventsLock.initialized); } @Override - public synchronized void shutdown() { - if (!this.initialized) { - return; - } - - try { - this.flagResolver.shutdown(); - } catch (Exception e) { - log.error("Error during shutdown {}", FLAGD_PROVIDER, e); - } finally { - this.initialized = false; + public void shutdown() { + synchronized (eventsLock) { + if (!eventsLock.initialized) { + return; + } + try { + this.flagResolver.shutdown(); + if (errorExecutor != null) { + errorExecutor.shutdownNow(); + errorExecutor.awaitTermination(deadline, TimeUnit.MILLISECONDS); + } + } catch (Exception e) { + log.error("Error during shutdown {}", FLAGD_PROVIDER, e); + } finally { + eventsLock.initialized = false; + } } } @@ -106,27 +160,27 @@ public Metadata getMetadata() { @Override public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { - return this.flagResolver.booleanEvaluation(key, defaultValue, ctx); + return flagResolver.booleanEvaluation(key, defaultValue, ctx); } @Override public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { - return this.flagResolver.stringEvaluation(key, defaultValue, ctx); + return flagResolver.stringEvaluation(key, defaultValue, ctx); } @Override public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { - return this.flagResolver.doubleEvaluation(key, defaultValue, ctx); + return flagResolver.doubleEvaluation(key, defaultValue, ctx); } @Override public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { - return this.flagResolver.integerEvaluation(key, defaultValue, ctx); + return flagResolver.integerEvaluation(key, defaultValue, ctx); } @Override public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { - return this.flagResolver.objectEvaluation(key, defaultValue, ctx); + return flagResolver.objectEvaluation(key, defaultValue, ctx); } /** @@ -139,7 +193,7 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa * @return Object map representing sync metadata */ protected Structure getSyncMetadata() { - return new ImmutableStructure(syncMetadata.asMap()); + return new ImmutableStructure(eventsLock.syncMetadata.asMap()); } /** @@ -148,50 +202,109 @@ protected Structure getSyncMetadata() { * @return context */ EvaluationContext getEnrichedContext() { - return enrichedContext; + return eventsLock.enrichedContext; } - private boolean isConnected() { - return this.connected; - } + @SuppressWarnings("checkstyle:fallthrough") + private void onProviderEvent(FlagdProviderEvent flagdProviderEvent) { - private void onConnectionEvent(ConnectionEvent connectionEvent) { - final boolean wasConnected = connected; - final boolean isConnected = connected = connectionEvent.isConnected(); + synchronized (eventsLock) { + log.info("FlagdProviderEvent: {}", flagdProviderEvent); + eventsLock.syncMetadata = flagdProviderEvent.getSyncMetadata(); + if (flagdProviderEvent.getSyncMetadata() != null) { + eventsLock.enrichedContext = contextEnricher.apply(flagdProviderEvent.getSyncMetadata()); + } - syncMetadata = connectionEvent.getSyncMetadata(); - enrichedContext = contextEnricher.apply(connectionEvent.getSyncMetadata()); + /* + * We only use Error and Ready as previous states. + * As error will first be emitted as Stale, and only turns after a while into an + * emitted Error. + * Ready is needed, as the InProcessResolver does not have a dedicated ready + * event, hence we need to + * forward a configuration changed to the ready, if we are not in the ready + * state. + */ + switch (flagdProviderEvent.getEvent()) { + case PROVIDER_CONFIGURATION_CHANGED: + if (eventsLock.previousEvent == ProviderEvent.PROVIDER_READY) { + onConfigurationChanged(flagdProviderEvent); + break; + } + // intentional fall through, a not-ready change will trigger a ready. + case PROVIDER_READY: + onReady(); + eventsLock.previousEvent = ProviderEvent.PROVIDER_READY; + break; - if (!initialized) { - return; + case PROVIDER_ERROR: + if (eventsLock.previousEvent != ProviderEvent.PROVIDER_ERROR) { + onError(); + } + eventsLock.previousEvent = ProviderEvent.PROVIDER_ERROR; + break; + default: + log.info("Unknown event {}", flagdProviderEvent.getEvent()); + } } + } + + private void onConfigurationChanged(FlagdProviderEvent flagdProviderEvent) { + this.emitProviderConfigurationChanged(ProviderEventDetails.builder() + .flagsChanged(flagdProviderEvent.getFlagsChanged()) + .message("configuration changed") + .build()); + } - if (!wasConnected && isConnected) { - ProviderEventDetails details = ProviderEventDetails.builder() - .flagsChanged(connectionEvent.getFlagsChanged()) - .message("connected to flagd") - .build(); - this.emitProviderReady(details); - return; + private void onReady() { + if (!eventsLock.initialized) { + eventsLock.initialized = true; + log.info("initialized FlagdProvider"); } + if (errorTask != null && !errorTask.isCancelled()) { + errorTask.cancel(false); + log.debug("Reconnection task cancelled as connection became READY."); + } + this.emitProviderReady( + ProviderEventDetails.builder().message("connected to flagd").build()); + } - if (wasConnected && isConnected) { - ProviderEventDetails details = ProviderEventDetails.builder() - .flagsChanged(connectionEvent.getFlagsChanged()) - .message("configuration changed") - .build(); - this.emitProviderConfigurationChanged(details); - return; + private void onError() { + log.info("Connection lost. Emit STALE event..."); + log.debug("Waiting {}s for connection to become available...", gracePeriod); + this.emitProviderStale(ProviderEventDetails.builder() + .message("there has been an error") + .build()); + + if (errorTask != null && !errorTask.isCancelled()) { + errorTask.cancel(false); } - if (connectionEvent.isStale()) { - this.emitProviderStale(ProviderEventDetails.builder() - .message("there has been an error") - .build()); - } else { - this.emitProviderError(ProviderEventDetails.builder() - .message("there has been an error") - .build()); + if (!errorExecutor.isShutdown()) { + errorTask = errorExecutor.schedule( + () -> { + if (eventsLock.previousEvent == ProviderEvent.PROVIDER_ERROR) { + log.debug( + "Provider did not reconnect successfully within {}s. Emit ERROR event...", + gracePeriod); + flagResolver.onError(); + this.emitProviderError(ProviderEventDetails.builder() + .message("there has been an error") + .build()); + } + }, + gracePeriod, + TimeUnit.SECONDS); } } + + /** + * Contains all fields we need to worry about locking, used as intrinsic lock + * for sync blocks. + */ + static class EventsLock { + volatile ProviderEvent previousEvent = null; + volatile Structure syncMetadata = new ImmutableStructure(); + volatile boolean initialized = false; + volatile EvaluationContext enrichedContext = new ImmutableContext(); + } } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/Resolver.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/Resolver.java index 1f9106501..c86a175fe 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/Resolver.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/Resolver.java @@ -10,6 +10,8 @@ public interface Resolver { void shutdown() throws Exception; + default void onError() {} + ProviderEvaluation booleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx); ProviderEvaluation stringEvaluation(String key, String defaultValue, EvaluationContext ctx); diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ChannelMonitor.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ChannelMonitor.java index 8ccb73c15..1b201d640 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ChannelMonitor.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ChannelMonitor.java @@ -32,7 +32,7 @@ public static void monitorChannelState( Runnable onConnectionLost) { channel.notifyWhenStateChanged(expectedState, () -> { ConnectivityState currentState = channel.getState(true); - log.info("Channel state changed to: {}", currentState); + log.debug("Channel state changed to: {}", currentState); if (currentState == ConnectivityState.READY) { if (onConnectionReady != null) { onConnectionReady.run(); diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ConnectionState.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ConnectionState.java deleted file mode 100644 index 6dbd388a0..000000000 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ConnectionState.java +++ /dev/null @@ -1,27 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.common; - -/** - * Represents the possible states of a connection. - */ -public enum ConnectionState { - - /** - * The connection is active and functioning as expected. - */ - CONNECTED, - - /** - * The connection is not active and has been fully disconnected. - */ - DISCONNECTED, - - /** - * The connection is inactive or degraded but may still recover. - */ - STALE, - - /** - * The connection has encountered an error and cannot function correctly. - */ - ERROR, -} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ConnectionEvent.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/FlagdProviderEvent.java similarity index 56% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ConnectionEvent.java rename to providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/FlagdProviderEvent.java index 0e8ff4c6b..517a23218 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ConnectionEvent.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/FlagdProviderEvent.java @@ -1,9 +1,11 @@ package dev.openfeature.contrib.providers.flagd.resolver.common; import dev.openfeature.sdk.ImmutableStructure; +import dev.openfeature.sdk.ProviderEvent; import dev.openfeature.sdk.Structure; import java.util.Collections; import java.util.List; +import lombok.Getter; /** * Represents an event payload for a connection state change in a @@ -11,12 +13,13 @@ * The event includes information about the connection status, any flags that have changed, * and metadata associated with the synchronization process. */ -public class ConnectionEvent { +public class FlagdProviderEvent { /** * The current state of the connection. */ - private final ConnectionState connected; + @Getter + private final ProviderEvent event; /** * A list of flags that have changed due to this connection event. @@ -28,57 +31,45 @@ public class ConnectionEvent { */ private final Structure syncMetadata; - /** - * Constructs a new {@code ConnectionEvent} with the connection status only. - * - * @param connected {@code true} if the connection is established, otherwise {@code false}. - */ - public ConnectionEvent(boolean connected) { - this( - connected ? ConnectionState.CONNECTED : ConnectionState.DISCONNECTED, - Collections.emptyList(), - new ImmutableStructure()); - } - /** * Constructs a new {@code ConnectionEvent} with the specified connection state. * - * @param connected the connection state indicating if the connection is established or not. + * @param event the event indicating the provider state. */ - public ConnectionEvent(ConnectionState connected) { - this(connected, Collections.emptyList(), new ImmutableStructure()); + public FlagdProviderEvent(ProviderEvent event) { + this(event, Collections.emptyList(), new ImmutableStructure()); } /** * Constructs a new {@code ConnectionEvent} with the specified connection state and changed flags. * - * @param connected the connection state indicating if the connection is established or not. + * @param event the event indicating the provider state. * @param flagsChanged a list of flags that have changed due to this connection event. */ - public ConnectionEvent(ConnectionState connected, List flagsChanged) { - this(connected, flagsChanged, new ImmutableStructure()); + public FlagdProviderEvent(ProviderEvent event, List flagsChanged) { + this(event, flagsChanged, new ImmutableStructure()); } /** * Constructs a new {@code ConnectionEvent} with the specified connection state and synchronization metadata. * - * @param connected the connection state indicating if the connection is established or not. + * @param event the event indicating the provider state. * @param syncMetadata metadata related to the synchronization process of this event. */ - public ConnectionEvent(ConnectionState connected, Structure syncMetadata) { - this(connected, Collections.emptyList(), new ImmutableStructure(syncMetadata.asMap())); + public FlagdProviderEvent(ProviderEvent event, Structure syncMetadata) { + this(event, Collections.emptyList(), new ImmutableStructure(syncMetadata.asMap())); } /** * Constructs a new {@code ConnectionEvent} with the specified connection state, changed flags, and * synchronization metadata. * - * @param connectionState the state of the connection. + * @param event the event. * @param flagsChanged a list of flags that have changed due to this connection event. * @param syncMetadata metadata related to the synchronization process of this event. */ - public ConnectionEvent(ConnectionState connectionState, List flagsChanged, Structure syncMetadata) { - this.connected = connectionState; + public FlagdProviderEvent(ProviderEvent event, List flagsChanged, Structure syncMetadata) { + this.event = event; this.flagsChanged = flagsChanged != null ? flagsChanged : Collections.emptyList(); // Ensure non-null list this.syncMetadata = syncMetadata != null ? new ImmutableStructure(syncMetadata.asMap()) @@ -103,22 +94,7 @@ public Structure getSyncMetadata() { return new ImmutableStructure(syncMetadata.asMap()); } - /** - * Indicates whether the current connection state is connected. - * - * @return {@code true} if connected, otherwise {@code false}. - */ - public boolean isConnected() { - return this.connected == ConnectionState.CONNECTED; - } - - /** - * Indicates - * whether the current connection state is stale. - * - * @return {@code true} if stale, otherwise {@code false}. - */ - public boolean isStale() { - return this.connected == ConnectionState.STALE; + public boolean isDisconnected() { + return event == ProviderEvent.PROVIDER_ERROR || event == ProviderEvent.PROVIDER_STALE; } } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/GrpcConnector.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/GrpcConnector.java index d5ca69aff..ae83227f2 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/GrpcConnector.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/GrpcConnector.java @@ -2,14 +2,12 @@ import dev.openfeature.contrib.providers.flagd.FlagdOptions; import dev.openfeature.sdk.ImmutableStructure; +import dev.openfeature.sdk.ProviderEvent; import io.grpc.ConnectivityState; import io.grpc.ManagedChannel; import io.grpc.stub.AbstractBlockingStub; import io.grpc.stub.AbstractStub; import java.util.Collections; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Function; @@ -54,34 +52,19 @@ public class GrpcConnector, K extends AbstractBlocking /** * A consumer that handles connection events such as connection loss or reconnection. */ - private final Consumer onConnectionEvent; + private final Consumer onConnectionEvent; /** * A consumer that handles GRPC service stubs for event stream handling. */ private final Consumer streamObserver; - /** - * An executor service responsible for scheduling reconnection attempts. - */ - private final ScheduledExecutorService reconnectExecutor; - - /** - * The grace period in milliseconds to wait for reconnection before emitting an error event. - */ - private final long gracePeriod; - /** * Indicates whether the connector is currently connected to the GRPC service. */ @Getter private boolean connected = false; - /** - * A scheduled task for managing reconnection attempts. - */ - private ScheduledFuture reconnectTask; - /** * Constructs a new {@code GrpcConnector} instance with the specified options and parameters. * @@ -96,19 +79,17 @@ public GrpcConnector( final FlagdOptions options, final Function stub, final Function blockingStub, - final Consumer onConnectionEvent, + final Consumer onConnectionEvent, final Consumer eventStreamObserver, ManagedChannel channel) { this.channel = channel; - this.serviceStub = stub.apply(channel); - this.blockingStub = blockingStub.apply(channel); + this.serviceStub = stub.apply(channel).withWaitForReady(); + this.blockingStub = blockingStub.apply(channel).withWaitForReady(); this.deadline = options.getDeadline(); this.streamDeadlineMs = options.getStreamDeadlineMs(); this.onConnectionEvent = onConnectionEvent; this.streamObserver = eventStreamObserver; - this.gracePeriod = options.getRetryGracePeriod(); - this.reconnectExecutor = Executors.newSingleThreadScheduledExecutor(); } /** @@ -124,7 +105,7 @@ public GrpcConnector( final FlagdOptions options, final Function stub, final Function blockingStub, - final Consumer onConnectionEvent, + final Consumer onConnectionEvent, final Consumer eventStreamObserver) { this(options, stub, blockingStub, onConnectionEvent, eventStreamObserver, ChannelBuilder.nettyChannel(options)); } @@ -136,8 +117,6 @@ public GrpcConnector( */ public void initialize() throws Exception { log.info("Initializing GRPC connection..."); - ChannelMonitor.waitForDesiredState( - ConnectivityState.READY, channel, this::onInitialConnect, deadline, TimeUnit.MILLISECONDS); ChannelMonitor.monitorChannelState(ConnectivityState.READY, channel, this::onReady, this::onConnectionLost); } @@ -157,25 +136,11 @@ public K getResolver() { */ public void shutdown() throws InterruptedException { log.info("Shutting down GRPC connection..."); - if (reconnectExecutor != null) { - reconnectExecutor.shutdownNow(); - reconnectExecutor.awaitTermination(deadline, TimeUnit.MILLISECONDS); - } if (!channel.isShutdown()) { channel.shutdownNow(); channel.awaitTermination(deadline, TimeUnit.MILLISECONDS); } - - if (connected) { - this.onConnectionEvent.accept(new ConnectionEvent(false)); - connected = false; - } - } - - private synchronized void onInitialConnect() { - connected = true; - restartStream(); } /** @@ -184,13 +149,7 @@ private synchronized void onInitialConnect() { */ private synchronized void onReady() { connected = true; - - if (reconnectTask != null && !reconnectTask.isCancelled()) { - reconnectTask.cancel(false); - log.debug("Reconnection task cancelled as connection became READY."); - } restartStream(); - this.onConnectionEvent.accept(new ConnectionEvent(true)); } /** @@ -198,27 +157,10 @@ private synchronized void onReady() { * Schedules a reconnection task after a grace period and emits a stale connection event. */ private synchronized void onConnectionLost() { - log.debug("Connection lost. Emit STALE event..."); - log.debug("Waiting {}s for connection to become available...", gracePeriod); connected = false; - this.onConnectionEvent.accept( - new ConnectionEvent(ConnectionState.STALE, Collections.emptyList(), new ImmutableStructure())); - - if (reconnectTask != null && !reconnectTask.isCancelled()) { - reconnectTask.cancel(false); - } - - if (!reconnectExecutor.isShutdown()) { - reconnectTask = reconnectExecutor.schedule( - () -> { - log.debug( - "Provider did not reconnect successfully within {}s. Emit ERROR event...", gracePeriod); - this.onConnectionEvent.accept(new ConnectionEvent(false)); - }, - gracePeriod, - TimeUnit.SECONDS); - } + this.onConnectionEvent.accept(new FlagdProviderEvent( + ProviderEvent.PROVIDER_ERROR, Collections.emptyList(), new ImmutableStructure())); } /** diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserver.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserver.java index db89931e5..8b8886bf8 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserver.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserver.java @@ -1,15 +1,15 @@ package dev.openfeature.contrib.providers.flagd.resolver.grpc; import com.google.protobuf.Value; -import dev.openfeature.contrib.providers.flagd.resolver.grpc.cache.Cache; +import dev.openfeature.contrib.providers.flagd.resolver.common.FlagdProviderEvent; import dev.openfeature.flagd.grpc.evaluation.Evaluation.EventStreamResponse; +import dev.openfeature.sdk.ProviderEvent; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import io.grpc.stub.StreamObserver; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.function.BiConsumer; +import java.util.function.Consumer; import lombok.extern.slf4j.Slf4j; /** @@ -20,25 +20,17 @@ @SuppressFBWarnings(justification = "cache needs to be read and write by multiple objects") class EventStreamObserver implements StreamObserver { - /** - * A consumer to handle connection events with a flag indicating success and a list of changed flags. - */ - private final BiConsumer> onConnectionEvent; - - /** - * The cache to update based on received events. - */ - private final Cache cache; + private final Consumer> onConfigurationChange; + private final Consumer onReady; /** * Constructs a new {@code EventStreamObserver} instance. * - * @param cache the cache to update based on received events * @param onConnectionEvent a consumer to handle connection events with a boolean and a list of changed flags */ - EventStreamObserver(Cache cache, BiConsumer> onConnectionEvent) { - this.cache = cache; - this.onConnectionEvent = onConnectionEvent; + EventStreamObserver(Consumer> onConfigurationChange, Consumer onReady) { + this.onConfigurationChange = onConfigurationChange; + this.onReady = onReady; } /** @@ -60,28 +52,11 @@ public void onNext(EventStreamResponse value) { } } - /** - * Called when an error occurs in the stream. - * - * @param throwable the error that occurred - */ @Override - public void onError(Throwable throwable) { - if (this.cache.getEnabled().equals(Boolean.TRUE)) { - this.cache.clear(); - } - } + public void onError(Throwable throwable) {} - /** - * Called when the stream is completed. - */ @Override - public void onCompleted() { - if (this.cache.getEnabled().equals(Boolean.TRUE)) { - this.cache.clear(); - } - this.onConnectionEvent.accept(false, Collections.emptyList()); - } + public void onCompleted() {} /** * Handles configuration change events by updating the cache and notifying listeners about changed flags. @@ -90,33 +65,22 @@ public void onCompleted() { */ private void handleConfigurationChangeEvent(EventStreamResponse value) { List changedFlags = new ArrayList<>(); - boolean cachingEnabled = this.cache.getEnabled(); Map data = value.getData().getFieldsMap(); Value flagsValue = data.get(Constants.FLAGS_KEY); - if (flagsValue == null) { - if (cachingEnabled) { - this.cache.clear(); - } - } else { + if (flagsValue != null) { Map flags = flagsValue.getStructValue().getFieldsMap(); - for (String flagKey : flags.keySet()) { - changedFlags.add(flagKey); - if (cachingEnabled) { - this.cache.remove(flagKey); - } - } + changedFlags.addAll(flags.keySet()); } - this.onConnectionEvent.accept(true, changedFlags); + onConfigurationChange.accept(changedFlags); } /** * Handles provider readiness events by clearing the cache (if enabled) and notifying listeners of readiness. */ private void handleProviderReadyEvent() { - if (this.cache.getEnabled().equals(Boolean.TRUE)) { - this.cache.clear(); - } + log.info("Received provider ready event"); + onReady.accept(new FlagdProviderEvent(ProviderEvent.PROVIDER_READY)); } } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcResolver.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcResolver.java index 5c8ad3ea1..2a9669ec3 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcResolver.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcResolver.java @@ -10,8 +10,7 @@ import dev.openfeature.contrib.providers.flagd.Config; import dev.openfeature.contrib.providers.flagd.FlagdOptions; import dev.openfeature.contrib.providers.flagd.resolver.Resolver; -import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionEvent; -import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionState; +import dev.openfeature.contrib.providers.flagd.resolver.common.FlagdProviderEvent; import dev.openfeature.contrib.providers.flagd.resolver.common.GrpcConnector; import dev.openfeature.contrib.providers.flagd.resolver.grpc.cache.Cache; import dev.openfeature.contrib.providers.flagd.resolver.grpc.strategy.ResolveFactory; @@ -26,6 +25,7 @@ import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.FlagNotFoundError; import dev.openfeature.sdk.exceptions.GeneralError; @@ -57,23 +57,28 @@ public final class GrpcResolver implements Resolver { * * @param options flagd options * @param cache cache to use - * @param onConnectionEvent lambda which handles changes in the connection/stream + * @param onProviderEvent lambda which handles changes in the connection/stream */ public GrpcResolver( - final FlagdOptions options, final Cache cache, final Consumer onConnectionEvent) { + final FlagdOptions options, final Cache cache, final Consumer onProviderEvent) { this.cache = cache; this.strategy = ResolveFactory.getStrategy(options); this.connector = new GrpcConnector<>( options, ServiceGrpc::newStub, ServiceGrpc::newBlockingStub, - onConnectionEvent, + onProviderEvent, stub -> stub.eventStream( Evaluation.EventStreamRequest.getDefaultInstance(), new EventStreamObserver( - cache, - (k, e) -> - onConnectionEvent.accept(new ConnectionEvent(ConnectionState.CONNECTED, e))))); + (flags) -> { + if (cache != null) { + flags.forEach(cache::remove); + } + onProviderEvent.accept(new FlagdProviderEvent( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, flags)); + }, + onProviderEvent))); } /** @@ -90,6 +95,13 @@ public void shutdown() throws Exception { this.connector.shutdown(); } + @Override + public void onError() { + if (cache != null) { + cache.clear(); + } + } + /** * Boolean evaluation from grpc resolver. */ @@ -207,7 +219,7 @@ private Boolean isEvaluationCacheable(ProviderEvaluation evaluation) { } private Boolean cacheAvailable() { - return this.cache.getEnabled() && this.connector.isConnected(); + return this.cache.getEnabled(); } private static ImmutableMetadata metadataFromResponse(Message response) { diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java index 95f2eb6d6..b3385781e 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java @@ -4,14 +4,11 @@ import dev.openfeature.contrib.providers.flagd.FlagdOptions; import dev.openfeature.contrib.providers.flagd.resolver.Resolver; -import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionEvent; -import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionState; -import dev.openfeature.contrib.providers.flagd.resolver.common.Util; +import dev.openfeature.contrib.providers.flagd.resolver.common.FlagdProviderEvent; import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.FlagStore; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.Storage; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageQueryResult; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageState; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageStateChange; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.Connector; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.file.FileConnector; @@ -22,13 +19,13 @@ import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.ParseError; import dev.openfeature.sdk.exceptions.TypeMismatchError; import java.util.Map; import java.util.function.Consumer; -import java.util.function.Supplier; import lombok.extern.slf4j.Slf4j; /** @@ -39,10 +36,9 @@ @Slf4j public class InProcessResolver implements Resolver { private final Storage flagStore; - private final Consumer onConnectionEvent; + private final Consumer onConnectionEvent; private final Operator operator; private final long deadline; - private final Supplier connectedSupplier; private final String scope; /** @@ -51,20 +47,14 @@ public class InProcessResolver implements Resolver { * Flags are evaluated locally. * * @param options flagd options - * @param connectedSupplier lambda providing current connection status from - * caller * @param onConnectionEvent lambda which handles changes in the * connection/stream */ - public InProcessResolver( - FlagdOptions options, - final Supplier connectedSupplier, - Consumer onConnectionEvent) { + public InProcessResolver(FlagdOptions options, Consumer onConnectionEvent) { this.flagStore = new FlagStore(getConnector(options, onConnectionEvent)); this.deadline = options.getDeadline(); this.onConnectionEvent = onConnectionEvent; this.operator = new Operator(); - this.connectedSupplier = connectedSupplier; this.scope = options.getSelector(); } @@ -78,15 +68,20 @@ public void init() throws Exception { while (true) { final StorageStateChange storageStateChange = flagStore.getStateQueue().take(); - if (storageStateChange.getStorageState() != StorageState.OK) { - log.info( - String.format("Storage returned NOK status: %s", storageStateChange.getStorageState())); - continue; + switch (storageStateChange.getStorageState()) { + case OK: + onConnectionEvent.accept(new FlagdProviderEvent( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, + storageStateChange.getChangedFlagsKeys(), + storageStateChange.getSyncMetadata())); + break; + case ERROR: + onConnectionEvent.accept(new FlagdProviderEvent(ProviderEvent.PROVIDER_ERROR)); + break; + default: + log.info(String.format( + "Storage emitted unhandled status: %s", storageStateChange.getStorageState())); } - onConnectionEvent.accept(new ConnectionEvent( - ConnectionState.CONNECTED, - storageStateChange.getChangedFlagsKeys(), - storageStateChange.getSyncMetadata())); } } catch (InterruptedException e) { log.warn("Storage state watcher interrupted", e); @@ -95,9 +90,6 @@ public void init() throws Exception { }); stateWatcher.setDaemon(true); stateWatcher.start(); - - // block till ready - Util.busyWaitAndCheck(this.deadline, this.connectedSupplier); } /** @@ -107,7 +99,6 @@ public void init() throws Exception { */ public void shutdown() throws InterruptedException { flagStore.shutdown(); - onConnectionEvent.accept(new ConnectionEvent(false)); } /** @@ -154,7 +145,7 @@ public ProviderEvaluation objectEvaluation(String key, Value defaultValue .build(); } - static Connector getConnector(final FlagdOptions options, Consumer onConnectionEvent) { + static Connector getConnector(final FlagdOptions options, Consumer onConnectionEvent) { if (options.getCustomConnector() != null) { return options.getCustomConnector(); } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/grpc/GrpcStreamConnector.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/grpc/GrpcStreamConnector.java index e48a65211..832c8a487 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/grpc/GrpcStreamConnector.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/grpc/GrpcStreamConnector.java @@ -1,7 +1,7 @@ package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.grpc; import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionEvent; +import dev.openfeature.contrib.providers.flagd.resolver.common.FlagdProviderEvent; import dev.openfeature.contrib.providers.flagd.resolver.common.GrpcConnector; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.Connector; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; @@ -16,7 +16,6 @@ import io.grpc.Context.CancellableContext; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import lombok.extern.slf4j.Slf4j; @@ -43,7 +42,7 @@ public class GrpcStreamConnector implements Connector { /** * Creates a new GrpcStreamConnector responsible for observing the event stream. */ - public GrpcStreamConnector(final FlagdOptions options, Consumer onConnectionEvent) { + public GrpcStreamConnector(final FlagdOptions options, Consumer onConnectionEvent) { deadline = options.getDeadline(); selector = options.getSelector(); streamReceiver = new LinkedBlockingQueue<>(QUEUE_SIZE); @@ -60,7 +59,7 @@ public void init() throws Exception { grpcConnector.initialize(); Thread listener = new Thread(() -> { try { - observeEventStream(blockingQueue, shutdown, selector, deadline); + observeEventStream(blockingQueue, shutdown, deadline); } catch (InterruptedException e) { log.warn("gRPC event stream interrupted, flag configurations are stale", e); Thread.currentThread().interrupt(); @@ -89,11 +88,7 @@ public void shutdown() throws InterruptedException { } /** Contains blocking calls, to be used concurrently. */ - void observeEventStream( - final BlockingQueue writeTo, - final AtomicBoolean shutdown, - final String selector, - final int deadline) + void observeEventStream(final BlockingQueue writeTo, final AtomicBoolean shutdown, final int deadline) throws InterruptedException { log.info("Initializing sync stream observer"); @@ -114,10 +109,7 @@ void observeEventStream( try (CancellableContext context = Context.current().withCancellation()) { try { - metadataResponse = grpcConnector - .getResolver() - .withDeadlineAfter(deadline, TimeUnit.MILLISECONDS) - .getMetadata(metadataRequest.build()); + metadataResponse = grpcConnector.getResolver().getMetadata(metadataRequest.build()); } catch (Exception e) { // the chances this call fails but the syncRequest does not are slim // it could be that the server doesn't implement this RPC @@ -126,6 +118,7 @@ void observeEventStream( metadataException = e; } + log.info("stream"); while (!shutdown.get()) { final GrpcResponseModel response = streamReceiver.take(); if (response.isComplete()) { @@ -137,6 +130,7 @@ void observeEventStream( Throwable streamException = response.getError(); if (streamException != null || metadataException != null) { + log.debug("Exception in GRPC connection"); if (!writeTo.offer(new QueuePayload( QueuePayloadType.ERROR, "Error from stream or metadata", metadataResponse))) { log.error("Failed to convey ERROR status, queue is full"); diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderTest.java index c223cbc31..0aa226fcf 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderTest.java @@ -17,8 +17,7 @@ import com.google.protobuf.Struct; import dev.openfeature.contrib.providers.flagd.resolver.Resolver; -import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionEvent; -import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionState; +import dev.openfeature.contrib.providers.flagd.resolver.common.FlagdProviderEvent; import dev.openfeature.contrib.providers.flagd.resolver.common.GrpcConnector; import dev.openfeature.contrib.providers.flagd.resolver.grpc.GrpcResolver; import dev.openfeature.contrib.providers.flagd.resolver.grpc.cache.Cache; @@ -43,11 +42,13 @@ import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.MutableStructure; import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderEvent; import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.Value; import io.cucumber.java.AfterAll; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -318,11 +319,6 @@ void resolvers_should_not_cache_responses_if_not_static() { do_resolvers_cache_responses(DEFAULT.toString(), true, false); } - @Test - void resolvers_should_not_cache_responses_if_event_stream_not_alive() { - do_resolvers_cache_responses(STATIC_REASON, false, false); - } - @Test void context_is_parsed_and_passed_to_grpc_service() { final String BOOLEAN_ATTR_KEY = "bool-attr"; @@ -570,6 +566,16 @@ void initializationAndShutdown() throws Exception { flagResolver.setAccessible(true); flagResolver.set(provider, resolverMock); + Method onProviderEvent = FlagdProvider.class.getDeclaredMethod("onProviderEvent", FlagdProviderEvent.class); + onProviderEvent.setAccessible(true); + + doAnswer((i) -> { + onProviderEvent.invoke(provider, new FlagdProviderEvent(ProviderEvent.PROVIDER_READY)); + return null; + }) + .when(resolverMock) + .init(); + // when // validate multiple initialization @@ -599,16 +605,17 @@ void contextEnrichment() throws Exception { // mock a resolver try (MockedConstruction mockResolver = mockConstruction(InProcessResolver.class, (mock, context) -> { - Consumer onConnectionEvent; + Consumer onConnectionEvent; // get a reference to the onConnectionEvent callback onConnectionEvent = - (Consumer) context.arguments().get(2); + (Consumer) context.arguments().get(1); // when our mock resolver initializes, it runs the passed onConnectionEvent // callback doAnswer(invocation -> { - onConnectionEvent.accept(new ConnectionEvent(ConnectionState.CONNECTED, metadata)); + onConnectionEvent.accept( + new FlagdProviderEvent(ProviderEvent.PROVIDER_READY, metadata)); return null; }) .when(mock) @@ -639,16 +646,17 @@ void updatesSyncMetadataWithCallback() throws Exception { // mock a resolver try (MockedConstruction mockResolver = mockConstruction(InProcessResolver.class, (mock, context) -> { - Consumer onConnectionEvent; + Consumer onConnectionEvent; // get a reference to the onConnectionEvent callback onConnectionEvent = - (Consumer) context.arguments().get(2); + (Consumer) context.arguments().get(1); // when our mock resolver initializes, it runs the passed onConnectionEvent // callback doAnswer(invocation -> { - onConnectionEvent.accept(new ConnectionEvent(ConnectionState.CONNECTED, metadata)); + onConnectionEvent.accept( + new FlagdProviderEvent(ProviderEvent.PROVIDER_READY, metadata)); return null; }) .when(mock) @@ -692,20 +700,14 @@ private FlagdProvider createProvider(GrpcConnector grpc, Cache cache) { final FlagdOptions flagdOptions = FlagdOptions.builder().build(); final GrpcResolver grpcResolver = new GrpcResolver(flagdOptions, cache, (connectionEvent) -> {}); - final FlagdProvider provider = new FlagdProvider(); - try { Field connector = GrpcResolver.class.getDeclaredField("connector"); connector.setAccessible(true); connector.set(grpcResolver, grpc); - - Field flagResolver = FlagdProvider.class.getDeclaredField("flagResolver"); - flagResolver.setAccessible(true); - flagResolver.set(provider, grpcResolver); } catch (NoSuchFieldException | IllegalAccessException e) { throw new RuntimeException(e); } - + final FlagdProvider provider = new FlagdProvider(grpcResolver, true); return provider; } diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ContainerConfig.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ContainerConfig.java deleted file mode 100644 index e5231b1bc..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ContainerConfig.java +++ /dev/null @@ -1,100 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import java.io.File; -import java.nio.file.Files; -import java.util.List; -import org.apache.logging.log4j.util.Strings; -import org.jetbrains.annotations.NotNull; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.Network; -import org.testcontainers.utility.DockerImageName; -import org.testcontainers.utility.MountableFile; - -public class ContainerConfig { - private static final String version; - private static final Network network = Network.newNetwork(); - - static { - String path = "test-harness/version.txt"; - File file = new File(path); - try { - List lines = Files.readAllLines(file.toPath()); - version = lines.get(0); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - /** - * @return a {@link org.testcontainers.containers.GenericContainer} instance of a stable sync - * flagd server with the port 9090 exposed - */ - public static GenericContainer sync() { - return sync(false, false); - } - - /** - * @param unstable if an unstable version of the container, which terminates the connection - * regularly should be used. - * @param addNetwork if set to true a custom network is attached for cross container access e.g. - * envoy --> sync:8015 - * @return a {@link org.testcontainers.containers.GenericContainer} instance of a sync flagd - * server with the port 8015 exposed - */ - public static GenericContainer sync(boolean unstable, boolean addNetwork) { - String container = generateContainerName("flagd", unstable ? "unstable" : ""); - GenericContainer genericContainer = - new GenericContainer(DockerImageName.parse(container)).withExposedPorts(8015); - - if (addNetwork) { - genericContainer.withNetwork(network); - genericContainer.withNetworkAliases("sync-service"); - } - - return genericContainer; - } - - /** - * @return a {@link org.testcontainers.containers.GenericContainer} instance of a stable flagd - * server with the port 8013 exposed - */ - public static GenericContainer flagd() { - return flagd(false); - } - - /** - * @param unstable if an unstable version of the container, which terminates the connection - * regularly should be used. - * @return a {@link org.testcontainers.containers.GenericContainer} instance of a flagd server - * with the port 8013 exposed - */ - public static GenericContainer flagd(boolean unstable) { - String container = generateContainerName("flagd", unstable ? "unstable" : ""); - return new GenericContainer(DockerImageName.parse(container)).withExposedPorts(8013); - } - - /** - * @return a {@link org.testcontainers.containers.GenericContainer} instance of envoy container - * using flagd sync service as backend expose on port 9211 - */ - public static GenericContainer envoy() { - final String container = "envoyproxy/envoy:v1.31.0"; - return new GenericContainer(DockerImageName.parse(container)) - .withCopyFileToContainer( - MountableFile.forClasspathResource("/envoy-config/envoy-custom.yaml"), "/etc/envoy/envoy.yaml") - .withExposedPorts(9211) - .withNetwork(network) - .withNetworkAliases("envoy"); - } - - public static @NotNull String generateContainerName(String type, String addition) { - String container = "ghcr.io/open-feature/"; - container += type; - container += "-testbed"; - if (!Strings.isBlank(addition)) { - container += "-" + addition; - } - container += ":v" + version; - return container; - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdContainer.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdContainer.java new file mode 100644 index 000000000..0aa8f50d0 --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdContainer.java @@ -0,0 +1,63 @@ +package dev.openfeature.contrib.providers.flagd.e2e; + +import java.io.File; +import java.nio.file.Files; +import java.util.List; +import org.apache.logging.log4j.util.Strings; +import org.jetbrains.annotations.NotNull; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +public class FlagdContainer extends GenericContainer { + private static final String version; + private static final Network network = Network.newNetwork(); + + static { + String path = "test-harness/version.txt"; + File file = new File(path); + try { + List lines = Files.readAllLines(file.toPath()); + version = lines.get(0); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private String feature; + + public FlagdContainer() { + this(""); + } + + public FlagdContainer(String feature) { + super(generateContainerName(feature)); + this.withReuse(true); + this.feature = feature; + if (!"socket".equals(this.feature)) this.addExposedPorts(8013, 8014, 8015, 8016); + } + + /** + * @return a {@link org.testcontainers.containers.GenericContainer} instance of envoy container using + * flagd sync service as backend expose on port 9211 + */ + public static GenericContainer envoy() { + final String container = "envoyproxy/envoy:v1.31.0"; + return new GenericContainer(DockerImageName.parse(container)) + .withCopyFileToContainer( + MountableFile.forClasspathResource("/envoy-config/envoy-custom.yaml"), "/etc/envoy/envoy.yaml") + .withExposedPorts(9211) + .withNetwork(network) + .withNetworkAliases("envoy"); + } + + public static @NotNull String generateContainerName(String feature) { + String container = "ghcr.io/open-feature/flagd-testbed"; + if (!Strings.isBlank(feature)) { + container += "-" + feature; + } + container += ":v" + version; + return container; + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunConfigCucumberTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunConfigCucumberTest.java index 0ef8b36be..d5dce18fe 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunConfigCucumberTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunConfigCucumberTest.java @@ -18,5 +18,5 @@ @IncludeEngines("cucumber") @SelectFile("test-harness/gherkin/config.feature") @ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") -@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps.config") +@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps") public class RunConfigCucumberTest {} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdInProcessCucumberTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdInProcessCucumberTest.java deleted file mode 100644 index 39ff92993..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdInProcessCucumberTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; -import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; - -import org.junit.jupiter.api.Order; -import org.junit.platform.suite.api.ConfigurationParameter; -import org.junit.platform.suite.api.IncludeEngines; -import org.junit.platform.suite.api.SelectFile; -import org.junit.platform.suite.api.Suite; -import org.testcontainers.junit.jupiter.Testcontainers; - -/** - * Class for running the tests associated with "stable" e2e tests (no fake disconnection) for the - * in-process provider - */ -@Order(value = Integer.MAX_VALUE) -@Suite -@IncludeEngines("cucumber") -@SelectFile("spec/specification/assets/gherkin/evaluation.feature") -@SelectFile("test-harness/gherkin/flagd-json-evaluator.feature") -@SelectFile("test-harness/gherkin/flagd.feature") -@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") -@ConfigurationParameter( - key = GLUE_PROPERTY_NAME, - value = - "dev.openfeature.contrib.providers.flagd.e2e.process.core,dev.openfeature.contrib.providers.flagd.e2e.steps") -@Testcontainers -public class RunFlagdInProcessCucumberTest {} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdInProcessEnvoyCucumberTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdInProcessEnvoyCucumberTest.java deleted file mode 100644 index a2bb60793..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdInProcessEnvoyCucumberTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; -import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; - -import org.junit.jupiter.api.Order; -import org.junit.platform.suite.api.ConfigurationParameter; -import org.junit.platform.suite.api.IncludeEngines; -import org.junit.platform.suite.api.SelectFile; -import org.junit.platform.suite.api.Suite; -import org.testcontainers.junit.jupiter.Testcontainers; - -/** - * Class for running the tests associated with "stable" e2e tests (no fake disconnection) for the - * in-process provider - */ -@Order(value = Integer.MAX_VALUE) -@Suite -@IncludeEngines("cucumber") -@SelectFile("spec/specification/assets/gherkin/evaluation.feature") -@SelectFile("test-harness/gherkin/flagd-json-evaluator.feature") -@SelectFile("test-harness/gherkin/flagd.feature") -@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") -@ConfigurationParameter( - key = GLUE_PROPERTY_NAME, - value = - "dev.openfeature.contrib.providers.flagd.e2e.process.envoy,dev.openfeature.contrib.providers.flagd.e2e.steps") -@Testcontainers -public class RunFlagdInProcessEnvoyCucumberTest {} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdInProcessReconnectCucumberTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdInProcessReconnectCucumberTest.java deleted file mode 100644 index 346d9b5a4..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdInProcessReconnectCucumberTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; -import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; - -import org.junit.jupiter.api.Order; -import org.junit.platform.suite.api.ConfigurationParameter; -import org.junit.platform.suite.api.IncludeEngines; -import org.junit.platform.suite.api.SelectFile; -import org.junit.platform.suite.api.Suite; -import org.testcontainers.junit.jupiter.Testcontainers; - -/** Class for running the reconnection tests for the in-process provider */ -@Order(value = Integer.MAX_VALUE) -@Suite -@IncludeEngines("cucumber") -@SelectFile("test-harness/gherkin/flagd-reconnect.feature") -@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") -@ConfigurationParameter( - key = GLUE_PROPERTY_NAME, - value = - "dev.openfeature.contrib.providers.flagd.e2e.reconnect.process,dev.openfeature.contrib.providers.flagd.e2e.reconnect.steps") -@Testcontainers -public class RunFlagdInProcessReconnectCucumberTest {} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdInProcessSSLCucumberTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdInProcessSSLCucumberTest.java deleted file mode 100644 index ae7ca415b..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdInProcessSSLCucumberTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; -import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; - -import org.apache.logging.log4j.core.config.Order; -import org.junit.platform.suite.api.ConfigurationParameter; -import org.junit.platform.suite.api.IncludeEngines; -import org.junit.platform.suite.api.Suite; -import org.testcontainers.junit.jupiter.Testcontainers; - -/** Class for running the reconnection tests for the RPC provider */ -@Order(value = Integer.MAX_VALUE) -@Suite(failIfNoTests = false) -@IncludeEngines("cucumber") -// @SelectFile("spec/specification/assets/gherkin/evaluation.feature") -@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") -@ConfigurationParameter( - key = GLUE_PROPERTY_NAME, - value = - "dev.openfeature.contrib.providers.flagd.e2e.ssl.process,dev.openfeature.contrib.providers.flagd.e2e.steps") -@Testcontainers -public class RunFlagdInProcessSSLCucumberTest {} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdRpcCucumberTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdRpcCucumberTest.java deleted file mode 100644 index e0d872b9f..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdRpcCucumberTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; -import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; - -import org.apache.logging.log4j.core.config.Order; -import org.junit.platform.suite.api.ConfigurationParameter; -import org.junit.platform.suite.api.IncludeEngines; -import org.junit.platform.suite.api.SelectFile; -import org.junit.platform.suite.api.Suite; -import org.testcontainers.junit.jupiter.Testcontainers; - -/** - * Class for running the tests associated with "stable" e2e tests (no fake disconnection) for the - * RPC provider - */ -@Order(value = Integer.MAX_VALUE) -@Suite -@IncludeEngines("cucumber") -@SelectFile("spec/specification/assets/gherkin/evaluation.feature") -@SelectFile("test-harness/gherkin/flagd-json-evaluator.feature") -@SelectFile("test-harness/gherkin/flagd.feature") -@SelectFile("test-harness/gherkin/flagd-rpc-caching.feature") -@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") -@ConfigurationParameter( - key = GLUE_PROPERTY_NAME, - value = "dev.openfeature.contrib.providers.flagd.e2e.rpc,dev.openfeature.contrib.providers.flagd.e2e.steps") -@Testcontainers -public class RunFlagdRpcCucumberTest {} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdRpcReconnectCucumberTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdRpcReconnectCucumberTest.java deleted file mode 100644 index 436ebf00f..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdRpcReconnectCucumberTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; -import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; - -import org.apache.logging.log4j.core.config.Order; -import org.junit.platform.suite.api.ConfigurationParameter; -import org.junit.platform.suite.api.IncludeEngines; -import org.junit.platform.suite.api.SelectFile; -import org.junit.platform.suite.api.Suite; -import org.testcontainers.junit.jupiter.Testcontainers; - -/** Class for running the reconnection tests for the RPC provider */ -@Order(value = Integer.MAX_VALUE) -@Suite -@IncludeEngines("cucumber") -@SelectFile("test-harness/gherkin/flagd-reconnect.feature") -@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") -@ConfigurationParameter( - key = GLUE_PROPERTY_NAME, - value = - "dev.openfeature.contrib.providers.flagd.e2e.reconnect.rpc,dev.openfeature.contrib.providers.flagd.e2e.reconnect.steps") -@Testcontainers -public class RunFlagdRpcReconnectCucumberTest {} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdRpcSSLCucumberTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdRpcSSLCucumberTest.java deleted file mode 100644 index f09846d41..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFlagdRpcSSLCucumberTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; -import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; - -import org.apache.logging.log4j.core.config.Order; -import org.junit.platform.suite.api.ConfigurationParameter; -import org.junit.platform.suite.api.IncludeEngines; -import org.junit.platform.suite.api.SelectFile; -import org.junit.platform.suite.api.Suite; -import org.testcontainers.junit.jupiter.Testcontainers; - -/** Class for running the reconnection tests for the RPC provider */ -@Order(value = Integer.MAX_VALUE) -@Suite -@IncludeEngines("cucumber") -@SelectFile("spec/specification/assets/gherkin/evaluation.feature") -@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") -@ConfigurationParameter( - key = GLUE_PROPERTY_NAME, - value = "dev.openfeature.contrib.providers.flagd.e2e.ssl.rpc,dev.openfeature.contrib.providers.flagd.e2e.steps") -@Testcontainers -public class RunFlagdRpcSSLCucumberTest {} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java new file mode 100644 index 000000000..e0edef240 --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java @@ -0,0 +1,39 @@ +package dev.openfeature.contrib.providers.flagd.e2e; + +import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; +import static io.cucumber.junit.platform.engine.Constants.OBJECT_FACTORY_PROPERTY_NAME; +import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; + +import dev.openfeature.contrib.providers.flagd.Config; +import org.apache.logging.log4j.core.config.Order; +import org.junit.platform.suite.api.BeforeSuite; +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.ExcludeTags; +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.IncludeTags; +import org.junit.platform.suite.api.SelectDirectories; +import org.junit.platform.suite.api.Suite; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Class for running the reconnection tests for the RPC provider + */ +@Order(value = Integer.MAX_VALUE) +@Suite +@IncludeEngines("cucumber") +@SelectDirectories("test-harness/gherkin") +// if you want to run just one feature file, use the following line instead of @SelectDirectories +// @SelectFile("test-harness/gherkin/connection.feature") +@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") +@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps") +@ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") +@IncludeTags("in-process") +@ExcludeTags({"unixsocket", "targetURI"}) +@Testcontainers +public class RunInProcessTest { + + @BeforeSuite + public static void before() { + State.resolverType = Config.Resolver.IN_PROCESS; + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java new file mode 100644 index 000000000..bc649ddeb --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java @@ -0,0 +1,39 @@ +package dev.openfeature.contrib.providers.flagd.e2e; + +import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; +import static io.cucumber.junit.platform.engine.Constants.OBJECT_FACTORY_PROPERTY_NAME; +import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; + +import dev.openfeature.contrib.providers.flagd.Config; +import org.apache.logging.log4j.core.config.Order; +import org.junit.platform.suite.api.BeforeSuite; +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.ExcludeTags; +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.IncludeTags; +import org.junit.platform.suite.api.SelectDirectories; +import org.junit.platform.suite.api.Suite; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Class for running the reconnection tests for the RPC provider + */ +@Order(value = Integer.MAX_VALUE) +@Suite +@IncludeEngines("cucumber") +@SelectDirectories("test-harness/gherkin") +// if you want to run just one feature file, use the following line instead of @SelectDirectories +// @SelectFile("test-harness/gherkin/rpc-caching.feature") +@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") +@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps") +@ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") +@IncludeTags({"rpc"}) +@ExcludeTags({"targetURI", "unixsocket"}) +@Testcontainers +public class RunRpcTest { + + @BeforeSuite + public static void before() { + State.resolverType = Config.Resolver.RPC; + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/State.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/State.java new file mode 100644 index 000000000..4ecab84e5 --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/State.java @@ -0,0 +1,26 @@ +package dev.openfeature.contrib.providers.flagd.e2e; + +import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.e2e.steps.Event; +import dev.openfeature.contrib.providers.flagd.e2e.steps.FlagSteps; +import dev.openfeature.contrib.providers.flagd.e2e.steps.ProviderType; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.MutableContext; +import java.util.LinkedList; +import java.util.List; +import java.util.Optional; + +public class State { + public ProviderType providerType; + public Client client; + public List events = new LinkedList<>(); + public Optional lastEvent; + public FlagSteps.Flag flag; + public MutableContext context = new MutableContext(); + public FlagEvaluationDetails evaluation; + public FlagdOptions options; + public FlagdOptions.FlagdOptionsBuilder builder = FlagdOptions.builder(); + public static Config.Resolver resolverType; +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/process/core/FlagdInProcessSetup.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/process/core/FlagdInProcessSetup.java deleted file mode 100644 index e23a95965..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/process/core/FlagdInProcessSetup.java +++ /dev/null @@ -1,44 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.process.core; - -import dev.openfeature.contrib.providers.flagd.Config; -import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.FlagdProvider; -import dev.openfeature.contrib.providers.flagd.e2e.ContainerConfig; -import dev.openfeature.contrib.providers.flagd.e2e.steps.StepDefinitions; -import dev.openfeature.sdk.FeatureProvider; -import io.cucumber.java.AfterAll; -import io.cucumber.java.Before; -import io.cucumber.java.BeforeAll; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.parallel.Isolated; -import org.testcontainers.containers.GenericContainer; - -@Isolated() -@Order(value = Integer.MAX_VALUE) -public class FlagdInProcessSetup { - - private static FeatureProvider provider; - - private static final GenericContainer flagdContainer = ContainerConfig.sync(); - - @BeforeAll() - public static void setup() throws InterruptedException { - flagdContainer.start(); - } - - @Before() - public static void setupTest() throws InterruptedException { - FlagdInProcessSetup.provider = new FlagdProvider(FlagdOptions.builder() - .resolverType(Config.Resolver.IN_PROCESS) - .deadline(1000) - .streamDeadlineMs(0) // this makes reconnect tests more predictable - .port(flagdContainer.getFirstMappedPort()) - .build()); - StepDefinitions.setProvider(provider); - } - - @AfterAll - public static void tearDown() { - flagdContainer.stop(); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/process/envoy/FlagdInProcessEnvoySetup.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/process/envoy/FlagdInProcessEnvoySetup.java deleted file mode 100644 index 37381a682..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/process/envoy/FlagdInProcessEnvoySetup.java +++ /dev/null @@ -1,45 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.process.envoy; - -import dev.openfeature.contrib.providers.flagd.Config; -import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.FlagdProvider; -import dev.openfeature.contrib.providers.flagd.e2e.ContainerConfig; -import dev.openfeature.contrib.providers.flagd.e2e.steps.StepDefinitions; -import dev.openfeature.sdk.FeatureProvider; -import io.cucumber.java.AfterAll; -import io.cucumber.java.BeforeAll; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.parallel.Isolated; -import org.testcontainers.containers.GenericContainer; - -@Isolated() -@Order(value = Integer.MAX_VALUE) -public class FlagdInProcessEnvoySetup { - - private static FeatureProvider provider; - - private static final GenericContainer flagdContainer = ContainerConfig.sync(false, true); - private static final GenericContainer envoyContainer = ContainerConfig.envoy(); - - @BeforeAll() - public static void setup() throws InterruptedException { - flagdContainer.start(); - envoyContainer.start(); - final String targetUri = - String.format("envoy://localhost:%s/flagd-sync.service", envoyContainer.getFirstMappedPort()); - - FlagdInProcessEnvoySetup.provider = new FlagdProvider(FlagdOptions.builder() - .resolverType(Config.Resolver.IN_PROCESS) - .deadline(1000) - .streamDeadlineMs(0) // this makes reconnect tests more predictabl - .targetUri(targetUri) - .build()); - StepDefinitions.setProvider(provider); - } - - @AfterAll - public static void tearDown() { - flagdContainer.stop(); - envoyContainer.stop(); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/process/FlagdInProcessSetup.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/process/FlagdInProcessSetup.java deleted file mode 100644 index 4ab76dd40..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/process/FlagdInProcessSetup.java +++ /dev/null @@ -1,49 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.reconnect.process; - -import dev.openfeature.contrib.providers.flagd.Config; -import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.FlagdProvider; -import dev.openfeature.contrib.providers.flagd.e2e.ContainerConfig; -import dev.openfeature.contrib.providers.flagd.e2e.reconnect.steps.StepDefinitions; -import dev.openfeature.sdk.FeatureProvider; -import io.cucumber.java.AfterAll; -import io.cucumber.java.Before; -import io.cucumber.java.BeforeAll; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.parallel.Isolated; -import org.testcontainers.containers.GenericContainer; - -@Isolated() -@Order(value = Integer.MAX_VALUE) -public class FlagdInProcessSetup { - - private static final GenericContainer flagdContainer = ContainerConfig.sync(true, false); - - @BeforeAll() - public static void setup() throws InterruptedException { - flagdContainer.start(); - } - - @Before() - public static void setupTest() throws InterruptedException { - FeatureProvider workingProvider = new FlagdProvider(FlagdOptions.builder() - .resolverType(Config.Resolver.IN_PROCESS) - .port(flagdContainer.getFirstMappedPort()) - // set a generous deadline, to prevent timeouts in actions - .deadline(3000) - .build()); - StepDefinitions.setUnstableProvider(workingProvider); - - FeatureProvider unavailableProvider = new FlagdProvider(FlagdOptions.builder() - .resolverType(Config.Resolver.IN_PROCESS) - .deadline(100) - .port(9092) // this port isn't serving anything, error expected - .build()); - StepDefinitions.setUnavailableProvider(unavailableProvider); - } - - @AfterAll - public static void tearDown() { - flagdContainer.stop(); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/rpc/FlagdRpcSetup.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/rpc/FlagdRpcSetup.java deleted file mode 100644 index e913265d5..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/rpc/FlagdRpcSetup.java +++ /dev/null @@ -1,53 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.reconnect.rpc; - -import dev.openfeature.contrib.providers.flagd.Config; -import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.FlagdProvider; -import dev.openfeature.contrib.providers.flagd.e2e.ContainerConfig; -import dev.openfeature.contrib.providers.flagd.e2e.reconnect.steps.StepDefinitions; -import dev.openfeature.contrib.providers.flagd.resolver.grpc.cache.CacheType; -import dev.openfeature.sdk.FeatureProvider; -import io.cucumber.java.AfterAll; -import io.cucumber.java.Before; -import io.cucumber.java.BeforeAll; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.parallel.Isolated; -import org.testcontainers.containers.GenericContainer; - -@Isolated() -@Order(value = Integer.MAX_VALUE) -public class FlagdRpcSetup { - private static final GenericContainer flagdContainer = ContainerConfig.flagd(true); - - @BeforeAll() - public static void setups() throws InterruptedException { - flagdContainer.start(); - } - - @Before() - public static void setupTest() throws InterruptedException { - - FeatureProvider workingProvider = new FlagdProvider(FlagdOptions.builder() - .resolverType(Config.Resolver.RPC) - .port(flagdContainer.getFirstMappedPort()) - .deadline(1000) - .retryGracePeriod(1) - .streamDeadlineMs(0) // this makes reconnect tests more predictable - .cacheType(CacheType.DISABLED.getValue()) - .build()); - StepDefinitions.setUnstableProvider(workingProvider); - - FeatureProvider unavailableProvider = new FlagdProvider(FlagdOptions.builder() - .resolverType(Config.Resolver.RPC) - .port(8015) // this port isn't serving anything, error expected - .deadline(100) - .cacheType(CacheType.DISABLED.getValue()) - .build()); - StepDefinitions.setUnavailableProvider(unavailableProvider); - } - - @AfterAll - public static void tearDown() { - flagdContainer.stop(); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/steps/StepDefinitions.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/steps/StepDefinitions.java deleted file mode 100644 index d438e7d0f..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/reconnect/steps/StepDefinitions.java +++ /dev/null @@ -1,121 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.reconnect.steps; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import dev.openfeature.sdk.Client; -import dev.openfeature.sdk.EventDetails; -import dev.openfeature.sdk.FeatureProvider; -import dev.openfeature.sdk.OpenFeatureAPI; -import io.cucumber.java.AfterAll; -import io.cucumber.java.en.Given; -import io.cucumber.java.en.Then; -import io.cucumber.java.en.When; -import java.time.Duration; -import java.util.function.Consumer; -import org.awaitility.Awaitility; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.parallel.Isolated; - -/** - * Test suite for testing flagd provider reconnect functionality. The associated container run a - * flagd instance which restarts every 5s. - */ -@Isolated() -@Order(value = Integer.MAX_VALUE) -public class StepDefinitions { - - private static Client client; - private static FeatureProvider unstableProvider; - private static FeatureProvider unavailableProvider; - - private int readyHandlerRunCount = 0; - private int errorHandlerRunCount = 0; - - private Consumer readyHandler = (EventDetails) -> { - readyHandlerRunCount++; - }; - private Consumer errorHandler = (EventDetails) -> { - errorHandlerRunCount++; - }; - - /** - * Injects the client to use for this test. Tests run one at a time, but just in case, a lock is - * used to make sure the client is not updated mid-test. - * - * @param provider client to inject into test. - */ - public static void setUnstableProvider(FeatureProvider provider) { - StepDefinitions.unstableProvider = provider; - } - - public static void setUnavailableProvider(FeatureProvider provider) { - StepDefinitions.unavailableProvider = provider; - } - - public StepDefinitions() { - StepDefinitions.client = OpenFeatureAPI.getInstance().getClient("unstable"); - OpenFeatureAPI.getInstance().setProviderAndWait("unstable", unstableProvider); - } - - @Given("a flagd provider is set") - public static void setup() { - // done in constructor - } - - @AfterAll() - public static void cleanUp() throws InterruptedException { - StepDefinitions.unstableProvider.shutdown(); - StepDefinitions.unstableProvider = null; - StepDefinitions.client = null; - } - - @When("a PROVIDER_READY handler and a PROVIDER_ERROR handler are added") - public void a_provider_ready_handler_and_a_provider_error_handler_are_added() { - client.onProviderReady(this.readyHandler); - client.onProviderError(this.errorHandler); - } - - @Then("the PROVIDER_READY handler must run when the provider connects") - public void the_provider_ready_handler_must_run_when_the_provider_connects() { - // no errors expected yet - assertEquals(0, errorHandlerRunCount); - // wait up to 240 seconds for a connect (PROVIDER_READY event) - Awaitility.await().atMost(Duration.ofSeconds(240)).until(() -> { - return this.readyHandlerRunCount == 1; - }); - } - - @Then("the PROVIDER_ERROR handler must run when the provider's connection is lost") - public void the_provider_error_handler_must_run_when_the_provider_s_connection_is_lost() { - // wait up to 240 seconds for a disconnect (PROVIDER_ERROR event) - Awaitility.await().atMost(Duration.ofSeconds(240)).until(() -> { - return this.errorHandlerRunCount > 0; - }); - } - - @Then("when the connection is reestablished the PROVIDER_READY handler must run again") - public void when_the_connection_is_reestablished_the_provider_ready_handler_must_run_again() { - // wait up to 240 seconds for a reconnect (PROVIDER_READY event) - Awaitility.await().atMost(Duration.ofSeconds(240)).until(() -> { - return this.readyHandlerRunCount > 1; - }); - } - - @Given("flagd is unavailable") - public void flagd_is_unavailable() { - // there is no flag available on the port used by StepDefinitions.unavailableProvider - } - - @When("a flagd provider is set and initialization is awaited") - public void a_flagd_provider_is_set_and_initialization_is_awaited() { - // handled below - } - - @Then("an error should be indicated within the configured deadline") - public void an_error_should_be_indicated_within_the_configured_deadline() { - assertThrows(Exception.class, () -> { - OpenFeatureAPI.getInstance().setProviderAndWait("unavailable", StepDefinitions.unavailableProvider); - }); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/rpc/FlagdRpcSetup.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/rpc/FlagdRpcSetup.java deleted file mode 100644 index 3d61dc034..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/rpc/FlagdRpcSetup.java +++ /dev/null @@ -1,42 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.rpc; - -import dev.openfeature.contrib.providers.flagd.Config; -import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.FlagdProvider; -import dev.openfeature.contrib.providers.flagd.e2e.ContainerConfig; -import dev.openfeature.contrib.providers.flagd.e2e.steps.StepDefinitions; -import dev.openfeature.sdk.FeatureProvider; -import io.cucumber.java.AfterAll; -import io.cucumber.java.Before; -import io.cucumber.java.BeforeAll; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.parallel.Isolated; -import org.testcontainers.containers.GenericContainer; - -@Isolated() -@Order(value = Integer.MAX_VALUE) -public class FlagdRpcSetup { - - private static FeatureProvider provider; - private static final GenericContainer flagdContainer = ContainerConfig.flagd(); - - @BeforeAll() - public static void setup() { - flagdContainer.start(); - } - - @Before() - public static void test_setup() { - FlagdRpcSetup.provider = new FlagdProvider(FlagdOptions.builder() - .resolverType(Config.Resolver.RPC) - .port(flagdContainer.getFirstMappedPort()) - .deadline(500) - .build()); - StepDefinitions.setProvider(provider); - } - - @AfterAll - public static void tearDown() { - flagdContainer.stop(); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ssl/process/FlagdInProcessSetup.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ssl/process/FlagdInProcessSetup.java deleted file mode 100644 index e75c92394..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ssl/process/FlagdInProcessSetup.java +++ /dev/null @@ -1,51 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.ssl.process; - -import dev.openfeature.contrib.providers.flagd.Config; -import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.FlagdProvider; -import dev.openfeature.contrib.providers.flagd.e2e.ContainerConfig; -import dev.openfeature.contrib.providers.flagd.e2e.steps.StepDefinitions; -import dev.openfeature.sdk.FeatureProvider; -import io.cucumber.java.AfterAll; -import io.cucumber.java.Before; -import io.cucumber.java.BeforeAll; -import java.io.File; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.parallel.Isolated; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.utility.DockerImageName; - -@Isolated() -@Order(value = Integer.MAX_VALUE) -public class FlagdInProcessSetup { - private static final GenericContainer flagdContainer = new GenericContainer( - DockerImageName.parse(ContainerConfig.generateContainerName("flagd", "ssl"))) - .withExposedPorts(8015); - - @BeforeAll() - public static void setups() throws InterruptedException { - flagdContainer.start(); - } - - @Before() - public static void setupTest() throws InterruptedException { - String path = "test-harness/ssl/custom-root-cert.crt"; - - File file = new File(path); - String absolutePath = file.getAbsolutePath(); - FeatureProvider workingProvider = new FlagdProvider(FlagdOptions.builder() - .resolverType(Config.Resolver.IN_PROCESS) - .port(flagdContainer.getFirstMappedPort()) - .deadline(10000) - .streamDeadlineMs(0) // this makes reconnect tests more predictable - .tls(true) - .certPath(absolutePath) - .build()); - StepDefinitions.setProvider(workingProvider); - } - - @AfterAll - public static void tearDown() { - flagdContainer.stop(); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ssl/rpc/FlagdRpcSetup.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ssl/rpc/FlagdRpcSetup.java deleted file mode 100644 index 6b318ae9e..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/ssl/rpc/FlagdRpcSetup.java +++ /dev/null @@ -1,51 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.ssl.rpc; - -import dev.openfeature.contrib.providers.flagd.Config; -import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.FlagdProvider; -import dev.openfeature.contrib.providers.flagd.e2e.ContainerConfig; -import dev.openfeature.contrib.providers.flagd.e2e.steps.StepDefinitions; -import dev.openfeature.sdk.FeatureProvider; -import io.cucumber.java.AfterAll; -import io.cucumber.java.Before; -import io.cucumber.java.BeforeAll; -import java.io.File; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.parallel.Isolated; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.utility.DockerImageName; - -@Isolated() -@Order(value = Integer.MAX_VALUE) -public class FlagdRpcSetup { - private static final GenericContainer flagdContainer = new GenericContainer( - DockerImageName.parse(ContainerConfig.generateContainerName("flagd", "ssl"))) - .withExposedPorts(8013); - - @BeforeAll() - public static void setups() throws InterruptedException { - flagdContainer.start(); - } - - @Before() - public static void setupTest() throws InterruptedException { - String path = "test-harness/ssl/custom-root-cert.crt"; - - File file = new File(path); - String absolutePath = file.getAbsolutePath(); - FeatureProvider workingProvider = new FlagdProvider(FlagdOptions.builder() - .resolverType(Config.Resolver.RPC) - .port(flagdContainer.getFirstMappedPort()) - .deadline(10000) - .streamDeadlineMs(0) // this makes reconnect tests more predictable - .tls(true) - .certPath(absolutePath) - .build()); - StepDefinitions.setProvider(workingProvider); - } - - @AfterAll - public static void tearDown() { - flagdContainer.stop(); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/AbstractSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/AbstractSteps.java new file mode 100644 index 000000000..133c1fb49 --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/AbstractSteps.java @@ -0,0 +1,11 @@ +package dev.openfeature.contrib.providers.flagd.e2e.steps; + +import dev.openfeature.contrib.providers.flagd.e2e.State; + +abstract class AbstractSteps { + State state; + + public AbstractSteps(State state) { + this.state = state; + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/config/ConfigSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ConfigSteps.java similarity index 57% rename from providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/config/ConfigSteps.java rename to providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ConfigSteps.java index a680a9947..8e8ee44d6 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/config/ConfigSteps.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ConfigSteps.java @@ -1,25 +1,22 @@ -package dev.openfeature.contrib.providers.flagd.e2e.steps.config; +package dev.openfeature.contrib.providers.flagd.e2e.steps; import static org.assertj.core.api.Assertions.assertThat; import dev.openfeature.contrib.providers.flagd.Config; -import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.resolver.grpc.cache.CacheType; +import dev.openfeature.contrib.providers.flagd.e2e.State; import io.cucumber.java.en.Given; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; -public class ConfigSteps { +@Slf4j +public class ConfigSteps extends AbstractSteps { /** * Not all properties are correctly implemented, hence that we need to ignore them till this is * fixed @@ -31,24 +28,24 @@ public class ConfigSteps { } }; - private static final Logger LOG = LoggerFactory.getLogger(ConfigSteps.class); - - FlagdOptions.FlagdOptionsBuilder builder = FlagdOptions.builder(); - FlagdOptions options; + public ConfigSteps(State state) { + super(state); + } @When("a config was initialized") public void we_initialize_a_config() { - options = builder.build(); + state.options = state.builder.build(); } @When("a config was initialized for {string}") public void we_initialize_a_config_for(String string) { switch (string.toLowerCase()) { case "in-process": - options = builder.resolverType(Config.Resolver.IN_PROCESS).build(); + state.options = + state.builder.resolverType(Config.Resolver.IN_PROCESS).build(); break; case "rpc": - options = builder.resolverType(Config.Resolver.RPC).build(); + state.options = state.builder.resolverType(Config.Resolver.RPC).build(); break; default: throw new RuntimeException("Unknown resolver type: " + string); @@ -56,19 +53,18 @@ public void we_initialize_a_config_for(String string) { } @Given("an option {string} of type {string} with value {string}") - public void we_have_an_option_of_type_with_value(String option, String type, String value) - throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException { + public void we_have_an_option_of_type_with_value(String option, String type, String value) throws Throwable { if (IGNORED_FOR_NOW.contains(option)) { - LOG.error("option '{}' is not supported", option); + log.error("option '{}' is not supported", option); return; } - Object converted = convert(value, type); - Method method = Arrays.stream(builder.getClass().getMethods()) + Object converted = Utils.convert(value, type); + Method method = Arrays.stream(state.builder.getClass().getMethods()) .filter(method1 -> method1.getName().equals(mapOptionNames(option))) .findFirst() .orElseThrow(RuntimeException::new); - method.invoke(builder, converted); + method.invoke(state.builder, converted); } Map envVarsSet = new HashMap<>(); @@ -81,44 +77,18 @@ public void we_have_an_environment_variable_with_value(String varName, String va EnvironmentVariableUtils.set(varName, value); } - private Object convert(String value, String type) throws ClassNotFoundException { - if (Objects.equals(value, "null")) return null; - switch (type) { - case "Boolean": - return Boolean.parseBoolean(value); - case "String": - return value; - case "Integer": - return Integer.parseInt(value); - case "Long": - return Long.parseLong(value); - case "ResolverType": - switch (value.toLowerCase()) { - case "in-process": - return Config.Resolver.IN_PROCESS; - case "rpc": - return Config.Resolver.RPC; - default: - throw new RuntimeException("Unknown resolver type: " + value); - } - case "CacheType": - return CacheType.valueOf(value.toUpperCase()).getValue(); - } - throw new RuntimeException("Unknown config type: " + type); - } - @Then("the option {string} of type {string} should have the value {string}") public void the_option_of_type_should_have_the_value(String option, String type, String value) throws Throwable { - Object convert = convert(value, type); + Object convert = Utils.convert(value, type); if (IGNORED_FOR_NOW.contains(option)) { - LOG.error("option '{}' is not supported", option); + log.error("option '{}' is not supported", option); return; } option = mapOptionNames(option); - assertThat(options).hasFieldOrPropertyWithValue(option, convert); + assertThat(state.options).hasFieldOrPropertyWithValue(option, convert); // Resetting env vars for (Map.Entry envVar : envVarsSet.entrySet()) { diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java new file mode 100644 index 000000000..9de541e23 --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java @@ -0,0 +1,39 @@ +package dev.openfeature.contrib.providers.flagd.e2e.steps; + +import dev.openfeature.contrib.providers.flagd.e2e.State; +import dev.openfeature.sdk.ImmutableStructure; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.Value; +import io.cucumber.java.en.Given; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.parallel.Isolated; + +@Isolated() +public class ContextSteps extends AbstractSteps { + + public ContextSteps(State state) { + super(state); + } + + @Given("a context containing a key {string}, with type {string} and with value {string}") + public void a_context_containing_a_key_with_type_and_with_value(String key, String type, String value) + throws ClassNotFoundException, InstantiationException { + Map map = state.context.asMap(); + map.put(key, new Value(value)); + state.context = new MutableContext(state.context.getTargetingKey(), map); + } + + @Given("a context containing a targeting key with value {string}") + public void a_context_containing_a_targeting_key_with_value(String string) { + state.context.setTargetingKey(string); + } + + @Given("a context containing a nested property with outer key {string} and inner key {string}, with value {string}") + public void a_context_containing_a_nested_property_with_outer_key_and_inner_key_with_value( + String outer, String inner, String value) { + Map innerMap = new HashMap<>(); + innerMap.put(inner, new Value(value)); + state.context.add(outer, new ImmutableStructure(innerMap)); + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/config/EnvironmentVariableUtils.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/EnvironmentVariableUtils.java similarity index 98% rename from providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/config/EnvironmentVariableUtils.java rename to providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/EnvironmentVariableUtils.java index 6d1946134..b3ef4346e 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/config/EnvironmentVariableUtils.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/EnvironmentVariableUtils.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.e2e.steps.config; +package dev.openfeature.contrib.providers.flagd.e2e.steps; /* * Copy of JUnit Pioneer's EnvironmentVariable Utils diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Event.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Event.java new file mode 100644 index 000000000..f93c327e6 --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Event.java @@ -0,0 +1,13 @@ +package dev.openfeature.contrib.providers.flagd.e2e.steps; + +import dev.openfeature.sdk.EventDetails; + +public class Event { + public String type; + public EventDetails details; + + public Event(String eventType, EventDetails eventDetails) { + this.type = eventType; + this.details = eventDetails; + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/EventSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/EventSteps.java new file mode 100644 index 000000000..6dbd0c9ca --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/EventSteps.java @@ -0,0 +1,70 @@ +package dev.openfeature.contrib.providers.flagd.e2e.steps; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.awaitility.Awaitility.await; + +import dev.openfeature.contrib.providers.flagd.e2e.State; +import dev.openfeature.sdk.ProviderEvent; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.util.LinkedList; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.parallel.Isolated; + +@Isolated() +@Slf4j +public class EventSteps extends AbstractSteps { + + public EventSteps(State state) { + super(state); + state.events = new LinkedList<>(); + } + + @Given("a {} event handler") + public void a_stale_event_handler(String eventType) { + state.client.on(mapEventType(eventType), eventDetails -> { + log.info("event tracked for {} ", eventType); + state.events.add(new Event(eventType, eventDetails)); + }); + } + + private static @NotNull ProviderEvent mapEventType(String eventType) { + switch (eventType) { + case "stale": + return ProviderEvent.PROVIDER_STALE; + case "ready": + return ProviderEvent.PROVIDER_READY; + case "error": + return ProviderEvent.PROVIDER_ERROR; + case "change": + return ProviderEvent.PROVIDER_CONFIGURATION_CHANGED; + default: + throw new IllegalArgumentException("Unknown event type: " + eventType); + } + } + + @When("a {} event was fired") + public void eventWasFired(String eventType) throws InterruptedException { + eventHandlerShouldBeExecutedWithin(eventType, 10000); + // we might be too fast in the execution + Thread.sleep(500); + } + + @Then("the {} event handler should have been executed") + public void eventHandlerShouldBeExecuted(String eventType) { + eventHandlerShouldBeExecutedWithin(eventType, 30000); + } + + @Then("the {} event handler should have been executed within {int}ms") + public void eventHandlerShouldBeExecutedWithin(String eventType, int ms) { + log.info("waiting for eventtype: {}", eventType); + await().atMost(ms, MILLISECONDS) + .until(() -> state.events.stream().anyMatch(event -> event.type.equals(eventType))); + state.lastEvent = state.events.stream() + .filter(event -> event.type.equals(eventType)) + .findFirst(); + state.events.removeIf(event -> event.type.equals(eventType)); + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/FlagSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/FlagSteps.java new file mode 100644 index 000000000..aab14a857 --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/FlagSteps.java @@ -0,0 +1,95 @@ +package dev.openfeature.contrib.providers.flagd.e2e.steps; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import dev.openfeature.contrib.providers.flagd.e2e.State; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Value; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import org.junit.jupiter.api.parallel.Isolated; + +@Isolated() +public class FlagSteps extends AbstractSteps { + + public FlagSteps(State state) { + super(state); + } + + @Given("a {}-flag with key {string} and a default value {string}") + public void givenAFlag(String type, String name, String defaultValue) throws Throwable { + state.flag = new Flag(type, name, Utils.convert(defaultValue, type)); + } + + @When("the flag was evaluated with details") + public void the_flag_was_evaluated_with_details() throws InterruptedException { + FlagEvaluationDetails details; + switch (state.flag.type) { + case "String": + details = + state.client.getStringDetails(state.flag.name, (String) state.flag.defaultValue, state.context); + break; + case "Boolean": + details = state.client.getBooleanDetails( + state.flag.name, (Boolean) state.flag.defaultValue, state.context); + break; + case "Float": + details = + state.client.getDoubleDetails(state.flag.name, (Double) state.flag.defaultValue, state.context); + break; + case "Integer": + details = state.client.getIntegerDetails( + state.flag.name, (Integer) state.flag.defaultValue, state.context); + break; + case "Object": + details = + state.client.getObjectDetails(state.flag.name, (Value) state.flag.defaultValue, state.context); + break; + default: + throw new AssertionError(); + } + state.evaluation = details; + } + + @Then("the resolved details value should be \"{}\"") + public void the_resolved_details_value_should_be(String value) throws Throwable { + assertThat(state.evaluation.getValue()).isEqualTo(Utils.convert(value, state.flag.type)); + } + + @Then("the reason should be {string}") + public void the_reason_should_be(String reason) { + assertThat(state.evaluation.getReason()).isEqualTo(reason); + } + + @Then("the variant should be {string}") + public void the_variant_should_be(String variant) { + assertThat(state.evaluation.getVariant()).isEqualTo(variant); + } + + @Then("the flag should be part of the event payload") + @Then("the flag was modified") + public void the_flag_was_modified() { + await().atMost(5000, MILLISECONDS).until(() -> state.events.stream() + .anyMatch(event -> event.type.equals("change") + && event.details.getFlagsChanged().contains(state.flag.name))); + state.lastEvent = state.events.stream() + .filter(event -> event.type.equals("change") + && event.details.getFlagsChanged().contains(state.flag.name)) + .findFirst(); + } + + public class Flag { + String name; + Object defaultValue; + String type; + + public Flag(String type, String name, Object defaultValue) { + this.name = name; + this.defaultValue = defaultValue; + this.type = type; + } + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ProviderSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ProviderSteps.java new file mode 100644 index 000000000..cf0e5ed0c --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ProviderSteps.java @@ -0,0 +1,232 @@ +package dev.openfeature.contrib.providers.flagd.e2e.steps; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.providers.flagd.FlagdProvider; +import dev.openfeature.contrib.providers.flagd.e2e.FlagdContainer; +import dev.openfeature.contrib.providers.flagd.e2e.State; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.OpenFeatureAPI; +import eu.rekawek.toxiproxy.Proxy; +import eu.rekawek.toxiproxy.ToxiproxyClient; +import eu.rekawek.toxiproxy.model.ToxicDirection; +import io.cucumber.java.After; +import io.cucumber.java.AfterAll; +import io.cucumber.java.Before; +import io.cucumber.java.BeforeAll; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.When; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Timer; +import java.util.TimerTask; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.parallel.Isolated; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.ToxiproxyContainer; +import org.testcontainers.shaded.org.apache.commons.io.FileUtils; + +@Isolated() +@Slf4j +public class ProviderSteps extends AbstractSteps { + + public static final int UNAVAILABLE_PORT = 9999; + static Map containers = new HashMap<>(); + public static Network network = Network.newNetwork(); + public static ToxiproxyContainer toxiproxy = + new ToxiproxyContainer("ghcr.io/shopify/toxiproxy:2.5.0").withNetwork(network); + public static ToxiproxyClient toxiproxyClient; + + static Path sharedTempDir; + + public ProviderSteps(State state) { + super(state); + } + + static String generateProxyName(Config.Resolver resolver, ProviderType providerType) { + return providerType + "-" + resolver; + } + + @BeforeAll + public static void beforeAll() throws IOException { + toxiproxy.start(); + toxiproxyClient = new ToxiproxyClient(toxiproxy.getHost(), toxiproxy.getControlPort()); + toxiproxyClient.createProxy( + generateProxyName(Config.Resolver.RPC, ProviderType.DEFAULT), "0.0.0.0:8666", "default:8013"); + + toxiproxyClient.createProxy( + generateProxyName(Config.Resolver.IN_PROCESS, ProviderType.DEFAULT), "0.0.0.0:8667", "default:8015"); + toxiproxyClient.createProxy( + generateProxyName(Config.Resolver.RPC, ProviderType.SSL), "0.0.0.0:8668", "ssl:8013"); + toxiproxyClient.createProxy( + generateProxyName(Config.Resolver.IN_PROCESS, ProviderType.SSL), "0.0.0.0:8669", "ssl:8015"); + + containers.put( + ProviderType.DEFAULT, new FlagdContainer().withNetwork(network).withNetworkAliases("default")); + containers.put( + ProviderType.SSL, new FlagdContainer("ssl").withNetwork(network).withNetworkAliases("ssl")); + sharedTempDir = Files.createDirectories( + Paths.get("tmp/" + RandomStringUtils.randomAlphanumeric(8).toLowerCase() + "/")); + containers.put( + ProviderType.SOCKET, + new FlagdContainer("socket") + .withFileSystemBind(sharedTempDir.toAbsolutePath().toString(), "/tmp", BindMode.READ_WRITE)); + } + + @AfterAll + public static void afterAll() throws IOException { + + containers.forEach((name, container) -> container.stop()); + FileUtils.deleteDirectory(sharedTempDir.toFile()); + toxiproxyClient.reset(); + toxiproxy.stop(); + } + + @Before + public void before() throws IOException { + + toxiproxyClient.getProxies().forEach(proxy -> { + try { + proxy.toxics().getAll().forEach(toxic -> { + try { + toxic.remove(); + } catch (IOException e) { + log.debug("Failed to remove timout", e); + } + }); + } catch (IOException e) { + log.debug("Failed to remove timout", e); + } + }); + + containers.values().stream() + .filter(containers -> !containers.isRunning()) + .forEach(FlagdContainer::start); + } + + @After + public void tearDown() { + OpenFeatureAPI.getInstance().shutdown(); + } + + public int getPort(Config.Resolver resolver, ProviderType providerType) { + switch (resolver) { + case RPC: + switch (providerType) { + case DEFAULT: + return toxiproxy.getMappedPort(8666); + case SSL: + return toxiproxy.getMappedPort(8668); + } + case IN_PROCESS: + switch (providerType) { + case DEFAULT: + return toxiproxy.getMappedPort(8667); + case SSL: + return toxiproxy.getMappedPort(8669); + } + default: + throw new IllegalArgumentException("Unsupported resolver: " + resolver); + } + } + + @Given("a {} flagd provider") + public void setupProvider(String providerType) throws IOException { + state.builder.deadline(500).keepAlive(0).retryGracePeriod(3); + boolean wait = true; + switch (providerType) { + case "unavailable": + this.state.providerType = ProviderType.SOCKET; + state.builder.port(UNAVAILABLE_PORT); + wait = false; + break; + case "socket": + this.state.providerType = ProviderType.SOCKET; + String socketPath = + sharedTempDir.resolve("socket.sock").toAbsolutePath().toString(); + state.builder.socketPath(socketPath); + state.builder.port(UNAVAILABLE_PORT); + break; + case "ssl": + String path = "test-harness/ssl/custom-root-cert.crt"; + + File file = new File(path); + String absolutePath = file.getAbsolutePath(); + this.state.providerType = ProviderType.SSL; + state.builder + .port(getPort(State.resolverType, state.providerType)) + .tls(true) + .certPath(absolutePath); + break; + case "offline": + File flags = new File("test-harness/flags"); + ObjectMapper objectMapper = new ObjectMapper(); + Object merged = new Object(); + for (File listFile : Objects.requireNonNull(flags.listFiles())) { + ObjectReader updater = objectMapper.readerForUpdating(merged); + merged = updater.readValue(listFile, Object.class); + } + Path offlinePath = Files.createTempFile("flags", ".json"); + objectMapper.writeValue(offlinePath.toFile(), merged); + + state.builder + .port(UNAVAILABLE_PORT) + .offlineFlagSourcePath(offlinePath.toAbsolutePath().toString()); + break; + + default: + this.state.providerType = ProviderType.DEFAULT; + state.builder.port(getPort(State.resolverType, state.providerType)); + break; + } + FeatureProvider provider = + new FlagdProvider(state.builder.resolverType(State.resolverType).build()); + + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + if (wait) { + api.setProviderAndWait(providerType, provider); + } else { + api.setProvider(providerType, provider); + } + this.state.client = api.getClient(providerType); + } + + @When("the connection is lost for {int}s") + public void the_connection_is_lost_for(int seconds) throws InterruptedException, IOException { + log.info("Timeout and wait for {} seconds", seconds); + String randomizer = RandomStringUtils.randomAlphanumeric(5); + String timeoutUpName = "restart-up-" + randomizer; + String timeoutDownName = "restart-down-" + randomizer; + Proxy proxy = toxiproxyClient.getProxy(generateProxyName(State.resolverType, state.providerType)); + proxy.toxics().timeout(timeoutDownName, ToxicDirection.DOWNSTREAM, seconds); + proxy.toxics().timeout(timeoutUpName, ToxicDirection.UPSTREAM, seconds); + + TimerTask task = new TimerTask() { + public void run() { + try { + proxy.toxics().get(timeoutUpName).remove(); + proxy.toxics().get(timeoutDownName).remove(); + } catch (IOException e) { + log.debug("Failed to remove timeout", e); + } + } + }; + Timer restartTimer = new Timer("Timer" + randomizer); + + restartTimer.schedule(task, seconds * 1000L); + } + + static FlagdContainer getContainer(ProviderType providerType) { + log.info("getting container for {}", providerType); + return containers.getOrDefault(providerType, containers.get(ProviderType.DEFAULT)); + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ProviderType.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ProviderType.java new file mode 100644 index 000000000..eac0ba967 --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ProviderType.java @@ -0,0 +1,7 @@ +package dev.openfeature.contrib.providers.flagd.e2e.steps; + +public enum ProviderType { + DEFAULT, + SSL, + SOCKET +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/StepDefinitions.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/StepDefinitions.java deleted file mode 100644 index c3f894a08..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/StepDefinitions.java +++ /dev/null @@ -1,555 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e.steps; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import dev.openfeature.sdk.Client; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.EventDetails; -import dev.openfeature.sdk.FeatureProvider; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.ImmutableContext; -import dev.openfeature.sdk.ImmutableStructure; -import dev.openfeature.sdk.OpenFeatureAPI; -import dev.openfeature.sdk.Reason; -import dev.openfeature.sdk.Structure; -import dev.openfeature.sdk.Value; -import io.cucumber.java.AfterAll; -import io.cucumber.java.en.And; -import io.cucumber.java.en.Given; -import io.cucumber.java.en.Then; -import io.cucumber.java.en.When; -import java.time.Duration; -import java.util.HashMap; -import java.util.Map; -import java.util.function.Consumer; -import org.awaitility.Awaitility; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.parallel.Isolated; - -/** Common test suite used by both RPC and in-process flagd providers. */ -@Isolated() -@Order(value = Integer.MAX_VALUE) -public class StepDefinitions { - - private static Client client; - private static FeatureProvider provider; - - private String booleanFlagKey; - private String stringFlagKey; - private String intFlagKey; - private String doubleFlagKey; - private String objectFlagKey; - - private boolean booleanFlagDefaultValue; - private String stringFlagDefaultValue; - private int intFlagDefaultValue; - private double doubleFlagDefaultValue; - private Value objectFlagDefaultValue; - - private FlagEvaluationDetails objectFlagDetails; - - private String contextAwareFlagKey; - private String contextAwareDefaultValue; - private EvaluationContext context; - private String contextAwareValue; - - private String notFoundFlagKey; - private String notFoundDefaultValue; - private FlagEvaluationDetails notFoundDetails; - private String typeErrorFlagKey; - private int typeErrorDefaultValue; - private FlagEvaluationDetails typeErrorDetails; - - private boolean isChangeHandlerRun = false; - private String changedFlag; - private boolean isReadyHandlerRun = false; - - private Consumer changeHandler; - private Consumer readyHandler; - - /** - * Injects the client to use for this test. Tests run one at a time, but just in case, a lock is - * used to make sure the client is not updated mid-test. - * - * @param client client to inject into test. - */ - public static void setProvider(FeatureProvider provider) { - StepDefinitions.provider = provider; - } - - public StepDefinitions() { - OpenFeatureAPI.getInstance().setProviderAndWait("e2e", provider); - StepDefinitions.client = OpenFeatureAPI.getInstance().getClient("e2e"); - } - - @Given("a provider is registered") - @Given("a flagd provider is set") - public static void setup() { - // done in constructor - } - - @AfterAll() - public static void cleanUp() throws InterruptedException { - StepDefinitions.provider.shutdown(); - StepDefinitions.provider = null; - StepDefinitions.client = null; - } - - /* - * Basic evaluation - */ - - // boolean value - @When("a boolean flag with key {string} is evaluated with default value {string}") - public void a_boolean_flag_with_key_boolean_flag_is_evaluated_with_default_value_false( - String flagKey, String defaultValue) { - this.booleanFlagKey = flagKey; - this.booleanFlagDefaultValue = Boolean.valueOf(defaultValue); - } - - @Then("the resolved boolean value should be {string}") - public void the_resolved_boolean_value_should_be_true(String expected) { - boolean value = client.getBooleanValue(this.booleanFlagKey, Boolean.valueOf(this.booleanFlagDefaultValue)); - assertEquals(Boolean.valueOf(expected), value); - } - - // string value - - @When("a string flag with key {string} is evaluated with default value {string}") - public void a_string_flag_with_key_is_evaluated_with_default_value(String flagKey, String defaultValue) { - this.stringFlagKey = flagKey; - this.stringFlagDefaultValue = defaultValue; - } - - @Then("the resolved string value should be {string}") - public void the_resolved_string_value_should_be(String expected) { - String value = client.getStringValue(this.stringFlagKey, this.stringFlagDefaultValue); - assertEquals(expected, value); - } - - // integer value - @When("an integer flag with key {string} is evaluated with default value {int}") - public void an_integer_flag_with_key_is_evaluated_with_default_value(String flagKey, Integer defaultValue) { - this.intFlagKey = flagKey; - this.intFlagDefaultValue = defaultValue; - } - - @Then("the resolved integer value should be {int}") - public void the_resolved_integer_value_should_be(int expected) { - int value = client.getIntegerValue(this.intFlagKey, this.intFlagDefaultValue); - assertEquals(expected, value); - } - - // float/double value - @When("a float flag with key {string} is evaluated with default value {double}") - public void a_float_flag_with_key_is_evaluated_with_default_value(String flagKey, double defaultValue) { - this.doubleFlagKey = flagKey; - this.doubleFlagDefaultValue = defaultValue; - } - - @Then("the resolved float value should be {double}") - public void the_resolved_float_value_should_be(double expected) { - double value = client.getDoubleValue(this.doubleFlagKey, this.doubleFlagDefaultValue); - assertEquals(expected, value); - } - - // object value - @When("an object flag with key {string} is evaluated with a null default value") - public void an_object_flag_with_key_is_evaluated_with_a_null_default_value(String flagKey) { - this.objectFlagKey = flagKey; - this.objectFlagDefaultValue = new Value(); // empty value is equivalent to null - } - - @Then( - "the resolved object value should be contain fields {string}, {string}, and {string}, with values {string}, {string} and {int}, respectively") - public void the_resolved_object_value_should_be_contain_fields_and_with_values_and_respectively( - String boolField, - String stringField, - String numberField, - String boolValue, - String stringValue, - int numberValue) { - Value value = client.getObjectValue(this.objectFlagKey, this.objectFlagDefaultValue); - Structure structure = value.asStructure(); - - assertEquals( - Boolean.valueOf(boolValue), structure.asMap().get(boolField).asBoolean()); - assertEquals(stringValue, structure.asMap().get(stringField).asString()); - assertEquals(numberValue, structure.asMap().get(numberField).asInteger()); - } - - /* - * Detailed evaluation - */ - - // boolean details - @When("a boolean flag with key {string} is evaluated with details and default value {string}") - public void a_boolean_flag_with_key_is_evaluated_with_details_and_default_value( - String flagKey, String defaultValue) { - this.booleanFlagKey = flagKey; - this.booleanFlagDefaultValue = Boolean.valueOf(defaultValue); - } - - @Then( - "the resolved boolean details value should be {string}, the variant should be {string}, and the reason should be {string}") - public void the_resolved_boolean_value_should_be_the_variant_should_be_and_the_reason_should_be( - String expectedValue, String expectedVariant, String expectedReason) { - FlagEvaluationDetails details = - client.getBooleanDetails(this.booleanFlagKey, Boolean.valueOf(this.booleanFlagDefaultValue)); - - assertEquals(Boolean.valueOf(expectedValue), details.getValue()); - assertEquals(expectedVariant, details.getVariant()); - assertEquals(expectedReason, details.getReason()); - } - - // string details - @When("a string flag with key {string} is evaluated with details and default value {string}") - public void a_string_flag_with_key_is_evaluated_with_details_and_default_value( - String flagKey, String defaultValue) { - this.stringFlagKey = flagKey; - this.stringFlagDefaultValue = defaultValue; - } - - @When("a string flag with key {string} is evaluated with details") - public void a_string_flag_with_key_is_evaluated_with_details(String flagKey) { - this.stringFlagKey = flagKey; - this.stringFlagDefaultValue = ""; - } - - @Then( - "the resolved string details value should be {string}, the variant should be {string}, and the reason should be {string}") - public void the_resolved_string_value_should_be_the_variant_should_be_and_the_reason_should_be( - String expectedValue, String expectedVariant, String expectedReason) { - FlagEvaluationDetails details = - client.getStringDetails(this.stringFlagKey, this.stringFlagDefaultValue); - - assertEquals(expectedValue, details.getValue()); - assertEquals(expectedVariant, details.getVariant()); - assertEquals(expectedReason, details.getReason()); - } - - // integer details - @When("an integer flag with key {string} is evaluated with details and default value {int}") - public void an_integer_flag_with_key_is_evaluated_with_details_and_default_value(String flagKey, int defaultValue) { - this.intFlagKey = flagKey; - this.intFlagDefaultValue = defaultValue; - } - - @Then( - "the resolved integer details value should be {int}, the variant should be {string}, and the reason should be {string}") - public void the_resolved_integer_value_should_be_the_variant_should_be_and_the_reason_should_be( - int expectedValue, String expectedVariant, String expectedReason) { - FlagEvaluationDetails details = client.getIntegerDetails(this.intFlagKey, this.intFlagDefaultValue); - - assertEquals(expectedValue, details.getValue()); - assertEquals(expectedVariant, details.getVariant()); - assertEquals(expectedReason, details.getReason()); - } - - // float/double details - @When("a float flag with key {string} is evaluated with details and default value {double}") - public void a_float_flag_with_key_is_evaluated_with_details_and_default_value(String flagKey, double defaultValue) { - this.doubleFlagKey = flagKey; - this.doubleFlagDefaultValue = defaultValue; - } - - @Then( - "the resolved float details value should be {double}, the variant should be {string}, and the reason should be {string}") - public void the_resolved_float_value_should_be_the_variant_should_be_and_the_reason_should_be( - double expectedValue, String expectedVariant, String expectedReason) { - FlagEvaluationDetails details = - client.getDoubleDetails(this.doubleFlagKey, this.doubleFlagDefaultValue); - - assertEquals(expectedValue, details.getValue()); - assertEquals(expectedVariant, details.getVariant()); - assertEquals(expectedReason, details.getReason()); - } - - // object details - @When("an object flag with key {string} is evaluated with details and a null default value") - public void an_object_flag_with_key_is_evaluated_with_details_and_a_null_default_value(String flagKey) { - this.objectFlagKey = flagKey; - this.objectFlagDefaultValue = new Value(); - } - - @Then( - "the resolved object details value should be contain fields {string}, {string}, and {string}, with values {string}, {string} and {int}, respectively") - public void the_resolved_object_value_should_be_contain_fields_and_with_values_and_respectively_again( - String boolField, - String stringField, - String numberField, - String boolValue, - String stringValue, - int numberValue) { - this.objectFlagDetails = client.getObjectDetails(this.objectFlagKey, this.objectFlagDefaultValue); - Structure structure = this.objectFlagDetails.getValue().asStructure(); - - assertEquals( - Boolean.valueOf(boolValue), structure.asMap().get(boolField).asBoolean()); - assertEquals(stringValue, structure.asMap().get(stringField).asString()); - assertEquals(numberValue, structure.asMap().get(numberField).asInteger()); - } - - @Then("the variant should be {string}, and the reason should be {string}") - public void the_variant_should_be_and_the_reason_should_be(String expectedVariant, String expectedReason) { - assertEquals(expectedVariant, this.objectFlagDetails.getVariant()); - assertEquals(expectedReason, this.objectFlagDetails.getReason()); - } - - /* - * Context-aware evaluation - */ - - @When( - "context contains keys {string}, {string}, {string}, {string} with values {string}, {string}, {int}, {string}") - public void context_contains_keys_with_values( - String field1, - String field2, - String field3, - String field4, - String value1, - String value2, - Integer value3, - String value4) { - Map attributes = new HashMap<>(); - attributes.put(field1, new Value(value1)); - attributes.put(field2, new Value(value2)); - attributes.put(field3, new Value(value3)); - attributes.put(field4, new Value(Boolean.valueOf(value4))); - this.context = new ImmutableContext(attributes); - } - - @When("a flag with key {string} is evaluated with default value {string}") - public void an_a_flag_with_key_is_evaluated(String flagKey, String defaultValue) { - contextAwareFlagKey = flagKey; - contextAwareDefaultValue = defaultValue; - contextAwareValue = client.getStringValue(flagKey, contextAwareDefaultValue, context); - } - - @Then("the resolved string response should be {string}") - public void the_resolved_string_response_should_be(String expected) { - assertEquals(expected, this.contextAwareValue); - } - - @Then("the resolved flag value is {string} when the context is empty") - public void the_resolved_flag_value_is_when_the_context_is_empty(String expected) { - String emptyContextValue = - client.getStringValue(contextAwareFlagKey, contextAwareDefaultValue, new ImmutableContext()); - assertEquals(expected, emptyContextValue); - } - - /* - * Errors - */ - - // not found - @When("a non-existent string flag with key {string} is evaluated with details and a default value {string}") - public void a_non_existent_string_flag_with_key_is_evaluated_with_details_and_a_default_value( - String flagKey, String defaultValue) { - notFoundFlagKey = flagKey; - notFoundDefaultValue = defaultValue; - notFoundDetails = client.getStringDetails(notFoundFlagKey, notFoundDefaultValue); - } - - @Then("the default string value should be returned") - public void then_the_default_string_value_should_be_returned() { - assertEquals(notFoundDefaultValue, notFoundDetails.getValue()); - } - - @Then("the reason should indicate an error and the error code should indicate a missing flag with {string}") - public void the_reason_should_indicate_an_error_and_the_error_code_should_be_flag_not_found(String errorCode) { - assertEquals(Reason.ERROR.toString(), notFoundDetails.getReason()); - assertEquals(errorCode, notFoundDetails.getErrorCode().toString()); - } - - // type mismatch - @When("a string flag with key {string} is evaluated as an integer, with details and a default value {int}") - public void a_string_flag_with_key_is_evaluated_as_an_integer_with_details_and_a_default_value( - String flagKey, int defaultValue) { - typeErrorFlagKey = flagKey; - typeErrorDefaultValue = defaultValue; - typeErrorDetails = client.getIntegerDetails(typeErrorFlagKey, typeErrorDefaultValue); - } - - @Then("the default integer value should be returned") - public void then_the_default_integer_value_should_be_returned() { - assertEquals(typeErrorDefaultValue, typeErrorDetails.getValue()); - } - - @Then("the reason should indicate an error and the error code should indicate a type mismatch with {string}") - public void the_reason_should_indicate_an_error_and_the_error_code_should_be_type_mismatch(String errorCode) { - assertEquals(Reason.ERROR.toString(), typeErrorDetails.getReason()); - assertEquals(errorCode, typeErrorDetails.getErrorCode().toString()); - } - - /* - * Custom JSON evaluators (only run for flagd-in-process) - */ - - @And("a context containing a nested property with outer key {string} and inner key {string}, with value {string}") - public void a_context_containing_a_nested_property_with_outer_key_and_inner_key_with_value( - String outerKey, String innerKey, String value) throws InstantiationException { - Map innerMap = new HashMap(); - innerMap.put(innerKey, new Value(value)); - Map outerMap = new HashMap(); - outerMap.put(outerKey, new Value(new ImmutableStructure(innerMap))); - this.context = new ImmutableContext(outerMap); - } - - @And("a context containing a nested property with outer key {string} and inner key {string}, with value {int}") - public void a_context_containing_a_nested_property_with_outer_key_and_inner_key_with_value_int( - String outerKey, String innerKey, Integer value) throws InstantiationException { - Map innerMap = new HashMap(); - innerMap.put(innerKey, new Value(value)); - Map outerMap = new HashMap(); - outerMap.put(outerKey, new Value(new ImmutableStructure(innerMap))); - this.context = new ImmutableContext(outerMap); - } - - @And("a context containing a key {string}, with value {string}") - public void a_context_containing_a_key_with_value(String key, String value) { - Map attrs = new HashMap(); - attrs.put(key, new Value(value)); - this.context = new ImmutableContext(attrs); - } - - @And("a context containing a key {string}, with value {double}") - public void a_context_containing_a_key_with_value_double(String key, Double value) { - Map attrs = new HashMap(); - attrs.put(key, new Value(value)); - this.context = new ImmutableContext(attrs); - } - - @Then("the returned value should be {string}") - public void the_returned_value_should_be(String expected) { - String value = client.getStringValue(this.stringFlagKey, this.stringFlagDefaultValue, this.context); - assertEquals(expected, value); - } - - @Then("the returned value should be {int}") - public void the_returned_value_should_be(Integer expectedValue) { - Integer value = client.getIntegerValue(this.intFlagKey, this.intFlagDefaultValue, this.context); - assertEquals(expectedValue, value); - } - - /* - * Events - */ - - // Flag change event - @When("a PROVIDER_CONFIGURATION_CHANGED handler is added") - public void a_provider_configuration_changed_handler_is_added() { - this.changeHandler = (EventDetails details) -> { - if (details.getFlagsChanged().size() > 0) { - // we get multiple change events from the test container... - // we're only interested in the ones with the changed flag in question - this.changedFlag = details.getFlagsChanged().get(0); - this.isChangeHandlerRun = true; - } - }; - client.onProviderConfigurationChanged(this.changeHandler); - } - - @When("a flag with key {string} is modified") - public void a_flag_with_key_is_modified(String flagKey) { - // This happens automatically - } - - @Then("the PROVIDER_CONFIGURATION_CHANGED handler must run") - public void the_provider_configuration_changed_handler_must_run() { - Awaitility.await().atMost(Duration.ofSeconds(2)).until(() -> { - return this.isChangeHandlerRun; - }); - } - - @Then("the event details must indicate {string} was altered") - public void the_event_details_must_indicate_was_altered(String flagKey) { - assertEquals(flagKey, this.changedFlag); - } - - // Provider ready event - @When("a PROVIDER_READY handler is added") - public void a_provider_ready_handler_is_added() { - this.readyHandler = (EventDetails details) -> { - this.isReadyHandlerRun = true; - }; - client.onProviderReady(this.readyHandler); - } - - @Then("the PROVIDER_READY handler must run") - public void the_provider_ready_handler_must_run() { - Awaitility.await().atMost(Duration.ofSeconds(2)).until(() -> { - return this.isReadyHandlerRun; - }); - } - - /* - * Zero Value - */ - - // boolean value - @When("a zero-value boolean flag with key {string} is evaluated with default value {string}") - public void a_zero_value_boolean_flag_with_key_is_evaluated_with_default_value( - String flagKey, String defaultValue) { - this.booleanFlagKey = flagKey; - this.booleanFlagDefaultValue = Boolean.valueOf(defaultValue); - } - - @Then("the resolved boolean zero-value should be {string}") - public void the_resolved_boolean_zero_value_should_be(String expected) { - boolean value = client.getBooleanValue(this.booleanFlagKey, this.booleanFlagDefaultValue); - assertEquals(Boolean.valueOf(expected), value); - } - - // float/double value - @When("a zero-value float flag with key {string} is evaluated with default value {double}") - public void a_zero_value_float_flag_with_key_is_evaluated_with_default_value(String flagKey, Double defaultValue) { - this.doubleFlagKey = flagKey; - this.doubleFlagDefaultValue = defaultValue; - } - - @Then("the resolved float zero-value should be {double}") - public void the_resolved_float_zero_value_should_be(Double expected) { - FlagEvaluationDetails details = client.getDoubleDetails("float-zero-flag", this.doubleFlagDefaultValue); - assertEquals(expected, details.getValue()); - } - - // integer value - @When("a zero-value integer flag with key {string} is evaluated with default value {int}") - public void a_zero_value_integer_flag_with_key_is_evaluated_with_default_value( - String flagKey, Integer defaultValue) { - this.intFlagKey = flagKey; - this.intFlagDefaultValue = defaultValue; - } - - @Then("the resolved integer zero-value should be {int}") - public void the_resolved_integer_zero_value_should_be(Integer expected) { - int value = client.getIntegerValue(this.intFlagKey, this.intFlagDefaultValue); - assertEquals(expected, value); - } - - // string value - @When("a zero-value string flag with key {string} is evaluated with default value {string}") - public void a_zero_value_string_flag_with_key_is_evaluated_with_default_value(String flagKey, String defaultValue) { - this.stringFlagKey = flagKey; - this.stringFlagDefaultValue = defaultValue; - } - - @Then("the resolved string zero-value should be {string}") - public void the_resolved_string_zero_value_should_be(String expected) { - String value = client.getStringValue(this.stringFlagKey, this.stringFlagDefaultValue); - assertEquals(expected, value); - } - - @When("a context containing a targeting key with value {string}") - public void a_context_containing_a_targeting_key_with_value(String targetingKey) { - this.context = new ImmutableContext(targetingKey); - } - - @Then("the returned reason should be {string}") - public void the_returned_reason_should_be(String reason) { - FlagEvaluationDetails details = - client.getStringDetails(this.stringFlagKey, this.stringFlagDefaultValue, this.context); - assertEquals(reason, details.getReason()); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Utils.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Utils.java new file mode 100644 index 000000000..909d4800a --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Utils.java @@ -0,0 +1,43 @@ +package dev.openfeature.contrib.providers.flagd.e2e.steps; + +import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.providers.flagd.resolver.grpc.cache.CacheType; +import dev.openfeature.sdk.Value; +import java.io.IOException; +import java.util.Objects; +import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; + +public final class Utils { + + private Utils() {} + + public static Object convert(String value, String type) throws ClassNotFoundException, IOException { + if (Objects.equals(value, "null")) return null; + switch (type) { + case "Boolean": + return Boolean.parseBoolean(value); + case "String": + return value; + case "Integer": + return Integer.parseInt(value); + case "Float": + return Double.parseDouble(value); + case "Long": + return Long.parseLong(value); + case "ResolverType": + switch (value.toLowerCase()) { + case "in-process": + return Config.Resolver.IN_PROCESS; + case "rpc": + return Config.Resolver.RPC; + default: + throw new RuntimeException("Unknown resolver type: " + value); + } + case "CacheType": + return CacheType.valueOf(value.toUpperCase()).getValue(); + case "Object": + return Value.objectToValue(new ObjectMapper().readValue(value, Object.class)); + } + throw new RuntimeException("Unknown config type: " + type); + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/common/GrpcConnectorTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/common/GrpcConnectorTest.java index 4c417f957..596042bb5 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/common/GrpcConnectorTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/common/GrpcConnectorTest.java @@ -70,44 +70,12 @@ private void tearDownGrpcServer() throws InterruptedException { } } - @Test - void whenShuttingDownAndRestartingGrpcServer_ConsumerReceivesDisconnectedAndConnectedEvent() throws Exception { - CountDownLatch sync = new CountDownLatch(2); - ArrayList connectionStateChanges = Lists.newArrayList(); - Consumer testConsumer = event -> { - connectionStateChanges.add(event.isConnected()); - sync.countDown(); - }; - - GrpcConnector instance = new GrpcConnector<>( - FlagdOptions.builder().build(), - ServiceGrpc::newStub, - ServiceGrpc::newBlockingStub, - testConsumer, - stub -> stub.eventStream(Evaluation.EventStreamRequest.getDefaultInstance(), mockEventStreamObserver), - testChannel); - - instance.initialize(); - - // when shutting down server - testServer.shutdown(); - testServer.awaitTermination(1, TimeUnit.SECONDS); - - // when restarting server - setupTestGrpcServer(); - - // then consumer received DISCONNECTED and CONNECTED event - boolean finished = sync.await(10, TimeUnit.SECONDS); - Assertions.assertTrue(finished); - Assertions.assertEquals(Lists.newArrayList(DISCONNECTED, CONNECTED), connectionStateChanges); - } - @Test void whenShuttingDownGrpcConnector_ConsumerReceivesDisconnectedEvent() throws Exception { CountDownLatch sync = new CountDownLatch(1); ArrayList connectionStateChanges = Lists.newArrayList(); - Consumer testConsumer = event -> { - connectionStateChanges.add(event.isConnected()); + Consumer testConsumer = event -> { + connectionStateChanges.add(!event.isDisconnected()); sync.countDown(); }; diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserverTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserverTest.java index 9370f821a..a4183deb7 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserverTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/EventStreamObserverTest.java @@ -1,19 +1,12 @@ package dev.openfeature.contrib.providers.flagd.resolver.grpc; -import static org.junit.Assert.assertTrue; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.atLeast; -import static org.mockito.Mockito.atMost; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.google.protobuf.Struct; -import com.google.protobuf.Value; -import dev.openfeature.contrib.providers.flagd.resolver.grpc.cache.Cache; import dev.openfeature.flagd.grpc.evaluation.Evaluation.EventStreamResponse; +import dev.openfeature.sdk.ProviderEvent; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -26,8 +19,7 @@ class EventStreamObserverTest { @Nested class StateChange { - Cache cache; - List states; + List states; EventStreamObserver stream; Runnable reconnect; Object sync; @@ -35,10 +27,10 @@ class StateChange { @BeforeEach void setUp() { states = new ArrayList<>(); - cache = mock(Cache.class); reconnect = mock(Runnable.class); - when(cache.getEnabled()).thenReturn(true); - stream = new EventStreamObserver(cache, (state, changed) -> states.add(state)); + stream = new EventStreamObserver( + (state) -> states.add(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED), + (state) -> states.add(state.getEvent())); } @Test @@ -50,42 +42,7 @@ public void change() { when(flagData.getFieldsMap()).thenReturn(new HashMap<>()); stream.onNext(resp); // we notify that we are ready - assertEquals(1, states.size()); - assertTrue(states.get(0)); - // we flush the cache - verify(cache, atLeast(1)).clear(); - } - - @Test - public void cacheBustingForKnownKeys() { - final String key1 = "myKey1"; - final String key2 = "myKey2"; - - EventStreamResponse resp = mock(EventStreamResponse.class); - Struct flagData = mock(Struct.class); - Value flagsValue = mock(Value.class); - Struct flagsStruct = mock(Struct.class); - HashMap fields = new HashMap<>(); - fields.put(Constants.FLAGS_KEY, flagsValue); - HashMap flags = new HashMap<>(); - flags.put(key1, null); - flags.put(key2, null); - - when(resp.getType()).thenReturn("configuration_change"); - when(resp.getData()).thenReturn(flagData); - when(flagData.getFieldsMap()).thenReturn(fields); - when(flagsValue.getStructValue()).thenReturn(flagsStruct); - when(flagsStruct.getFieldsMap()).thenReturn(flags); - - stream.onNext(resp); - // we notify that the configuration changed - assertEquals(1, states.size()); - assertTrue(states.get(0)); - // we did NOT flush the whole cache - verify(cache, atMost(0)).clear(); - // we only clean the two keys - verify(cache, times(1)).remove(eq(key1)); - verify(cache, times(1)).remove(eq(key2)); + assertThat(states).hasSize(1).contains(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED); } } } diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolverTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolverTest.java index 90295ef10..141f4a305 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolverTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolverTest.java @@ -20,7 +20,7 @@ import dev.openfeature.contrib.providers.flagd.Config; import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.resolver.common.ConnectionEvent; +import dev.openfeature.contrib.providers.flagd.resolver.common.FlagdProviderEvent; import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.MockConnector; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageState; @@ -88,7 +88,7 @@ void eventHandling() throws Throwable { InProcessResolver inProcessResolver = getInProcessResolverWith( new MockStorage(new HashMap<>(), sender), connectionEvent -> receiver.offer(new StorageStateChange( - connectionEvent.isConnected() ? StorageState.OK : StorageState.ERROR, + connectionEvent.isDisconnected() ? StorageState.ERROR : StorageState.OK, connectionEvent.getFlagsChanged(), connectionEvent.getSyncMetadata()))); @@ -517,25 +517,25 @@ void flagSetMetadataIsOverwrittenByFlagMetadataToEvaluation() throws Exception { private InProcessResolver getInProcessResolverWith(final FlagdOptions options, final MockStorage storage) throws NoSuchFieldException, IllegalAccessException { - final InProcessResolver resolver = new InProcessResolver(options, () -> true, connectionEvent -> {}); + final InProcessResolver resolver = new InProcessResolver(options, connectionEvent -> {}); return injectFlagStore(resolver, storage); } private InProcessResolver getInProcessResolverWith( - final MockStorage storage, final Consumer onConnectionEvent) + final MockStorage storage, final Consumer onConnectionEvent) throws NoSuchFieldException, IllegalAccessException { final InProcessResolver resolver = - new InProcessResolver(FlagdOptions.builder().deadline(1000).build(), () -> true, onConnectionEvent); + new InProcessResolver(FlagdOptions.builder().deadline(1000).build(), onConnectionEvent); return injectFlagStore(resolver, storage); } private InProcessResolver getInProcessResolverWith( - final MockStorage storage, final Consumer onConnectionEvent, String selector) + final MockStorage storage, final Consumer onConnectionEvent, String selector) throws NoSuchFieldException, IllegalAccessException { final InProcessResolver resolver = new InProcessResolver( - FlagdOptions.builder().selector(selector).deadline(1000).build(), () -> true, onConnectionEvent); + FlagdOptions.builder().selector(selector).deadline(1000).build(), onConnectionEvent); return injectFlagStore(resolver, storage); } diff --git a/providers/flagd/test-harness b/providers/flagd/test-harness index 8931c8645..fc7867922 160000 --- a/providers/flagd/test-harness +++ b/providers/flagd/test-harness @@ -1 +1 @@ -Subproject commit 8931c8645b8600e251d5e3ebbad42dff8ce4c78e +Subproject commit fc786792273b7984911dc3bcb7b47489f261ba57