diff --git a/detekt_custom_safe_calls.yml b/detekt_custom_safe_calls.yml index dc34e29760..dec5d49f37 100644 --- a/detekt_custom_safe_calls.yml +++ b/detekt_custom_safe_calls.yml @@ -449,6 +449,7 @@ datadog: - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.api.SdkCore?)" - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.api.feature.FeatureEventReceiver?)" - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.flags.internal.repository.DefaultFlagsRepository.FlagsState?)" + - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.flags.model.FlagsClientState?)" - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.flags.model.ProviderContext?)" - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.rum.internal.domain.RumContext?)" - "java.util.concurrent.atomic.AtomicReference.constructor(kotlin.collections.Map?)" @@ -1064,6 +1065,7 @@ datadog: - "kotlin.IllegalArgumentException(kotlin.String?)" - "kotlin.IllegalStateException(kotlin.String?)" - "kotlin.Throwable.constructor()" + - "kotlin.Throwable.constructor(kotlin.String?)" - "kotlin.Throwable.fillInStackTrace()" - "kotlin.Throwable.stackTraceToString()" - "kotlin.UnsupportedOperationException()" diff --git a/features/dd-sdk-android-flags/api/apiSurface b/features/dd-sdk-android-flags/api/apiSurface index 326ce2e2ea..e9f6e35603 100644 --- a/features/dd-sdk-android-flags/api/apiSurface +++ b/features/dd-sdk-android-flags/api/apiSurface @@ -8,6 +8,7 @@ interface com.datadog.android.flags.FlagsClient fun resolveIntValue(String, Int): Int fun resolveStructureValue(String, org.json.JSONObject): org.json.JSONObject fun resolve(String, T): com.datadog.android.flags.model.ResolutionDetails + val state: StateObservable class Builder constructor(String = DEFAULT_CLIENT_NAME, com.datadog.android.api.SdkCore = Datadog.getInstance()) fun build(): FlagsClient @@ -22,6 +23,12 @@ data class com.datadog.android.flags.FlagsConfiguration fun gracefulModeEnabled(Boolean): Builder fun build(): FlagsConfiguration companion object +interface com.datadog.android.flags.FlagsStateListener + fun onStateChanged(com.datadog.android.flags.model.FlagsClientState) +interface com.datadog.android.flags.StateObservable + fun getCurrentState(): com.datadog.android.flags.model.FlagsClientState + fun addListener(FlagsStateListener) + fun removeListener(FlagsStateListener) enum com.datadog.android.flags.model.ErrorCode - PROVIDER_NOT_READY - FLAG_NOT_FOUND @@ -29,6 +36,13 @@ enum com.datadog.android.flags.model.ErrorCode - TYPE_MISMATCH data class com.datadog.android.flags.model.EvaluationContext constructor(String, Map = emptyMap()) +sealed class com.datadog.android.flags.model.FlagsClientState + object NotReady : FlagsClientState + object Ready : FlagsClientState + object Reconciling : FlagsClientState + object Stale : FlagsClientState + data class Error : FlagsClientState + constructor(Throwable? = null) data class com.datadog.android.flags.model.ResolutionDetails constructor(T, String? = null, ResolutionReason? = null, ErrorCode? = null, String? = null, Map = emptyMap()) enum com.datadog.android.flags.model.ResolutionReason diff --git a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api index c29caad72e..8bc02440ed 100644 --- a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api +++ b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api @@ -11,6 +11,7 @@ public abstract interface class com/datadog/android/flags/FlagsClient { public static fun get ()Lcom/datadog/android/flags/FlagsClient; public static fun get (Ljava/lang/String;)Lcom/datadog/android/flags/FlagsClient; public static fun get (Ljava/lang/String;Lcom/datadog/android/api/SdkCore;)Lcom/datadog/android/flags/FlagsClient; + public abstract fun getState ()Lcom/datadog/android/flags/StateObservable; public abstract fun resolve (Ljava/lang/String;Ljava/lang/Object;)Lcom/datadog/android/flags/model/ResolutionDetails; public abstract fun resolveBooleanValue (Ljava/lang/String;Z)Z public abstract fun resolveDoubleValue (Ljava/lang/String;D)D @@ -55,6 +56,16 @@ public final class com/datadog/android/flags/FlagsConfiguration$Builder { public final class com/datadog/android/flags/FlagsConfiguration$Companion { } +public abstract interface class com/datadog/android/flags/FlagsStateListener { + public abstract fun onStateChanged (Lcom/datadog/android/flags/model/FlagsClientState;)V +} + +public abstract interface class com/datadog/android/flags/StateObservable { + public abstract fun addListener (Lcom/datadog/android/flags/FlagsStateListener;)V + public abstract fun getCurrentState ()Lcom/datadog/android/flags/model/FlagsClientState; + public abstract fun removeListener (Lcom/datadog/android/flags/FlagsStateListener;)V +} + public final class com/datadog/android/flags/model/ErrorCode : java/lang/Enum { public static final field FLAG_NOT_FOUND Lcom/datadog/android/flags/model/ErrorCode; public static final field PARSE_ERROR Lcom/datadog/android/flags/model/ErrorCode; @@ -170,6 +181,38 @@ public final class com/datadog/android/flags/model/ExposureEvent$Subject$Compani public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/ExposureEvent$Subject; } +public abstract class com/datadog/android/flags/model/FlagsClientState { +} + +public final class com/datadog/android/flags/model/FlagsClientState$Error : com/datadog/android/flags/model/FlagsClientState { + public fun ()V + public fun (Ljava/lang/Throwable;)V + public synthetic fun (Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Throwable; + public final fun copy (Ljava/lang/Throwable;)Lcom/datadog/android/flags/model/FlagsClientState$Error; + public static synthetic fun copy$default (Lcom/datadog/android/flags/model/FlagsClientState$Error;Ljava/lang/Throwable;ILjava/lang/Object;)Lcom/datadog/android/flags/model/FlagsClientState$Error; + public fun equals (Ljava/lang/Object;)Z + public final fun getError ()Ljava/lang/Throwable; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/flags/model/FlagsClientState$NotReady : com/datadog/android/flags/model/FlagsClientState { + public static final field INSTANCE Lcom/datadog/android/flags/model/FlagsClientState$NotReady; +} + +public final class com/datadog/android/flags/model/FlagsClientState$Ready : com/datadog/android/flags/model/FlagsClientState { + public static final field INSTANCE Lcom/datadog/android/flags/model/FlagsClientState$Ready; +} + +public final class com/datadog/android/flags/model/FlagsClientState$Reconciling : com/datadog/android/flags/model/FlagsClientState { + public static final field INSTANCE Lcom/datadog/android/flags/model/FlagsClientState$Reconciling; +} + +public final class com/datadog/android/flags/model/FlagsClientState$Stale : com/datadog/android/flags/model/FlagsClientState { + public static final field INSTANCE Lcom/datadog/android/flags/model/FlagsClientState$Stale; +} + public final class com/datadog/android/flags/model/ResolutionDetails { public fun (Ljava/lang/Object;Ljava/lang/String;Lcom/datadog/android/flags/model/ResolutionReason;Lcom/datadog/android/flags/model/ErrorCode;Ljava/lang/String;Ljava/util/Map;)V public synthetic fun (Ljava/lang/Object;Ljava/lang/String;Lcom/datadog/android/flags/model/ResolutionReason;Lcom/datadog/android/flags/model/ErrorCode;Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index 32ee125faa..b7c49aefba 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -16,6 +16,7 @@ import com.datadog.android.core.InternalSdkCore import com.datadog.android.flags.internal.DatadogFlagsClient import com.datadog.android.flags.internal.DefaultRumEvaluationLogger import com.datadog.android.flags.internal.FlagsFeature +import com.datadog.android.flags.internal.FlagsStateManager import com.datadog.android.flags.internal.LogWithPolicy import com.datadog.android.flags.internal.NoOpFlagsClient import com.datadog.android.flags.internal.NoOpRumEvaluationLogger @@ -28,6 +29,7 @@ import com.datadog.android.flags.internal.repository.NoOpFlagsRepository import com.datadog.android.flags.internal.repository.net.PrecomputeMapper import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.ResolutionDetails +import com.datadog.android.internal.utils.DDCoreSubscription import org.json.JSONObject /** @@ -148,6 +150,24 @@ interface FlagsClient { */ fun resolve(flagKey: String, defaultValue: T): ResolutionDetails + /** + * Observable interface for tracking client state changes. + * + * Provides three ways to observe state: + * - Synchronous: [StateObservable.getCurrentState] for immediate queries (Java-friendly) + * - Callback: [StateObservable.addListener] for traditional observers (Java-friendly) + * + * Example: + * ```kotlin + * // Synchronous + * val current = client.state.getCurrentState() + * + * // Callback + * client.state.addListener(listener) + * ``` + */ + val state: StateObservable + /** * Builder for creating [FlagsClient] instances with custom configuration. * @@ -338,7 +358,8 @@ interface FlagsClient { // region Internal - internal const val FLAGS_CLIENT_EXECUTOR_NAME = "flags-client-executor" + internal const val FLAGS_NETWORK_EXECUTOR_NAME = "flags-network" + internal const val FLAGS_STATE_NOTIFICATION_EXECUTOR_NAME = "flags-state-notifications" @Suppress("LongMethod") internal fun createInternal( @@ -347,8 +368,11 @@ interface FlagsClient { flagsFeature: FlagsFeature, name: String ): FlagsClient { - val executorService = featureSdkCore.createSingleThreadExecutorService( - executorContext = FLAGS_CLIENT_EXECUTOR_NAME + val networkExecutorService = featureSdkCore.createSingleThreadExecutorService( + executorContext = FLAGS_NETWORK_EXECUTOR_NAME + ) + val stateNotificationExecutorService = featureSdkCore.createSingleThreadExecutorService( + executorContext = FLAGS_STATE_NOTIFICATION_EXECUTOR_NAME ) val datadogContext = (featureSdkCore as InternalSdkCore).getDatadogContext() @@ -400,12 +424,19 @@ interface FlagsClient { val precomputeMapper = PrecomputeMapper(featureSdkCore.internalLogger) + val flagStateManager = FlagsStateManager( + DDCoreSubscription.create(), + stateNotificationExecutorService, + featureSdkCore.internalLogger + ) + val evaluationsManager = EvaluationsManager( - executorService = executorService, + executorService = networkExecutorService, internalLogger = featureSdkCore.internalLogger, flagsRepository = flagsRepository, assignmentsReader = assignmentsDownloader, - precomputeMapper = precomputeMapper + precomputeMapper = precomputeMapper, + flagStateManager = flagStateManager ) val rumEvaluationLogger = createRumEvaluationLogger(featureSdkCore) @@ -416,7 +447,8 @@ interface FlagsClient { flagsRepository = flagsRepository, flagsConfiguration = configuration, rumEvaluationLogger = rumEvaluationLogger, - processor = flagsFeature.processor + processor = flagsFeature.processor, + flagStateManager = flagStateManager ) } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateListener.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateListener.kt new file mode 100644 index 0000000000..7cb15873d3 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateListener.kt @@ -0,0 +1,25 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.flags + +import com.datadog.android.flags.model.FlagsClientState + +/** + * Listener interface for receiving state change notifications from a [FlagsClient]. + * + * Implementations of this interface can be registered with a [FlagsClient] to receive + * callbacks whenever the client's state changes. + */ +interface FlagsStateListener { + /** + * Called when the state of the [FlagsClient] changes. + * + * @param newState The new state of the client. If the state is [FlagsClientState.Error], + * the error details are contained within the state object itself. + */ + fun onStateChanged(newState: FlagsClientState) +} diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/StateObservable.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/StateObservable.kt new file mode 100644 index 0000000000..efd5035c21 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/StateObservable.kt @@ -0,0 +1,61 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.flags + +import com.datadog.android.flags.model.FlagsClientState + +/** + * Observable interface for tracking [FlagsClient] state changes. + * + * This interface provides two ways to observe state: + * 1. **Synchronous getter**: [getCurrentState] for immediate state queries + * 2. **Callback pattern**: [addListener]/[removeListener] for reactive observers + * + * ## Usage Examples + * + * ```kotlin + * // Synchronous getter + * val current = client.state.getCurrentState() + * if (current is FlagsClientState.Ready) { + * // Proceed + * } + * + * // Callback pattern + * client.state.addListener(object : FlagsStateListener { + * override fun onStateChanged(newState: FlagsClientState) { + * // Handle state change + * } + * }) + * ``` + */ +interface StateObservable { + /** + * Returns the current state synchronously. + * + * This method is safe to call from any thread. + * + * @return The current [FlagsClientState]. + */ + fun getCurrentState(): FlagsClientState + + /** + * Registers a listener to receive state change notifications. + * + * The listener will immediately receive the current state upon registration, + * then be notified of all future state changes. + * + * @param listener The [FlagsStateListener] to register. + */ + fun addListener(listener: FlagsStateListener) + + /** + * Unregisters a previously registered state listener. + * + * @param listener The [FlagsStateListener] to unregister. + */ + fun removeListener(listener: FlagsStateListener) +} diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt index 831c08a3f7..281367cb93 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt @@ -10,6 +10,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.flags.FlagsClient import com.datadog.android.flags.FlagsConfiguration +import com.datadog.android.flags.StateObservable import com.datadog.android.flags.internal.evaluation.EvaluationsManager import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.repository.FlagsRepository @@ -35,6 +36,7 @@ import org.json.JSONObject * @param flagsConfiguration configuration for the flags feature * @param rumEvaluationLogger responsible for sending flag evaluations to RUM. * @param processor responsible for writing exposure batches to be sent to flags backend. + * @param flagStateManager channel for managing state change listeners */ @Suppress("TooManyFunctions") // All functions are necessary for flag evaluation lifecycle internal class DatadogFlagsClient( @@ -43,9 +45,12 @@ internal class DatadogFlagsClient( private val flagsRepository: FlagsRepository, private val flagsConfiguration: FlagsConfiguration, private val rumEvaluationLogger: RumEvaluationLogger, - private val processor: EventsProcessor + private val processor: EventsProcessor, + private val flagStateManager: FlagsStateManager ) : FlagsClient { + override val state: StateObservable = flagStateManager + // region FlagsClient /** diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt new file mode 100644 index 0000000000..5aa24aac11 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt @@ -0,0 +1,91 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.flags.internal + +import com.datadog.android.api.InternalLogger +import com.datadog.android.core.internal.utils.executeSafe +import com.datadog.android.flags.FlagsStateListener +import com.datadog.android.flags.StateObservable +import com.datadog.android.flags.model.FlagsClientState +import com.datadog.android.internal.utils.DDCoreSubscription +import java.util.concurrent.ExecutorService + +/** + * Manages state transitions and notifications for a [com.datadog.android.flags.FlagsClient]. + * + * This class handles state change notifications to registered listeners. All notification + * methods are thread-safe and guarantee ordered delivery to listeners by using a + * single-threaded executor service. + * + * The current state is stored and emitted to new listeners immediately upon registration, + * ensuring every listener receives the current state. + * + * @param subscription the underlying subscription for managing listeners + * @param executorService single-threaded executor for ordered state notification delivery + * @param internalLogger logger for error and debug messages + */ +internal class FlagsStateManager( + private val subscription: DDCoreSubscription, + private val executorService: ExecutorService, + private val internalLogger: InternalLogger +) : StateObservable { + /** + * The current state of the client. + * Thread-safe: uses volatile for visibility across threads. + */ + @Volatile + private var currentState: FlagsClientState = FlagsClientState.NotReady + + /** + * Returns the current state synchronously. + * + * @return The current [FlagsClientState]. + */ + override fun getCurrentState(): FlagsClientState = currentState + + /** + * Updates the state and notifies all listeners. + * + * This method stores the new state and asynchronously notifies all registered listeners + * on the executor service, ensuring ordered delivery. + * + * @param newState The new state to transition to. + */ + internal fun updateState(newState: FlagsClientState) { + executorService.executeSafe( + operationName = UPDATE_STATE_OPERATION_NAME, + internalLogger = internalLogger + ) { + currentState = newState + subscription.notifyListeners { + onStateChanged(newState) + } + } + } + + override fun addListener(listener: FlagsStateListener) { + subscription.addListener(listener) + + // Emit current state to new listener + executorService.executeSafe( + operationName = NOTIFY_NEW_LISTENER_OPERATION_NAME, + internalLogger = internalLogger + ) { + val state = currentState + listener.onStateChanged(state) + } + } + + override fun removeListener(listener: FlagsStateListener) { + subscription.removeListener(listener) + } + + companion object { + private const val UPDATE_STATE_OPERATION_NAME = "Update flags client state" + private const val NOTIFY_NEW_LISTENER_OPERATION_NAME = "Notify new listener of current flags state" + } +} diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt index d44571304c..e58d37ac03 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt @@ -8,8 +8,11 @@ package com.datadog.android.flags.internal import com.datadog.android.api.InternalLogger import com.datadog.android.flags.FlagsClient +import com.datadog.android.flags.FlagsStateListener +import com.datadog.android.flags.StateObservable import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionDetails import org.json.JSONObject @@ -32,6 +35,12 @@ internal class NoOpFlagsClient( private val logWithPolicy: LogWithPolicy ) : FlagsClient { + override val state: StateObservable = object : StateObservable { + override fun getCurrentState(): FlagsClientState = FlagsClientState.Error(null) + override fun addListener(listener: FlagsStateListener) = Unit + override fun removeListener(listener: FlagsStateListener) = Unit + } + /** * No-op implementation that ignores context updates and logs a warning. * @param context Ignored evaluation context. diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt index 8ce7ea9289..fbf0b85ffb 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt @@ -8,10 +8,12 @@ package com.datadog.android.flags.internal.evaluation import com.datadog.android.api.InternalLogger import com.datadog.android.core.internal.utils.executeSafe +import com.datadog.android.flags.internal.FlagsStateManager import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader import com.datadog.android.flags.internal.repository.FlagsRepository import com.datadog.android.flags.internal.repository.net.PrecomputeMapper import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState import java.util.concurrent.ExecutorService /** @@ -26,13 +28,15 @@ import java.util.concurrent.ExecutorService * @param flagsRepository local storage for flag data and evaluation context * @param assignmentsReader handles reading assignments for the context. * @param precomputeMapper transforms network responses into internal flag format + * @param flagStateManager channel for notifying state change listeners */ internal class EvaluationsManager( private val executorService: ExecutorService, private val internalLogger: InternalLogger, private val flagsRepository: FlagsRepository, private val assignmentsReader: PrecomputedAssignmentsReader, - private val precomputeMapper: PrecomputeMapper + private val precomputeMapper: PrecomputeMapper, + private val flagStateManager: FlagsStateManager ) { /** * Processes a new evaluation context by fetching flags and storing atomically. @@ -48,6 +52,8 @@ internal class EvaluationsManager( * a valid targeting key. */ fun updateEvaluationsForContext(context: EvaluationContext) { + flagStateManager.updateState(FlagsClientState.Reconciling) + executorService.executeSafe( operationName = FETCH_AND_STORE_OPERATION_NAME, internalLogger = internalLogger @@ -58,24 +64,31 @@ internal class EvaluationsManager( { "Processing evaluation context: ${context.targetingKey}" } ) + val hadFlags = flagsRepository.hasFlags() val response = assignmentsReader.readPrecomputedFlags(context) - val flagsMap = if (response != null) { - precomputeMapper.map(response) + if (response != null) { + val flagsMap = precomputeMapper.map(response) + flagsRepository.setFlagsAndContext(context, flagsMap) + internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { "Successfully processed context ${context.targetingKey} with ${flagsMap.size} flags" } + ) + + flagStateManager.updateState(FlagsClientState.Ready) } else { internalLogger.log( InternalLogger.Level.WARN, InternalLogger.Target.USER, { NETWORK_REQUEST_FAILED_MESSAGE } ) - emptyMap() - } - flagsRepository.setFlagsAndContext(context, flagsMap) - internalLogger.log( - InternalLogger.Level.DEBUG, - InternalLogger.Target.MAINTAINER, - { "Successfully processed context ${context.targetingKey} with ${flagsMap.size} flags" } - ) + if (hadFlags) { + flagStateManager.updateState(FlagsClientState.Stale) + } else { + flagStateManager.updateState(FlagsClientState.Error(Throwable(NETWORK_REQUEST_FAILED_MESSAGE))) + } + } } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt index 404aa82cf4..376e28f67e 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt @@ -87,6 +87,8 @@ internal class DefaultFlagsRepository( return atomicState.get()?.context } + override fun hasFlags(): Boolean = atomicState.get()?.flags?.isNotEmpty() ?: false + @Suppress("ReturnCount") override fun getPrecomputedFlagWithContext(key: String): Pair? { waitForPersistenceLoad() diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt index 77dbea779b..09165afe5f 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt @@ -16,4 +16,5 @@ internal interface FlagsRepository { fun getEvaluationContext(): EvaluationContext? fun setFlagsAndContext(context: EvaluationContext, flags: Map) fun getPrecomputedFlagWithContext(key: String): Pair? + fun hasFlags(): Boolean } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/FlagsClientState.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/FlagsClientState.kt new file mode 100644 index 0000000000..187275a6b5 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/FlagsClientState.kt @@ -0,0 +1,44 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.flags.model + +/** + * Represents the current state of a [com.datadog.android.flags.FlagsClient]. + */ +sealed class FlagsClientState { + /** + * The client has been created but no evaluation context has been set. + * No flags are available for evaluation in this state. + */ + object NotReady : FlagsClientState() + + /** + * The client has successfully loaded flags and they are available for evaluation. + * This is the normal operational state. + */ + object Ready : FlagsClientState() + + /** + * The client is currently fetching new flags for a context change. + * Cached flags may still be available for evaluation during this state. + */ + object Reconciling : FlagsClientState() + + /** + * The client is currently stale. + * Cached flags may still be available for evaluation during this state. + */ + object Stale : FlagsClientState() + + /** + * An unrecoverable error has occurred. + * The client cannot provide flag evaluations in this state. + * + * @param error The error that caused the transition to this state, or null if unknown. + */ + data class Error(val error: Throwable? = null) : FlagsClientState() +} diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt index 05d6a0d608..13b50a14a4 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt @@ -12,7 +12,6 @@ import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.Feature.Companion.FLAGS_FEATURE_NAME import com.datadog.android.api.feature.Feature.Companion.RUM_FEATURE_NAME import com.datadog.android.core.InternalSdkCore -import com.datadog.android.flags.FlagsClient.Companion.FLAGS_CLIENT_EXECUTOR_NAME import com.datadog.android.flags.internal.FlagsFeature import com.datadog.android.flags.utils.forge.ForgeConfigurator import fr.xgouchet.elmyr.annotation.StringForgery @@ -25,6 +24,7 @@ import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock @@ -59,8 +59,7 @@ internal class FlagsTest { @BeforeEach fun `set up`() { whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger - whenever(mockSdkCore.createSingleThreadExecutorService(FLAGS_CLIENT_EXECUTOR_NAME)) doReturn - mockExecutorService + whenever(mockSdkCore.createSingleThreadExecutorService(any())) doReturn mockExecutorService whenever(mockDatadogContext.clientToken) doReturn fakeClientToken whenever(mockDatadogContext.site) doReturn DatadogSite.US1 diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt index dbfa04f9c8..0dec102441 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt @@ -11,6 +11,7 @@ import com.datadog.android.flags.internal.LogWithPolicy import com.datadog.android.flags.internal.NoOpFlagsClient import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -25,7 +26,9 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.argThat import org.mockito.kotlin.eq +import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.quality.Strictness @ExtendWith(MockitoExtension::class, ForgeExtension::class) @@ -425,4 +428,43 @@ internal class NoOpFlagsClientTest { } // endregion + + // region State Management + + @Test + fun `M return error state W state_getCurrentState()`() { + // When + val state = testedClient.state.getCurrentState() + + // Then + assertThat(state).isInstanceOf(FlagsClientState.Error::class.java) + } + + @Test + fun `M do nothing W state_addListener()`() { + // Given + val mockListener = mock() + + // When + testedClient.state.addListener(mockListener) + + // Then + // No exception should be thrown, method should be no-op + verifyNoInteractions(mockListener) + } + + @Test + fun `M do nothing W state_removeListener()`() { + // Given + val mockListener = mock() + + // When + testedClient.state.removeListener(mockListener) + + // Then + // No exception should be thrown, method should be no-op + verifyNoInteractions(mockListener) + } + + // endregion } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index bc72f5a069..dd56a7e10e 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -10,6 +10,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.Feature.Companion.RUM_FEATURE_NAME import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.flags.FlagsConfiguration +import com.datadog.android.flags.FlagsStateListener import com.datadog.android.flags.internal.evaluation.EvaluationsManager import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.model.VariationType @@ -40,6 +41,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness +import java.util.concurrent.ExecutorService @Extensions( ExtendWith(MockitoExtension::class), @@ -67,6 +69,12 @@ internal class DatadogFlagsClientTest { @Mock lateinit var mockRumEvaluationLogger: RumEvaluationLogger + @Mock + lateinit var mockExecutorService: ExecutorService + + @Mock + lateinit var mockFlagsStateManager: FlagsStateManager + private lateinit var testedClient: DatadogFlagsClient @StringForgery @@ -80,6 +88,12 @@ internal class DatadogFlagsClientTest { whenever(mockFeatureSdkCore.internalLogger) doReturn mockInternalLogger whenever(mockFeatureSdkCore.getFeature(RUM_FEATURE_NAME)) doReturn mock() + // Mock executor to run tasks synchronously for testing + whenever(mockExecutorService.execute(any())).thenAnswer { invocation -> + val runnable = invocation.getArgument(0) + runnable.run() + } + // Mock evaluation context as ready by default // Tests that need to test "not ready" state should override this val defaultContext = EvaluationContext( @@ -97,7 +111,8 @@ internal class DatadogFlagsClientTest { rumIntegrationEnabled = true ), rumEvaluationLogger = mockRumEvaluationLogger, - processor = mockProcessor + processor = mockProcessor, + flagStateManager = mockFlagsStateManager ) } @@ -638,7 +653,8 @@ internal class DatadogFlagsClientTest { flagsRepository = customRepository, flagsConfiguration = forge.getForgery(), rumEvaluationLogger = mockRumEvaluationLogger, - processor = mockProcessor + processor = mockProcessor, + flagStateManager = mockFlagsStateManager ) // When @@ -937,7 +953,8 @@ internal class DatadogFlagsClientTest { rumIntegrationEnabled = false ), rumEvaluationLogger = mockRumEvaluationLogger, - processor = mockProcessor + processor = mockProcessor, + flagStateManager = mockFlagsStateManager ) // When @@ -1041,7 +1058,8 @@ internal class DatadogFlagsClientTest { rumIntegrationEnabled = false ), rumEvaluationLogger = mockRumEvaluationLogger, - processor = mockProcessor + processor = mockProcessor, + flagStateManager = mockFlagsStateManager ) whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn (fakeFlag to fakeEvaluationContext) @@ -1292,4 +1310,37 @@ internal class DatadogFlagsClientTest { } // endregion + + // region State Management + + @Test + fun `M delegate to state manager W state_addListener()`() { + // Given + val mockListener = mock() + + // When + testedClient.state.addListener(mockListener) + + // Then + // Verify delegation to state manager + verify(mockFlagsStateManager).addListener(mockListener) + } + + @Test + fun `M delegate to state manager W state_removeListener()`() { + // Given + val mockListener = mock() + + // When + testedClient.state.removeListener(mockListener) + + // Then + // Verify delegation to state manager + verify(mockFlagsStateManager).removeListener(mockListener) + } + + // Note: updateState() tests removed - this is now an internal method called only by + // EvaluationsManager. State notification testing is covered in FlagsStateManagerTest. + + // endregion } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt new file mode 100644 index 0000000000..ff5e02267b --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt @@ -0,0 +1,155 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.flags.internal + +import com.datadog.android.api.InternalLogger +import com.datadog.android.flags.FlagsStateListener +import com.datadog.android.flags.model.FlagsClientState +import com.datadog.android.internal.utils.DDCoreSubscription +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.concurrent.ExecutorService +import java.util.stream.Stream + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class FlagsStateManagerTest { + + @Mock + lateinit var mockListener: FlagsStateListener + + @Mock + lateinit var mockExecutorService: ExecutorService + + @Mock + lateinit var mockInternalLogger: InternalLogger + + private lateinit var testedManager: FlagsStateManager + + @BeforeEach + fun `set up`() { + // Mock executor to run tasks synchronously for testing + whenever(mockExecutorService.execute(any())).thenAnswer { invocation -> + val runnable = invocation.getArgument(0) + runnable.run() + } + + testedManager = FlagsStateManager( + DDCoreSubscription.create(), + mockExecutorService, + mockInternalLogger + ) + } + + // region updateState + + @ParameterizedTest + @MethodSource("allStates") + fun `M notify listeners with state W updateState(state)`(state: FlagsClientState) { + // Given + testedManager.addListener(mockListener) + + // When + testedManager.updateState(state) + + // Then + if (state == FlagsClientState.NotReady) { + // Special case: NotReady is both initial state and transition state + verify(mockListener, times(2)).onStateChanged(FlagsClientState.NotReady) + } else { + inOrder(mockListener) { + verify(mockListener).onStateChanged(FlagsClientState.NotReady) // Initial state on add + verify(mockListener).onStateChanged(state) // State change + } + } + } + + // endregion + + // region addListener / removeListener + + @Test + fun `M not notify listener after removal W removeListener() and notify`() { + // Given + testedManager.addListener(mockListener) + // Verify initial state was emitted + verify(mockListener).onStateChanged(FlagsClientState.NotReady) + + testedManager.removeListener(mockListener) + + // When + testedManager.updateState(FlagsClientState.Ready) + + // Then - no further notifications after removal + verifyNoMoreInteractions(mockListener) + } + + @Test + fun `M notify all listeners W multiple listeners registered`() { + // Given + val mockListener2 = mock() + testedManager.addListener(mockListener) + testedManager.addListener(mockListener2) + + // When + testedManager.updateState(FlagsClientState.Ready) + + // Then + // Both listeners get current state on add, then Ready notification + inOrder(mockListener, mockListener2) { + verify(mockListener).onStateChanged(FlagsClientState.NotReady) + verify(mockListener2).onStateChanged(FlagsClientState.NotReady) + verify(mockListener).onStateChanged(FlagsClientState.Ready) + verify(mockListener2).onStateChanged(FlagsClientState.Ready) + } + } + + @Test + fun `M notify listeners in order W multiple state transitions`() { + // Given + testedManager.addListener(mockListener) + + // When + testedManager.updateState(FlagsClientState.Reconciling) + testedManager.updateState(FlagsClientState.Ready) + + // Then + inOrder(mockListener) { + verify(mockListener).onStateChanged(FlagsClientState.NotReady) // Initial on add + verify(mockListener).onStateChanged(FlagsClientState.Reconciling) // Transition + verify(mockListener).onStateChanged(FlagsClientState.Ready) // Transition + } + } + + // endregion + + companion object { + @JvmStatic + fun allStates(): Stream = Stream.of( + FlagsClientState.NotReady, + FlagsClientState.Ready, + FlagsClientState.Reconciling, + FlagsClientState.Stale, + FlagsClientState.Error(null), + FlagsClientState.Error(RuntimeException("Test error")) + ) + } +} diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt index 44469165ce..4d5b2ac0d4 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt @@ -7,11 +7,13 @@ package com.datadog.android.flags.internal.evaluation import com.datadog.android.api.InternalLogger +import com.datadog.android.flags.internal.FlagsStateManager import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader import com.datadog.android.flags.internal.repository.FlagsRepository import com.datadog.android.flags.internal.repository.net.PrecomputeMapper import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension import okhttp3.mockwebserver.MockWebServer @@ -28,6 +30,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -53,6 +56,9 @@ internal class EvaluationsManagerTest { @Mock lateinit var mockPrecomputeMapper: PrecomputeMapper + @Mock + lateinit var mockFlagsStateManager: FlagsStateManager + @StringForgery lateinit var fakeTargetingKey: String @@ -75,7 +81,8 @@ internal class EvaluationsManagerTest { internalLogger = mockInternalLogger, flagsRepository = mockFlagsRepository, assignmentsReader = mockAssignmentsDownloader, - precomputeMapper = mockPrecomputeMapper + precomputeMapper = mockPrecomputeMapper, + flagStateManager = mockFlagsStateManager ) // Mock executor to run tasks synchronously for testing @@ -158,8 +165,8 @@ internal class EvaluationsManagerTest { evaluationsManager.updateEvaluationsForContext(context) // Then - // When response is null, flags are not updated - only debug log is emitted - verify(mockInternalLogger, times(2)).log( + // When response is null, only 1 debug log (processing start) and 1 warn log (failure) + verify(mockInternalLogger, times(1)).log( eq(InternalLogger.Level.DEBUG), eq(InternalLogger.Target.MAINTAINER), any<() -> String>(), @@ -207,7 +214,7 @@ internal class EvaluationsManagerTest { // Then val logCaptor = argumentCaptor<() -> String>() - verify(mockInternalLogger, times(2)).log( + verify(mockInternalLogger, times(1)).log( eq(InternalLogger.Level.DEBUG), eq(InternalLogger.Target.MAINTAINER), logCaptor.capture(), @@ -218,4 +225,64 @@ internal class EvaluationsManagerTest { assertThat(logCaptor.firstValue.invoke()).contains("Processing evaluation context: $fakeTargetingKey") } + + // region State Transitions + + @Test + fun `M notify RECONCILING then READY W updateEvaluationsForContext() { successful fetch }`() { + // Given + val publicContext = EvaluationContext(fakeTargetingKey, emptyMap()) + val mockResponse = "{}" + val expectedFlags = mapOf() + + whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext)).thenReturn(mockResponse) + whenever(mockPrecomputeMapper.map(mockResponse)).thenReturn(expectedFlags) + + // When + evaluationsManager.updateEvaluationsForContext(publicContext) + + // Then + inOrder(mockFlagsStateManager) { + verify(mockFlagsStateManager).updateState(FlagsClientState.Reconciling) + verify(mockFlagsStateManager).updateState(FlagsClientState.Ready) + } + } + + @Test + fun `M notify RECONCILING then ERROR W updateEvaluationsForContext() { network failure, no previous flags }`() { + // Given + val publicContext = EvaluationContext(fakeTargetingKey, emptyMap()) + + whenever(mockFlagsRepository.hasFlags()).thenReturn(false) + whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext)).thenReturn(null) + + // When + evaluationsManager.updateEvaluationsForContext(publicContext) + + // Then + inOrder(mockFlagsStateManager) { + verify(mockFlagsStateManager).updateState(FlagsClientState.Reconciling) + verify(mockFlagsStateManager).updateState(org.mockito.kotlin.argThat { this is FlagsClientState.Error }) + } + } + + @Test + fun `M notify RECONCILING then STALE W updateEvaluationsForContext() { network failure, has previous flags }`() { + // Given + val publicContext = EvaluationContext(fakeTargetingKey, emptyMap()) + + whenever(mockFlagsRepository.hasFlags()).thenReturn(true) + whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext)).thenReturn(null) + + // When + evaluationsManager.updateEvaluationsForContext(publicContext) + + // Then + inOrder(mockFlagsStateManager) { + verify(mockFlagsStateManager).updateState(FlagsClientState.Reconciling) + verify(mockFlagsStateManager).updateState(FlagsClientState.Stale) + } + } + + // endregion } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt index 1f7ae7ad4e..205b15b9e3 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt @@ -52,8 +52,12 @@ internal class DefaultFlagsRepositoryTest { private lateinit var testedRepository: DefaultFlagsRepository + private lateinit var testContext: EvaluationContext + private lateinit var singleFlagMap: Map + private lateinit var multipleFlagsMap: Map + @BeforeEach - fun `set up`() { + fun `set up`(forge: Forge) { whenever(mockFeatureSdkCore.internalLogger) doReturn mockInternalLogger whenever( mockDataStore.value( @@ -73,6 +77,42 @@ internal class DefaultFlagsRepositoryTest { dataStore = mockDataStore, instanceName = "default" ) + + // Setup test fixtures for hasFlags tests + testContext = EvaluationContext(forge.anAlphabeticalString(), emptyMap()) + + singleFlagMap = mapOf( + forge.anAlphabeticalString() to PrecomputedFlag( + variationType = "string", + variationValue = forge.anAlphabeticalString(), + doLog = false, + allocationKey = forge.anAlphabeticalString(), + variationKey = forge.anAlphabeticalString(), + extraLogging = JSONObject(), + reason = "DEFAULT" + ) + ) + + multipleFlagsMap = mapOf( + forge.anAlphabeticalString() to PrecomputedFlag( + variationType = "string", + variationValue = forge.anAlphabeticalString(), + doLog = false, + allocationKey = forge.anAlphabeticalString(), + variationKey = forge.anAlphabeticalString(), + extraLogging = JSONObject(), + reason = "DEFAULT" + ), + forge.anAlphabeticalString() to PrecomputedFlag( + variationType = "boolean", + variationValue = "true", + doLog = false, + allocationKey = forge.anAlphabeticalString(), + variationKey = forge.anAlphabeticalString(), + extraLogging = JSONObject(), + reason = "TARGETING_MATCH" + ) + ) } @Test @@ -184,4 +224,73 @@ internal class DefaultFlagsRepositoryTest { // Then assertThat(result?.variationValue).isEqualTo(flagValue) } + + // region hasFlags + + @Test + fun `M return false W hasFlags() { no state set }`() { + // When + Then + assertThat(testedRepository.hasFlags()).isFalse() + } + + @Test + fun `M return false W hasFlags() { empty flags map }`(forge: Forge) { + // Given + testedRepository.setFlagsAndContext( + EvaluationContext(forge.anAlphabeticalString(), emptyMap()), + emptyMap() + ) + + // When + Then + assertThat(testedRepository.hasFlags()).isFalse() + } + + @Test + fun `M return true W hasFlags() { single flag }`() { + // Given + testedRepository.setFlagsAndContext(testContext, singleFlagMap) + + // When + Then + assertThat(testedRepository.hasFlags()).isTrue() + } + + @Test + fun `M return true W hasFlags() { multiple flags }`() { + // Given + testedRepository.setFlagsAndContext(testContext, multipleFlagsMap) + + // When + Then + assertThat(testedRepository.hasFlags()).isTrue() + } + + @Test + fun `M not block W hasFlags() { persistence still loading }`() { + // Given + doAnswer { + // Never call the callback - simulate slow persistence + null + }.whenever(mockDataStore).value( + key = any(), + version = any(), + callback = any(), + deserializer = any() + ) + val slowRepository = DefaultFlagsRepository( + featureSdkCore = mockFeatureSdkCore, + dataStore = mockDataStore, + instanceName = "slow", + persistenceLoadTimeoutMs = 1000L // Long timeout + ) + + // When + val startTime = System.currentTimeMillis() + val result = slowRepository.hasFlags() + val elapsedTime = System.currentTimeMillis() - startTime + + // Then + assertThat(result).isFalse + assertThat(elapsedTime).isLessThan(100L) // Should not wait for persistence + } + + // endregion }