diff --git a/features/dd-sdk-android-flags/README.md b/features/dd-sdk-android-flags/README.md index 60b8cdcc84..8cebf37890 100644 --- a/features/dd-sdk-android-flags/README.md +++ b/features/dd-sdk-android-flags/README.md @@ -93,6 +93,26 @@ val flagsConfig = FlagsConfiguration.Builder() .build() ``` +#### Load only selected flags + +To load only the flags that your app uses, set a flag selection. The SDK sends it with the flag request, and the server returns only the flags that have any of the tags or whose key matches any of the key patterns: + +```kotlin +val flagsConfig = FlagsConfiguration.Builder() + .flagSelection( + tags = listOf("mobile"), + keyPatterns = listOf("checkout-*") + ) + .build() +``` + +- Tags match exactly and are case-sensitive. A key pattern matches the whole flag key, and only `*` is a wildcard. +- Each list accepts at most 31 values, and each value is at most 200 UTF-8 bytes. The SDK does not send a selection that is over a limit. It logs an error and loads the full flag configuration. +- When the server applies the selection, evaluating a flag outside it returns the default value with `ErrorCode.FLAG_NOT_FOUND`. +- The flag selection is fixed when the Flags feature is enabled and applies to every `FlagsClient`. Flags cached under a different selection are not loaded at startup; until the next successful fetch, a client starts without cached flags. +- If the server ignores a selection, the SDK loads the full flag configuration and logs an error with the reason. A reason that the SDK does not know is reported as `unknown`. +- With a custom flag endpoint, the proxy must forward the `DD-Flag-Selection-Ignored` response header. Otherwise, the SDK cannot report a selection that the server ignored. + ## Use the Feature Flags SDK ### Create a Flags client diff --git a/features/dd-sdk-android-flags/api/apiSurface b/features/dd-sdk-android-flags/api/apiSurface index 46cc8e7990..9c1443a151 100644 --- a/features/dd-sdk-android-flags/api/apiSurface +++ b/features/dd-sdk-android-flags/api/apiSurface @@ -38,6 +38,7 @@ data class com.datadog.android.flags.FlagsConfiguration fun evaluationFlushInterval(Long): Builder fun useCustomFlagEndpoint(String): Builder fun initializationTimeout(Long): Builder + fun flagSelection(List?, List?): Builder fun rumIntegrationEnabled(Boolean): Builder fun gracefulModeEnabled(Boolean): Builder fun build(): FlagsConfiguration 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 e8ed206824..74d89c76be 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 @@ -76,6 +76,7 @@ public final class com/datadog/android/flags/FlagsConfiguration$Builder { public fun ()V public final fun build ()Lcom/datadog/android/flags/FlagsConfiguration; public final fun evaluationFlushInterval (J)Lcom/datadog/android/flags/FlagsConfiguration$Builder; + public final fun flagSelection (Ljava/util/List;Ljava/util/List;)Lcom/datadog/android/flags/FlagsConfiguration$Builder; public final fun gracefulModeEnabled (Z)Lcom/datadog/android/flags/FlagsConfiguration$Builder; public final fun initializationTimeout (J)Lcom/datadog/android/flags/FlagsConfiguration$Builder; public final fun rumIntegrationEnabled (Z)Lcom/datadog/android/flags/FlagsConfiguration$Builder; diff --git a/features/dd-sdk-android-flags/src/main/json/flags/flags-state.json b/features/dd-sdk-android-flags/src/main/json/flags/flags-state.json index 66fbd93752..fe90cc630d 100644 --- a/features/dd-sdk-android-flags/src/main/json/flags/flags-state.json +++ b/features/dd-sdk-android-flags/src/main/json/flags/flags-state.json @@ -22,6 +22,14 @@ "salt": { "type": "string" } }, "required": ["scheme", "salt"] + }, + "flagSelection": { + "type": "object", + "properties": { + "tags": { "type": "array", "items": { "type": "string" } }, + "keyPatterns": { "type": "array", "items": { "type": "string" } } + }, + "required": ["tags", "keyPatterns"] } }, "required": ["evaluationContext", "lastUpdateTimestamp"] diff --git a/features/dd-sdk-android-flags/src/main/json/flags/precomputed-assignments-request.json b/features/dd-sdk-android-flags/src/main/json/flags/precomputed-assignments-request.json index 56bb5c2a29..069cb4d38a 100644 --- a/features/dd-sdk-android-flags/src/main/json/flags/precomputed-assignments-request.json +++ b/features/dd-sdk-android-flags/src/main/json/flags/precomputed-assignments-request.json @@ -68,6 +68,28 @@ "targeting_key", "targeting_attributes" ] + }, + "flag_selection": { + "description": "Canonical flag selection. Sent only when the application configures a valid selection.", + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "key_patterns": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "tags", + "key_patterns" + ] } }, "required": [ 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 a72855d0e6..754f9c004f 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 @@ -470,6 +470,7 @@ interface FlagsClient { featureSdkCore = featureSdkCore, dataStore = datastore, instanceName = name, + flagSelection = flagsFeature.flagSelection, experimentationOptOutMode = experimentationOptOutMode, deliverFirstFlags = { delivery -> val worker = featureSdkCore.createSingleThreadExecutorService(FLAGS_FIRST_FLAGS_EXECUTOR_NAME) @@ -492,6 +493,7 @@ interface FlagsClient { PrecomputedAssignmentsRequestFactory( featureSdkCore.internalLogger, configuration.customFlagEndpoint, + flagSelection = flagsFeature.flagSelection, experimentationOptOutMode = experimentationOptOutMode ) } else { diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsConfiguration.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsConfiguration.kt index 5a51768925..eb5e919626 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsConfiguration.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsConfiguration.kt @@ -6,6 +6,9 @@ package com.datadog.android.flags +import com.datadog.android.flags.internal.selection.FlagSelection +import com.datadog.android.flags.model.ErrorCode + private const val DEFAULT_INITIALIZATION_TIMEOUT_MS = 5_000L /** @@ -21,10 +24,11 @@ data class FlagsConfiguration internal constructor( internal val evaluationFlushIntervalMs: Long, internal val rumIntegrationEnabled: Boolean, internal val gracefulModeEnabled: Boolean, - internal val initializationTimeoutMs: Long? + internal val initializationTimeoutMs: Long?, + internal val flagSelection: FlagSelection? ) { /** - * Copies this configuration and preserves the initialization timeout. + * Copies this configuration and preserves the initialization timeout and the flag selection. * * This overload keeps the public JVM signature that existed before the initialization timeout was added. */ @@ -46,7 +50,8 @@ data class FlagsConfiguration internal constructor( evaluationFlushIntervalMs = evaluationFlushIntervalMs, rumIntegrationEnabled = rumIntegrationEnabled, gracefulModeEnabled = gracefulModeEnabled, - initializationTimeoutMs = initializationTimeoutMs + initializationTimeoutMs = initializationTimeoutMs, + flagSelection = flagSelection ) /** @@ -62,6 +67,7 @@ data class FlagsConfiguration internal constructor( private var rumIntegrationEnabled: Boolean = true private var gracefulModeEnabled: Boolean = true private var initializationTimeoutMs: Long? = DEFAULT_INITIALIZATION_TIMEOUT_MS + private var flagSelection: FlagSelection? = null /** * Sets whether exposures should be logged to the dedicated exposures intake endpoint. @@ -172,6 +178,36 @@ data class FlagsConfiguration internal constructor( initializationTimeoutMs = timeoutMs.takeIf { it > 0 } } + /** + * Loads only the flags that match the given tags or key patterns. + * + * Tags match exactly and are case-sensitive; a flag matches if it has any of the tags. Key patterns are + * whole-key globs where only `*` is special. Leading and trailing Unicode whitespace is trimmed from every + * value, empty values are dropped, and duplicates are removed. A null list is treated as absent. + * + * Each list accepts at most 31 values after duplicates are removed, and each tag and key pattern is at most + * 200 UTF-8 bytes after trimming. A selection over a limit, or one that contains a null value or an unpaired + * UTF-16 surrogate, is not sent, and the full flag configuration is loaded. The server may also ignore a very + * large selection (`too_many_values`), in which case the full flag configuration is loaded. Servers that do + * not support flag selection, or have it disabled, return the full flag configuration without a warning. + * A server reason that the SDK does not know is reported as `unknown`. With [useCustomFlagEndpoint], the proxy + * must forward the `DD-Flag-Selection-Ignored` response header for an ignored selection to be reported. + * + * When the server applies the selection, flags outside it are not loaded, so evaluating them returns the + * default value with [ErrorCode.FLAG_NOT_FOUND]. + * + * The selection is fixed when the feature is enabled and applies to every [FlagsClient]. Flags cached under a + * different selection are not loaded at startup; until the next successful fetch, a client starts without + * cached flags. Calling this method again replaces the previous selection. + * + * @param tags The flag tags to load, or null for none. + * @param keyPatterns The flag key patterns to load, or null for none. + * @return this [Builder] instance for method chaining. + */ + fun flagSelection(tags: List?, keyPatterns: List?): Builder = apply { + flagSelection = FlagSelection(tags?.toList(), keyPatterns?.toList()) + } + /** * Sets whether RUM evaluation logging is enabled. * This adds the result of evaluating a feature flag to the view. @@ -217,7 +253,8 @@ data class FlagsConfiguration internal constructor( evaluationFlushIntervalMs = evaluationFlushIntervalMs, rumIntegrationEnabled = rumIntegrationEnabled, gracefulModeEnabled = gracefulModeEnabled, - initializationTimeoutMs = initializationTimeoutMs + initializationTimeoutMs = initializationTimeoutMs, + flagSelection = flagSelection ) internal companion object { diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsFeature.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsFeature.kt index 436004e057..e129f9bd9c 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsFeature.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsFeature.kt @@ -21,6 +21,10 @@ import com.datadog.android.flags.FlagsConfiguration import com.datadog.android.flags.internal.evaluation.InitializationTimeoutScheduler import com.datadog.android.flags.internal.net.ExposuresRequestFactory import com.datadog.android.flags.internal.net.PrecomputedAssignmentsRequestFactory +import com.datadog.android.flags.internal.selection.CanonicalFlagSelection +import com.datadog.android.flags.internal.selection.FlagSelectionCanonicalizer +import com.datadog.android.flags.internal.selection.FlagSelectionResult +import com.datadog.android.flags.internal.selection.logDroppedFlagSelection import com.datadog.android.flags.internal.storage.ExposureEventRecordWriter import com.datadog.android.flags.internal.storage.NoOpRecordWriter import com.datadog.android.flags.internal.storage.RecordWriter @@ -76,10 +80,16 @@ internal class FlagsFeature( // region Domain Objects + private val flagSelectionResult = FlagSelectionCanonicalizer.canonicalize(flagsConfiguration.flagSelection) + + internal val flagSelection: CanonicalFlagSelection? = + (flagSelectionResult as? FlagSelectionResult.Selected)?.selection + internal val precomputedRequestFactory = PrecomputedAssignmentsRequestFactory( internalLogger = sdkCore.internalLogger, - customFlagEndpoint = flagsConfiguration.customFlagEndpoint + customFlagEndpoint = flagsConfiguration.customFlagEndpoint, + flagSelection = flagSelection ) internal val initializationTimeoutScheduler = InitializationTimeoutScheduler { timeoutMs, action -> @@ -123,6 +133,9 @@ internal class FlagsFeature( } isDebugBuild = (appContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 isInitialized = true + (flagSelectionResult as? FlagSelectionResult.Dropped)?.let { + sdkCore.internalLogger.logDroppedFlagSelection(it.error) + } dataWriter = createDataWriter() processor = ExposureEventsProcessor( writer = dataWriter, 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 6b2a0db94c..3b9f3d22b9 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 @@ -26,6 +26,7 @@ import com.datadog.android.flags.internal.net.NetworkRequestFailedException 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.internal.selection.logIgnoredFlagSelection import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.FlagsClientState import java.util.concurrent.Executor @@ -95,6 +96,7 @@ private class InitializationCompletion(private var callback: EvaluationContextCa * @param experimentationOptOut validates contexts and prevents superseded installations * @param callbackExecutor delivers customer callbacks outside state delivery and network operations */ +@Suppress("TooManyFunctions") // Each step of the fetch, install and completion lifecycle is a separate function. internal class EvaluationsManager( private val sdkCore: FeatureSdkCore, private val executorService: ExecutorService, @@ -200,15 +202,21 @@ internal class EvaluationsManager( { "Processing evaluation context: ${context.targetingKey}" } ) matchingCachedAssignments.set( - flagsRepository.hasFlags() && flagsRepository.getEvaluationContext() == context + flagsRepository.hasConfiguration() && flagsRepository.getEvaluationContext() == context ) val response = datadogContext?.let { assignmentsReader.readPrecomputedFlags(context, it, diagnostics) } val assignments = response?.let { rawResponse -> - precomputeMapper.map(rawResponse, request.takeIf { it.optedOut }) + precomputeMapper.map(rawResponse.body, request.takeIf { it.optedOut }) } ?.takeIf { it.obfuscation == null || supportsObfuscation } if (assignments != null) { - installFlags(request, assignments, initializationCompletion, callback) + installFlags( + request, + assignments, + response.flagSelectionIgnoredReason, + initializationCompletion, + callback + ) } else { handleRequestFailure( request, @@ -266,39 +274,55 @@ internal class EvaluationsManager( private fun installFlags( request: ExperimentationOptOut.Request, assignments: PrecomputedAssignments, + flagSelectionIgnoredReason: String?, initializationCompletion: InitializationCompletion?, callback: EvaluationContextCallback? ) { val context = request.context - synchronized(initializationTerminalLock) { - val installed = experimentationOptOut.ifCurrent(request) { - val installation = flagsRepository.installFlagsAndContext( - context, - assignments.flags, - assignments.obfuscation - ) - val result = takeCompletion(initializationCompletion, callback) - currentState = FlagsClientState.Ready - effects.enqueue { - installation.complete { - effects.notifySafely { flagStateManager.updateState(FlagsClientState.Ready) } - complete(result) - } - } + val installed = synchronized(initializationTerminalLock) { + val isCurrent = experimentationOptOut.ifCurrent(request) { + installCurrentFlags(context, assignments, initializationCompletion, callback) } - if (!installed) { + if (!isCurrent) { enqueueCompletion( takeCompletion(initializationCompletion, callback), FlagsContextSupersededException(SUPERSEDED_MESSAGE) ) } + isCurrent } effects.drain() - internalLogger.log( - InternalLogger.Level.DEBUG, - InternalLogger.Target.MAINTAINER, - { "Successfully processed context ${context.targetingKey} with ${assignments.flags.size} flags" } + // A superseded response is not installed, so its ignored reason would describe flags that never loaded. + if (installed) { + flagSelectionIgnoredReason?.let { internalLogger.logIgnoredFlagSelection(it) } + internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { "Successfully processed context ${context.targetingKey} with ${assignments.flags.size} flags" } + ) + } + } + + // Callers hold initializationTerminalLock; the Ready notification and callback run later from effects.drain(). + private fun installCurrentFlags( + context: EvaluationContext, + assignments: PrecomputedAssignments, + initializationCompletion: InitializationCompletion?, + callback: EvaluationContextCallback? + ) { + val installation = flagsRepository.installFlagsAndContext( + context, + assignments.flags, + assignments.obfuscation ) + val result = takeCompletion(initializationCompletion, callback) + currentState = FlagsClientState.Ready + effects.enqueue { + installation.complete { + effects.notifySafely { flagStateManager.updateState(FlagsClientState.Ready) } + complete(result) + } + } } private fun startInitializationTimeout( @@ -327,7 +351,7 @@ internal class EvaluationsManager( if (currentState != FlagsClientState.Ready && currentState != FlagsClientState.Stale) { val hasMatchingCachedAssignments = (target === request && matchingCachedAssignments.get()) || - flagsRepository.hasLoadedFlagsForContext(target.context) + flagsRepository.hasConfigurationForContext(target.context) val timeoutState = if (hasMatchingCachedAssignments) { FlagsClientState.Stale } else { diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagsStateEntry.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagsStateEntry.kt index b2de7aa2f0..48952ff9ad 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagsStateEntry.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/model/FlagsStateEntry.kt @@ -6,11 +6,13 @@ package com.datadog.android.flags.internal.model +import com.datadog.android.flags.internal.selection.CanonicalFlagSelection import com.datadog.android.flags.model.EvaluationContext internal data class FlagsStateEntry( val evaluationContext: EvaluationContext, val flags: Map, val lastUpdateTimestamp: Long, - val obfuscation: FlagKeyObfuscation? = null + val obfuscation: FlagKeyObfuscation? = null, + val flagSelection: CanonicalFlagSelection? = null ) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsDownloader.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsDownloader.kt index fb32870b8b..578222adc1 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsDownloader.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsDownloader.kt @@ -10,6 +10,7 @@ import androidx.annotation.WorkerThread import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.flags.internal.model.PrecomputedAssignmentsRequest.Diagnostics +import com.datadog.android.flags.internal.selection.FlagSelectionIgnoredReason import com.datadog.android.flags.model.EvaluationContext import okhttp3.Call import okhttp3.Request @@ -33,13 +34,13 @@ internal class PrecomputedAssignmentsDownloader( context: EvaluationContext, datadogContext: DatadogContext, diagnostics: Diagnostics - ): String? { + ): PrecomputedAssignmentsResponse? { val request = requestFactory.create(context, datadogContext, diagnostics) ?: return null return executeDownloadRequest(request) } @Suppress("TooGenericExceptionCaught") - private fun executeDownloadRequest(request: Request): String? = try { + private fun executeDownloadRequest(request: Request): PrecomputedAssignmentsResponse? = try { val response = callFactory.newCall(request).execute() handleResponse(response) } catch (e: Throwable) { @@ -52,9 +53,20 @@ internal class PrecomputedAssignmentsDownloader( null } - private fun handleResponse(response: Response): String? = if (response.isSuccessful) { + private fun handleResponse(response: Response): PrecomputedAssignmentsResponse? = if (response.isSuccessful) { @Suppress("UnsafeThirdPartyFunctionCall") // Safe: wrapped in outer try-catch - response.body?.use { it.string() } + response.body?.use { it.string() }?.let { body -> + PrecomputedAssignmentsResponse( + body = body, + flagSelectionIgnoredReason = if (requestFactory.sendsFlagSelection) { + response.header(HEADER_FLAG_SELECTION_IGNORED) + ?.takeIf(String::isNotEmpty) + ?.let(FlagSelectionIgnoredReason::sanitize) + } else { + null + } + ) + } } else { internalLogger.log( InternalLogger.Level.ERROR, @@ -74,4 +86,8 @@ internal class PrecomputedAssignmentsDownloader( null } + + private companion object { + const val HEADER_FLAG_SELECTION_IGNORED = "DD-Flag-Selection-Ignored" + } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsReader.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsReader.kt index 3edc60b34b..0f5c7c7615 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsReader.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsReader.kt @@ -19,5 +19,13 @@ internal interface PrecomputedAssignmentsReader { context: EvaluationContext, datadogContext: DatadogContext, diagnostics: Diagnostics - ): String? + ): PrecomputedAssignmentsResponse? } + +/** + * [flagSelectionIgnoredReason] is the sanitized `DD-Flag-Selection-Ignored` reason, or null when the header is absent. + */ +internal data class PrecomputedAssignmentsResponse( + val body: String, + val flagSelectionIgnoredReason: String? +) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt index 25ea88368d..d7665d0976 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactory.kt @@ -15,6 +15,7 @@ import com.datadog.android.flags.internal.evaluation.wireValue import com.datadog.android.flags.internal.getFlagsEndpoint import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.PrecomputedAssignmentsRequest +import com.datadog.android.flags.internal.selection.CanonicalFlagSelection import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.internal.rum.rumContext import okhttp3.Headers @@ -28,9 +29,12 @@ import okhttp3.RequestBody.Companion.toRequestBody internal class PrecomputedAssignmentsRequestFactory( private val internalLogger: InternalLogger, private val customFlagEndpoint: String?, + private val flagSelection: CanonicalFlagSelection?, private val experimentationOptOutMode: ExperimentationOptOutMode = ExperimentationOptOutMode.DISABLED ) { + val sendsFlagSelection: Boolean = flagSelection != null + /** * Creates an OkHttp Request for fetching precomputed flag assignments. * @@ -134,7 +138,10 @@ internal class PrecomputedAssignmentsRequestFactory( targetingAttributes = PrecomputedAssignmentsRequest.TargetingAttributes( additionalProperties = targetingAttributes ) - ) + ), + flagSelection = flagSelection?.let { + PrecomputedAssignmentsRequest.FlagSelection(tags = it.tags, keyPatterns = it.keyPatterns) + } ) ) ) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsPersistenceManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsPersistenceManager.kt index e836d5cae4..766b6913a3 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsPersistenceManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsPersistenceManager.kt @@ -15,11 +15,13 @@ import com.datadog.android.flags.ExperimentationOptOutMode import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.FlagsStateEntry import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.selection.CanonicalFlagSelection import com.datadog.android.flags.model.EvaluationContext internal class FlagsPersistenceManager( private val dataStore: DataStoreHandler, instanceName: String, + private val flagSelection: CanonicalFlagSelection?, private val internalLogger: InternalLogger, experimentationOptOutMode: ExperimentationOptOutMode = ExperimentationOptOutMode.DISABLED, onStateLoaded: (FlagsStateEntry?) -> Unit @@ -27,13 +29,16 @@ internal class FlagsPersistenceManager( private val serializer = FlagsStateSerializer(internalLogger) private val deserializer = FlagsStateDeserializer(internalLogger) - private val flagsStateKey: String = when (experimentationOptOutMode) { - ExperimentationOptOutMode.DISABLED -> "$FLAGS_STATE_KEY_PREFIX-$instanceName" - ExperimentationOptOutMode.IMMEDIATE -> "flags-immediate-v1-state-$instanceName" - ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY -> "flags-future-v1-state-$instanceName" - } + // SDK versions without flag selection read only the unselected key for their opt-out mode and ignore the + // flagSelection field, so selected state uses its own key and is never read by them as a full configuration. + private val unselectedStateKey = stateKey(instanceName, experimentationOptOutMode, selected = false) + private val selectedStateKey = stateKey(instanceName, experimentationOptOutMode, selected = true) + private val flagsStateKey: String = if (flagSelection == null) unselectedStateKey else selectedStateKey + private val staleStateKey: String = if (flagSelection == null) selectedStateKey else unselectedStateKey init { + // One persisted state per instance: changing to or from a selection discards the other key's state. + dataStore.removeValue(staleStateKey) loadFlagsState(onStateLoaded) } @@ -48,7 +53,8 @@ internal class FlagsPersistenceManager( evaluationContext = context, flags = flags, lastUpdateTimestamp = currentTimestamp, - obfuscation = obfuscation + obfuscation = obfuscation, + flagSelection = flagSelection ) dataStore.setValue( @@ -82,6 +88,14 @@ internal class FlagsPersistenceManager( } companion object { - private const val FLAGS_STATE_KEY_PREFIX = "flags-state" + internal fun stateKey(instanceName: String, mode: ExperimentationOptOutMode, selected: Boolean): String { + val selection = if (selected) "selection-" else "" + val modePart = when (mode) { + ExperimentationOptOutMode.DISABLED -> "" + ExperimentationOptOutMode.IMMEDIATE -> "immediate-v1-" + ExperimentationOptOutMode.FUTURE_EXPERIMENTS_ONLY -> "future-v1-" + } + return "flags-$selection${modePart}state-$instanceName" + } } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt index 1cfcc63235..3f74fd5d7a 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializer.kt @@ -10,6 +10,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.core.internal.persistence.Deserializer import com.datadog.android.flags.internal.model.FlagsStateEntry import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.selection.CanonicalFlagSelection import com.datadog.android.flags.internal.serialization.FlagsJson import com.datadog.android.flags.model.EvaluationContext import com.google.gson.JsonElement @@ -48,7 +49,8 @@ internal class FlagsStateDeserializer(private val internalLogger: InternalLogger ), flags = flags, lastUpdateTimestamp = state.lastUpdateTimestamp, - obfuscation = obfuscation + obfuscation = obfuscation, + flagSelection = state.flagSelection?.let { CanonicalFlagSelection(it.tags, it.keyPatterns) } ) } } catch (e: JsonParseException) { diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt index c7aaa7c918..e700c9a03e 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializer.kt @@ -33,7 +33,8 @@ internal class FlagsStateSerializer(private val internalLogger: InternalLogger) encodedFlags = flags.takeIf { model.obfuscation != null }, lastUpdateTimestamp = model.lastUpdateTimestamp, obfuscated = true.takeIf { model.obfuscation != null }, - obfuscation = model.obfuscation?.let { FlagsState.Obfuscation(FlagKeyObfuscation.SCHEME, it.salt) } + obfuscation = model.obfuscation?.let { FlagsState.Obfuscation(FlagKeyObfuscation.SCHEME, it.salt) }, + flagSelection = model.flagSelection?.let { FlagsState.FlagSelection(it.tags, it.keyPatterns) } ).toJson().toString() } catch (e: JsonParseException) { internalLogger.log( 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 0e2a6be159..d930ea514c 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 @@ -15,6 +15,7 @@ import com.datadog.android.flags.ExperimentationOptOutMode import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.persistence.FlagsPersistenceManager +import com.datadog.android.flags.internal.selection.CanonicalFlagSelection import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.ResolutionReason import java.util.concurrent.CountDownLatch @@ -25,6 +26,7 @@ internal class DefaultFlagsRepository( private val featureSdkCore: FeatureSdkCore, instanceName: String, dataStore: DataStoreHandler, + flagSelection: CanonicalFlagSelection?, private val internalLogger: InternalLogger = featureSdkCore.internalLogger, private val persistenceLoadTimeoutMs: Long = PERSISTENCE_LOAD_TIMEOUT_MS, private val deliverFirstFlags: (Runnable) -> Unit = Runnable::run, @@ -52,9 +54,12 @@ internal class DefaultFlagsRepository( private val persistenceManager = FlagsPersistenceManager( dataStore = dataStore, instanceName = instanceName, + flagSelection = flagSelection, internalLogger = internalLogger, experimentationOptOutMode = experimentationOptOutMode - ) { persistedState -> + ) { loadedState -> + // State fetched with a different selection holds a different set of flags. + val persistedState = loadedState?.takeIf { it.flagSelection == flagSelection } val cachedState = try { persistedState?.let { FlagsState( @@ -181,15 +186,13 @@ internal class DefaultFlagsRepository( return readableState?.context } - override fun hasFlags(): Boolean { + override fun hasConfiguration(): Boolean { waitForPersistenceLoad() - return readableState?.flags?.isNotEmpty() ?: false + return readableState != null } - override fun hasLoadedFlagsForContext(context: EvaluationContext): Boolean { - val state = readableState - return state?.context == context && state.flags.isNotEmpty() - } + override fun hasConfigurationForContext(context: EvaluationContext): Boolean = + readableState?.context == context override fun getPrecomputedFlagWithContext(key: String): Pair? { val state = readableState ?: return null 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 2810f66728..0eb1a7c6cc 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 @@ -39,8 +39,12 @@ internal interface FlagsRepository { // Nonblocking snapshot. Missing flags retain their context from the same atomic state read. fun getPrecomputedFlagWithContext(key: String): Pair? val firstFlags: FirstFlagsLatch - fun hasFlags(): Boolean - fun hasLoadedFlagsForContext(context: EvaluationContext): Boolean + + /** True once a configuration is installed, including a valid configuration with zero flags. */ + fun hasConfiguration(): Boolean + + /** True when the installed configuration is for [context], including a valid configuration with zero flags. */ + fun hasConfigurationForContext(context: EvaluationContext): Boolean @Deprecated("Use getAssignmentsSnapshot instead") fun getFlagsSnapshot(): Map diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/NoOpFlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/NoOpFlagsRepository.kt index 4818398e1a..9c8ad09a0a 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/NoOpFlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/NoOpFlagsRepository.kt @@ -38,9 +38,9 @@ internal class NoOpFlagsRepository : FlagsRepository { override val firstFlags = FirstFlagsLatch() - override fun hasFlags(): Boolean = false + override fun hasConfiguration(): Boolean = false - override fun hasLoadedFlagsForContext(context: EvaluationContext): Boolean = false + override fun hasConfigurationForContext(context: EvaluationContext): Boolean = false override fun getAssignmentsSnapshot(): FlagsAssignmentsSnapshot? = null diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/selection/FlagSelection.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/selection/FlagSelection.kt new file mode 100644 index 0000000000..aaa9dddd59 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/selection/FlagSelection.kt @@ -0,0 +1,159 @@ +/* + * 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.selection + +/** + * The flag selection as configured by the application, before canonicalization. + * Lists and elements are nullable because Java callers can pass nulls. A null list means the list is absent. + */ +internal data class FlagSelection( + val tags: List?, + val keyPatterns: List? +) + +/** + * A flag selection that was trimmed, deduplicated, sorted in UTF-8 byte order and validated. + */ +internal data class CanonicalFlagSelection( + val tags: List, + val keyPatterns: List +) + +/** + * Reasons a flag selection is dropped. The values are the reason codes that the server and the other Datadog SDKs use. + */ +internal enum class FlagSelectionError(val reason: String, val limit: String) { + MALFORMED_SELECTION( + "malformed_selection", + "tags and key patterns must be non-null strings without unpaired UTF-16 surrogates" + ), + TOO_MANY_VALUES( + "too_many_values", + "each list accepts at most ${FlagSelectionCanonicalizer.MAX_VALUES} values" + ), + INVALID_KEY_PATTERN( + "invalid_key_pattern", + "each key pattern is at most ${FlagSelectionCanonicalizer.MAX_KEY_PATTERN_BYTES} UTF-8 bytes" + ), + INVALID_TAG( + "invalid_tag", + "each tag is at most ${FlagSelectionCanonicalizer.MAX_TAG_BYTES} UTF-8 bytes" + ) +} + +/** + * Reasons the server reports in the `DD-Flag-Selection-Ignored` response header. + * The server validates with the same rules as the SDK, so validation failures reuse the [FlagSelectionError] codes. + * [TAG_LOAD_FAILED] is reported only by the server, so it is not a [FlagSelectionError]. + */ +internal enum class ServerIgnoredReason(val reason: String) { + MALFORMED_SELECTION(FlagSelectionError.MALFORMED_SELECTION.reason), + TOO_MANY_VALUES(FlagSelectionError.TOO_MANY_VALUES.reason), + INVALID_KEY_PATTERN(FlagSelectionError.INVALID_KEY_PATTERN.reason), + INVALID_TAG(FlagSelectionError.INVALID_TAG.reason), + TAG_LOAD_FAILED("tag_load_failed") +} + +/** + * Sanitizes the `DD-Flag-Selection-Ignored` response header, so a raw header value never reaches logs or telemetry. + * A [ServerIgnoredReason] is kept, and any other non-empty value becomes [UNKNOWN]. + */ +internal object FlagSelectionIgnoredReason { + + internal const val UNKNOWN = "unknown" + + // The longest known reason has 19 characters. The limit bounds the work on an oversized header. + private const val MAX_LENGTH = 32 + + fun sanitize(value: String): String { + if (value.length > MAX_LENGTH) return UNKNOWN + return ServerIgnoredReason.entries.firstOrNull { it.reason == value }?.reason ?: UNKNOWN + } +} + +internal sealed interface FlagSelectionResult { + object None : FlagSelectionResult + data class Selected(val selection: CanonicalFlagSelection) : FlagSelectionResult + data class Dropped(val error: FlagSelectionError) : FlagSelectionResult +} + +/** + * Canonicalizes and validates a flag selection with the same rules as the server. + */ +internal object FlagSelectionCanonicalizer { + + internal const val MAX_VALUES = 31 + internal const val MAX_TAG_BYTES = 200 + internal const val MAX_KEY_PATTERN_BYTES = 200 + + // The Unicode White_Space property, which the server trims. + // Kotlin's trim() differs: it also trims U+001C..U+001F and does not trim U+0085. + private const val WHITE_SPACE = "\u0009\u000A\u000B\u000C\u000D\u0020\u0085\u00A0\u1680" + + "\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A" + + "\u2028\u2029\u202F\u205F\u3000" + + @Suppress("ReturnCount") + fun canonicalize(selection: FlagSelection?): FlagSelectionResult { + if (selection == null) return FlagSelectionResult.None + val rawTags = selection.tags.orEmpty().wellFormedOrNull() + val rawKeyPatterns = selection.keyPatterns.orEmpty().wellFormedOrNull() + if (rawTags == null || rawKeyPatterns == null) { + return FlagSelectionResult.Dropped(FlagSelectionError.MALFORMED_SELECTION) + } + + val tags = canonicalizeList(rawTags) + val keyPatterns = canonicalizeList(rawKeyPatterns) + + val error = when { + tags.size > MAX_VALUES || keyPatterns.size > MAX_VALUES -> FlagSelectionError.TOO_MANY_VALUES + keyPatterns.any { it.utf8Size() > MAX_KEY_PATTERN_BYTES } -> FlagSelectionError.INVALID_KEY_PATTERN + tags.any { it.utf8Size() > MAX_TAG_BYTES } -> FlagSelectionError.INVALID_TAG + else -> null + } + return when { + error != null -> FlagSelectionResult.Dropped(error) + tags.isEmpty() && keyPatterns.isEmpty() -> FlagSelectionResult.None + else -> FlagSelectionResult.Selected(CanonicalFlagSelection(tags, keyPatterns)) + } + } + + private fun canonicalizeList(values: List): List { + val distinct = values + .asSequence() + .map { value -> value.trim { it in WHITE_SPACE } } + .filter { it.isNotEmpty() } + .toList() + .distinct() + // An over-limit list is dropped as too_many_values, so its order does not matter. + if (distinct.size > MAX_VALUES) return distinct + return distinct + .map { it to it.utf8SortKey() } + .sortedBy { it.second } + .map { it.first } + } + + // Latin-1 maps each byte to one char, so comparing these strings compares unsigned UTF-8 bytes. + private fun String.utf8SortKey(): String = String(toByteArray(Charsets.UTF_8), Charsets.ISO_8859_1) + + private fun String.utf8Size(): Int = toByteArray(Charsets.UTF_8).size + + // A Java caller can pass a raw list, so check each element's type instead of trusting the generic type. + private fun List<*>.wellFormedOrNull(): List? = + if (all { it is String && !it.hasUnpairedSurrogate() }) filterIsInstance() else null + + private fun String.hasUnpairedSurrogate(): Boolean { + var index = 0 + var unpaired = false + while (index < length && !unpaired) { + val char = this[index] + val isPair = char.isHighSurrogate() && index + 1 < length && this[index + 1].isLowSurrogate() + unpaired = !isPair && (char.isHighSurrogate() || char.isLowSurrogate()) + index += if (isPair) 2 else 1 + } + return unpaired + } +} diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/selection/FlagSelectionLogging.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/selection/FlagSelectionLogging.kt new file mode 100644 index 0000000000..a8b9b4efb2 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/selection/FlagSelectionLogging.kt @@ -0,0 +1,48 @@ +/* + * 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.selection + +import com.datadog.android.api.InternalLogger + +// Telemetry carries only the reason, never selection values, and is sent once per process. +private const val TELEMETRY_REASON_KEY = "reason" + +private const val FLAG_SELECTION_DROPPED_TELEMETRY_MESSAGE = "Flag selection dropped" +private const val FLAG_SELECTION_IGNORED_TELEMETRY_MESSAGE = "Flag selection ignored" + +internal fun InternalLogger.logDroppedFlagSelection(error: FlagSelectionError) { + log( + InternalLogger.Level.ERROR, + InternalLogger.Target.USER, + { + "flagSelection was not sent (${error.reason}: ${error.limit}). " + + "Loading the full flag configuration." + } + ) + log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.TELEMETRY, + { FLAG_SELECTION_DROPPED_TELEMETRY_MESSAGE }, + additionalProperties = mapOf(TELEMETRY_REASON_KEY to error.reason), + onlyOnce = true + ) +} + +internal fun InternalLogger.logIgnoredFlagSelection(reason: String) { + log( + InternalLogger.Level.ERROR, + InternalLogger.Target.USER, + { "flagSelection was ignored by the server ($reason). Loaded the full flag configuration." } + ) + log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.TELEMETRY, + { FLAG_SELECTION_IGNORED_TELEMETRY_MESSAGE }, + additionalProperties = mapOf(TELEMETRY_REASON_KEY to reason), + onlyOnce = true + ) +} diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsClientTest.kt index fd2cfffb69..67219e211b 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsClientTest.kt @@ -11,14 +11,23 @@ import com.datadog.android.api.feature.Feature import com.datadog.android.api.feature.Feature.Companion.FLAGS_FEATURE_NAME import com.datadog.android.api.feature.FeatureScope import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.api.storage.datastore.DataStoreHandler +import com.datadog.android.api.storage.datastore.DataStoreReadCallback +import com.datadog.android.core.persistence.datastore.DataStoreContent +import com.datadog.android.flags.internal.DatadogFlagsClient import com.datadog.android.flags.internal.FlagsFeature import com.datadog.android.flags.internal.NoOpFlagsClient +import com.datadog.android.flags.internal.model.FlagsStateEntry +import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.selection.CanonicalFlagSelection import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.utils.verifyLog import fr.xgouchet.elmyr.annotation.BoolForgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension +import okhttp3.Call import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -28,12 +37,15 @@ 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.anyOrNull import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness +import java.util.concurrent.ExecutorService @ExtendWith(MockitoExtension::class, ForgeExtension::class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -120,6 +132,67 @@ internal class FlagsClientTest { flagsFeature?.clearClients() } + @Test + fun `M key persisted flags by the configured selection W createInternal() { flag selection }`( + @StringForgery fakeFlagKey: String, + @StringForgery fakeClientName: String + ) { + // Given + val mockCore = mock() + val mockDataStore = mock() + val mockFeatureScope = mock() + whenever(mockCore.internalLogger).thenReturn(mockInternalLogger) + whenever(mockCore.timeProvider).thenReturn(mock()) + whenever(mockCore.createSingleThreadExecutorService(any())).thenReturn(mock()) + whenever(mockCore.createOkHttpCallFactory(any())).thenReturn(mock()) + whenever(mockFeatureScope.dataStore).thenReturn(mockDataStore) + whenever(mockCore.getFeature(FLAGS_FEATURE_NAME)).thenReturn(mockFeatureScope) + lateinit var readCallback: DataStoreReadCallback + doAnswer { + readCallback = it.getArgument(2) + null + }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) + val configuration = FlagsConfiguration.Builder() + .trackEvaluations(false) + .flagSelection(tags = listOf("mobile"), keyPatterns = null) + .build() + val client = FlagsClient.createInternal( + configuration = configuration, + featureSdkCore = mockCore, + flagsFeature = FlagsFeature(mockCore, configuration), + evaluationsFeature = null, + name = fakeClientName, + evaluationTelemetryOptOutParameterEnabled = false + ) as DatadogFlagsClient + + // When + readCallback.onSuccess( + DataStoreContent( + 1, + FlagsStateEntry( + evaluationContext = EvaluationContext("user"), + flags = mapOf( + fakeFlagKey to + PrecomputedFlag( + "boolean", + "true", + false, + "allocation", + "on", + JSONObject(), + "TARGETING_MATCH" + ) + ), + lastUpdateTimestamp = 0L, + flagSelection = CanonicalFlagSelection(tags = listOf("mobile"), keyPatterns = emptyList()) + ) + ) + ) + + // Then + assertThat(client.getFlagAssignmentsSnapshot()).containsOnlyKeys(fakeFlagKey) + } + // region Static Flag Resolution Methods @Test diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsConfigurationTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsConfigurationTest.kt index f0dc4b7136..b85c6338bb 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsConfigurationTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsConfigurationTest.kt @@ -6,6 +6,7 @@ package com.datadog.android.flags +import com.datadog.android.flags.internal.selection.FlagSelection import fr.xgouchet.elmyr.annotation.BoolForgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -30,6 +31,7 @@ internal class FlagsConfigurationTest { assertThat(configuration.customFlagEndpoint).isNull() assertThat(configuration.gracefulModeEnabled).isTrue() assertThat(configuration.initializationTimeoutMs).isEqualTo(5_000L) + assertThat(configuration.flagSelection).isNull() } @Test @@ -181,5 +183,63 @@ internal class FlagsConfigurationTest { assertThat(copiedConfiguration.initializationTimeoutMs).isEqualTo(2_500L) } + @Test + fun `M set the flag selection W flagSelection() { called twice }`() { + // Given + val builder = FlagsConfiguration.Builder() + .flagSelection(tags = listOf("web"), keyPatterns = listOf("web-*")) + + // When + val configuration = builder + .flagSelection(tags = listOf("mobile"), keyPatterns = emptyList()) + .build() + + // Then + assertThat(configuration.flagSelection) + .isEqualTo(FlagSelection(tags = listOf("mobile"), keyPatterns = emptyList())) + } + + @Test + fun `M copy the lists W flagSelection() { caller mutates the lists after the call }`() { + // Given + val tags = mutableListOf("mobile") + val keyPatterns = mutableListOf("checkout-*") + val builder = FlagsConfiguration.Builder().flagSelection(tags, keyPatterns) + + // When + tags += "web" + keyPatterns.clear() + + // Then + assertThat(builder.build().flagSelection) + .isEqualTo(FlagSelection(tags = listOf("mobile"), keyPatterns = listOf("checkout-*"))) + } + + @Test + fun `M keep null lists as absent W flagSelection() { null lists }`() { + // When + val configuration = FlagsConfiguration.Builder() + .flagSelection(tags = null, keyPatterns = listOf("checkout-*")) + .build() + + // Then + assertThat(configuration.flagSelection) + .isEqualTo(FlagSelection(tags = null, keyPatterns = listOf("checkout-*"))) + } + + @Test + fun `M preserve the flag selection W copy() { legacy parameters }`() { + // Given + val testedConfiguration = FlagsConfiguration.Builder() + .flagSelection(tags = listOf("mobile"), keyPatterns = listOf("checkout-*")) + .build() + + // When + val copiedConfiguration = testedConfiguration.copy(trackExposures = false) + + // Then + assertThat(copiedConfiguration.flagSelection).isEqualTo(testedConfiguration.flagSelection) + } + // 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 ea34486017..fd925bc316 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 @@ -205,6 +205,7 @@ internal class DatadogFlagsClientTest { featureSdkCore = mockFeatureSdkCore, instanceName = fakeJsonKey, dataStore = mock(), + flagSelection = null, persistenceLoadTimeoutMs = 0 ) ) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsFeatureTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsFeatureTest.kt index c4a0950c09..6f16c2a9ce 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsFeatureTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsFeatureTest.kt @@ -15,6 +15,8 @@ import com.datadog.android.flags.ExperimentationOptOutMode import com.datadog.android.flags.FlagsClient import com.datadog.android.flags.FlagsConfiguration import com.datadog.android.flags.internal.evaluation.ExperimentationOptOut +import com.datadog.android.flags.internal.selection.CanonicalFlagSelection +import com.datadog.android.flags.internal.selection.FlagSelectionError import com.datadog.android.flags.internal.storage.ExposureEventRecordWriter import com.datadog.android.flags.internal.storage.NoOpRecordWriter import com.datadog.android.utils.verifyLog @@ -26,7 +28,9 @@ import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.Mockito.mock import org.mockito.junit.jupiter.MockitoExtension @@ -37,6 +41,7 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever @@ -176,6 +181,91 @@ internal class FlagsFeatureTest { ).isNotNull() } + @Test + fun `M expose the canonical flag selection W constructor { valid selection }`() { + // When + val feature = FlagsFeature( + sdkCore = mockSdkCore, + flagsConfiguration = FlagsConfiguration.Builder() + .flagSelection(tags = listOf(" web", "mobile", "web"), keyPatterns = listOf("checkout-*")) + .build() + ) + + // Then + assertThat(feature.flagSelection) + .isEqualTo(CanonicalFlagSelection(tags = listOf("mobile", "web"), keyPatterns = listOf("checkout-*"))) + assertThat(feature.precomputedRequestFactory.sendsFlagSelection).isTrue() + } + + @ParameterizedTest(name = "{0}") + @MethodSource("selectionsWithoutValues") + fun `M send no selection and log nothing W onInitialize() { no values after canonicalization }`( + description: String, + tags: List?, + keyPatterns: List? + ) { + // Given + val feature = FlagsFeature( + sdkCore = mockSdkCore, + flagsConfiguration = FlagsConfiguration.Builder() + .flagSelection(tags = tags, keyPatterns = keyPatterns) + .build() + ) + + // When + feature.onInitialize(mockContext) + + // Then + assertThat(feature.flagSelection).describedAs(description).isNull() + assertThat(feature.precomputedRequestFactory.sendsFlagSelection).describedAs(description).isFalse() + mockInternalLogger.verifyLog( + InternalLogger.Level.ERROR, + InternalLogger.Target.USER, + { "flagSelection" in it }, + mode = never() + ) + FlagSelectionError.entries.forEach { + mockInternalLogger.verifyLog( + InternalLogger.Level.DEBUG, + InternalLogger.Target.TELEMETRY, + "Flag selection dropped", + mode = never(), + additionalProperties = mapOf("reason" to it.reason) + ) + } + } + + @Test + fun `M log the dropped flag selection W onInitialize() { selection over a limit }`() { + // Given + val feature = FlagsFeature( + sdkCore = mockSdkCore, + flagsConfiguration = FlagsConfiguration.Builder() + .flagSelection(tags = List(32) { "tag-$it" }, keyPatterns = emptyList()) + .build() + ) + + // When + feature.onInitialize(mockContext) + + // Then + assertThat(feature.flagSelection).isNull() + assertThat(feature.precomputedRequestFactory.sendsFlagSelection).isFalse() + mockInternalLogger.verifyLog( + InternalLogger.Level.ERROR, + InternalLogger.Target.USER, + "flagSelection was not sent (too_many_values: each list accepts at most 31 values). " + + "Loading the full flag configuration." + ) + mockInternalLogger.verifyLog( + InternalLogger.Level.DEBUG, + InternalLogger.Target.TELEMETRY, + "Flag selection dropped", + onlyOnce = true, + additionalProperties = mapOf("reason" to "too_many_values") + ) + } + @Test fun `M reset dataWriter to NoOp W onStop`() { // Given @@ -468,4 +558,13 @@ internal class FlagsFeatureTest { } // endregion + + companion object { + @JvmStatic + fun selectionsWithoutValues() = listOf( + Arguments.of("empty lists", emptyList(), emptyList()), + Arguments.of("whitespace-only values", listOf(" ", "\u3000"), listOf("")), + Arguments.of("null lists", null, null) + ) + } } 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 863c47d6fe..d9a7e3f10d 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 @@ -21,6 +21,7 @@ import com.datadog.android.flags.FlagsInitializationTimeoutException import com.datadog.android.flags.FlagsInvalidContextException import com.datadog.android.flags.FlagsStateListener import com.datadog.android.flags.internal.FlagsStateManager +import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.FlagsStateEntry import com.datadog.android.flags.internal.model.PrecomputedAssignments import com.datadog.android.flags.internal.model.PrecomputedAssignmentsRequest.Diagnostics @@ -30,6 +31,7 @@ import com.datadog.android.flags.internal.model.PrecomputedAssignmentsRequest.Tr import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.net.NetworkRequestFailedException import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader +import com.datadog.android.flags.internal.net.PrecomputedAssignmentsResponse import com.datadog.android.flags.internal.repository.DefaultFlagsRepository import com.datadog.android.flags.internal.repository.FlagsInstallation import com.datadog.android.flags.internal.repository.FlagsRepository @@ -39,6 +41,7 @@ import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.utils.forge.ForgeConfigurator import com.datadog.android.internal.utils.DDCoreStateHolder +import com.datadog.android.utils.verifyLog import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.IntForgery @@ -62,6 +65,7 @@ import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeast import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow @@ -115,7 +119,7 @@ internal class EvaluationsManagerTest { whenever(mockAssignmentsDownloader.readPrecomputedFlags(any(), any(), any())).thenAnswer { readerEntered.countDown() check(releaseReader.await(5, TimeUnit.SECONDS)) - "response" + PrecomputedAssignmentsResponse("response", null) } whenever(mockPrecomputeMapper.map(eq("response"), anyOrNull())) .thenReturn(PrecomputedAssignments(emptyMap(), null)) @@ -414,7 +418,7 @@ internal class EvaluationsManagerTest { // Then inOrder(mockFlagsRepository, mockAssignmentsDownloader) { - verify(mockFlagsRepository).hasFlags() + verify(mockFlagsRepository).hasConfiguration() verify(mockAssignmentsDownloader).readPrecomputedFlags( eq(EvaluationContext(fakeTargetingKey, emptyMap())), eq(fakeDatadogContext), @@ -499,7 +503,8 @@ internal class EvaluationsManagerTest { whenever(mockSdkCore.internalLogger).thenReturn(mockInternalLogger) whenever(mockSdkCore.timeProvider).thenReturn(mock()) val store = mock() - val repository = DefaultFlagsRepository(mockSdkCore, fakeTargetingKey, store, persistenceLoadTimeoutMs = 0) + val repository = + DefaultFlagsRepository(mockSdkCore, fakeTargetingKey, store, null, persistenceLoadTimeoutMs = 0) val manager = EvaluationsManager( mockSdkCore, mockExecutorService, mockInternalLogger, repository, mockAssignmentsDownloader, mockPrecomputeMapper, mockFlagsStateManager, @@ -524,7 +529,7 @@ internal class EvaluationsManagerTest { } whenever( mockAssignmentsDownloader.readPrecomputedFlags(eq(context), eq(fakeDatadogContext), any()) - ).thenReturn("response") + ).thenReturn(PrecomputedAssignmentsResponse("response", null)) whenever( mockPrecomputeMapper.map(eq("response"), anyOrNull()) ).thenReturn(PrecomputedAssignments(emptyMap(), null)) @@ -545,7 +550,8 @@ internal class EvaluationsManagerTest { whenever(mockSdkCore.internalLogger).thenReturn(mockInternalLogger) whenever(mockSdkCore.timeProvider).thenReturn(mock()) val store = mock() - val repository = DefaultFlagsRepository(mockSdkCore, fakeTargetingKey, store, persistenceLoadTimeoutMs = 0) + val repository = + DefaultFlagsRepository(mockSdkCore, fakeTargetingKey, store, null, persistenceLoadTimeoutMs = 0) val manager = EvaluationsManager( mockSdkCore, mockExecutorService, mockInternalLogger, repository, mockAssignmentsDownloader, mockPrecomputeMapper, mockFlagsStateManager, @@ -559,7 +565,7 @@ internal class EvaluationsManagerTest { }.whenever(store).setValue(any(), any(), any(), anyOrNull(), any()) whenever( mockAssignmentsDownloader.readPrecomputedFlags(any(), eq(fakeDatadogContext), any()) - ).thenReturn("response") + ).thenReturn(PrecomputedAssignmentsResponse("response", null)) whenever( mockPrecomputeMapper.map(eq("response"), anyOrNull()) ).thenReturn(PrecomputedAssignments(emptyMap(), null)) @@ -605,7 +611,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn("response") + .thenReturn(PrecomputedAssignmentsResponse("response", null)) whenever(mockPrecomputeMapper.map("response")).thenReturn(PrecomputedAssignments(emptyMap(), null)) // When @@ -664,7 +670,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(mockResponse) + .thenReturn(PrecomputedAssignmentsResponse(mockResponse, null)) whenever(mockPrecomputeMapper.map(mockResponse)).thenReturn(PrecomputedAssignments(expectedFlags)) // When @@ -725,7 +731,7 @@ internal class EvaluationsManagerTest { eq(fakeDatadogContext), isA() ) - ).thenReturn(invalidResponse) + ).thenReturn(PrecomputedAssignmentsResponse(invalidResponse, null)) whenever(mockPrecomputeMapper.map(invalidResponse)).thenReturn(null) // When @@ -778,7 +784,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(mockResponse) + .thenReturn(PrecomputedAssignmentsResponse(mockResponse, null)) whenever(mockPrecomputeMapper.map(mockResponse)).thenReturn(PrecomputedAssignments(expectedFlags)) // When @@ -796,7 +802,7 @@ internal class EvaluationsManagerTest { // Given val publicContext = EvaluationContext(fakeTargetingKey, emptyMap()) - whenever(mockFlagsRepository.hasFlags()).thenReturn(false) + whenever(mockFlagsRepository.hasConfiguration()).thenReturn(false) // When evaluationsManager.updateEvaluationsForContext(publicContext) @@ -813,7 +819,7 @@ internal class EvaluationsManagerTest { // Given val publicContext = EvaluationContext(fakeTargetingKey, emptyMap()) - whenever(mockFlagsRepository.hasFlags()).thenReturn(true) + whenever(mockFlagsRepository.hasConfiguration()).thenReturn(true) whenever(mockFlagsRepository.getEvaluationContext()).thenReturn(publicContext) // When @@ -832,7 +838,7 @@ internal class EvaluationsManagerTest { val requestedContext = EvaluationContext(fakeTargetingKey, emptyMap()) val cachedContext = EvaluationContext("different-user", emptyMap()) - whenever(mockFlagsRepository.hasFlags()).thenReturn(true) + whenever(mockFlagsRepository.hasConfiguration()).thenReturn(true) whenever(mockFlagsRepository.getEvaluationContext()).thenReturn(cachedContext) whenever( mockAssignmentsDownloader.readPrecomputedFlags( @@ -868,7 +874,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(jsonResponse) + .thenReturn(PrecomputedAssignmentsResponse(jsonResponse, null)) whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(PrecomputedAssignments(flagsMap)) // When @@ -887,7 +893,7 @@ internal class EvaluationsManagerTest { val publicContext = EvaluationContext(fakeTargetingKey, emptyMap()) val mockCallback = mock() - whenever(mockFlagsRepository.hasFlags()).thenReturn(false) + whenever(mockFlagsRepository.hasConfiguration()).thenReturn(false) // When evaluationsManager.updateEvaluationsForContext(publicContext, callback = mockCallback) @@ -905,7 +911,7 @@ internal class EvaluationsManagerTest { val publicContext = EvaluationContext(fakeTargetingKey, emptyMap()) val mockCallback = mock() - whenever(mockFlagsRepository.hasFlags()).thenReturn(true) + whenever(mockFlagsRepository.hasConfiguration()).thenReturn(true) // When evaluationsManager.updateEvaluationsForContext(publicContext, callback = mockCallback) @@ -931,7 +937,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(jsonResponse) + .thenReturn(PrecomputedAssignmentsResponse(jsonResponse, null)) whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(PrecomputedAssignments(flagsMap)) // When / Then - should not throw @@ -957,7 +963,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) + .thenReturn(EMPTY_FLAGS_RESPONSE) whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = 2_500L, @@ -1014,7 +1020,7 @@ internal class EvaluationsManagerTest { callbackError = error } } - whenever(mockFlagsRepository.hasFlags()).thenReturn(true) + whenever(mockFlagsRepository.hasConfiguration()).thenReturn(true) whenever(mockFlagsRepository.getEvaluationContext()).thenReturn(context) whenever( mockAssignmentsDownloader.readPrecomputedFlags( @@ -1055,8 +1061,8 @@ internal class EvaluationsManagerTest { onStateChanged = FlagsStateListener::onStateChanged ) ) - whenever(mockFlagsRepository.hasFlags()).thenReturn(false) - whenever(mockFlagsRepository.hasLoadedFlagsForContext(context)).thenReturn(true) + whenever(mockFlagsRepository.hasConfiguration()).thenReturn(false) + whenever(mockFlagsRepository.hasConfigurationForContext(context)).thenReturn(true) whenever( mockAssignmentsDownloader.readPrecomputedFlags( eq(context), @@ -1109,7 +1115,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) + .thenReturn(EMPTY_FLAGS_RESPONSE) whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val stateManager = FlagsStateManager( DDCoreStateHolder.create( @@ -1160,7 +1166,7 @@ internal class EvaluationsManagerTest { val callbackCompleted = CountDownLatch(1) var timeoutAction: (() -> Unit)? = null whenever(mockExecutorService.execute(any())).thenAnswer { null } - whenever(mockFlagsRepository.hasFlags()).thenAnswer { + whenever(mockFlagsRepository.hasConfiguration()).thenAnswer { cacheReadStarted.countDown() check(releaseCacheRead.await(1, TimeUnit.SECONDS)) false @@ -1272,7 +1278,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) + .thenReturn(EMPTY_FLAGS_RESPONSE) whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = 2_500L, @@ -1316,7 +1322,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) + .thenReturn(EMPTY_FLAGS_RESPONSE) whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = 2_500L, @@ -1349,7 +1355,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) + .thenReturn(EMPTY_FLAGS_RESPONSE) whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenAnswer { checkNotNull(timeoutAction).invoke() PrecomputedAssignments(emptyMap()) @@ -1384,7 +1390,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) + .thenReturn(EMPTY_FLAGS_RESPONSE) whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = 2_500L, @@ -1415,7 +1421,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) + .thenReturn(EMPTY_FLAGS_RESPONSE) whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val testedManager = createManager( initializationTimeoutMs = null, @@ -1476,7 +1482,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(EMPTY_FLAGS_RESPONSE_JSON) + .thenReturn(EMPTY_FLAGS_RESPONSE) whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) val stateManager = FlagsStateManager( DDCoreStateHolder.create( @@ -1607,7 +1613,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(jsonResponse) + .thenReturn(PrecomputedAssignmentsResponse(jsonResponse, null)) whenever(mockPrecomputeMapper.map(jsonResponse)).thenReturn(PrecomputedAssignments(flagsMap)) // When @@ -1622,7 +1628,7 @@ internal class EvaluationsManagerTest { // Given whenever(mockSdkCore.internalLogger).thenReturn(mockInternalLogger) whenever(mockSdkCore.timeProvider).thenReturn(mock()) - val repository = DefaultFlagsRepository(mockSdkCore, "late-success", mock(), persistenceLoadTimeoutMs = 1) + val repository = DefaultFlagsRepository(mockSdkCore, "late-success", mock(), null, persistenceLoadTimeoutMs = 1) val state = FlagsStateManager( DDCoreStateHolder.create(FlagsClientState.NotReady, FlagsStateListener::onStateChanged) ) @@ -1658,7 +1664,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn("response") + .thenReturn(PrecomputedAssignmentsResponse("response", null)) whenever(mockPrecomputeMapper.map("response")) .thenReturn(PrecomputedAssignments(mapOf("enabled" to mock()))) @@ -1691,7 +1697,7 @@ internal class EvaluationsManagerTest { whenever(mockSdkCore.timeProvider).thenReturn(mock()) doThrow(storageError).whenever(store) .setValue(any(), any(), any(), anyOrNull(), any()) - val repository = DefaultFlagsRepository(mockSdkCore, "throwing", store, persistenceLoadTimeoutMs = 1) + val repository = DefaultFlagsRepository(mockSdkCore, "throwing", store, null, persistenceLoadTimeoutMs = 1) val state = FlagsStateManager( DDCoreStateHolder.create(FlagsClientState.NotReady, FlagsStateListener::onStateChanged) ) @@ -1731,7 +1737,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn("response") + .thenReturn(PrecomputedAssignmentsResponse("response", null)) whenever(mockPrecomputeMapper.map("response")).thenReturn(PrecomputedAssignments(emptyMap())) // When @@ -1766,7 +1772,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(ENCODED_RESPONSE_JSON) + .thenReturn(PrecomputedAssignmentsResponse(ENCODED_RESPONSE_JSON, null)) // When testedManager.updateEvaluationsForContext(context, mockCallback) @@ -1783,7 +1789,7 @@ internal class EvaluationsManagerTest { eq(fakeDatadogContext), isA() ) - ).thenReturn(invalidResponse) + ).thenReturn(PrecomputedAssignmentsResponse(invalidResponse, null)) // When testedManager.updateEvaluationsForContext(context, mockCallback) @@ -1804,7 +1810,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(invalidResponse) + .thenReturn(PrecomputedAssignmentsResponse(invalidResponse, null)) // When testedManager.updateEvaluationsForContext(otherContext, mockCallback) @@ -1832,7 +1838,7 @@ internal class EvaluationsManagerTest { isA() ) ) - .thenReturn(ENCODED_RESPONSE_JSON) + .thenReturn(PrecomputedAssignmentsResponse(ENCODED_RESPONSE_JSON, null)) // When testedManager.updateEvaluationsForContext(context, mockCallback) @@ -1841,7 +1847,7 @@ internal class EvaluationsManagerTest { val failure = argumentCaptor() verify(mockCallback).onFailure(failure.capture()) assertThat(failure.firstValue.message).isEqualTo("Unable to read the feature flags response.") - assertThat(testedRepository.hasFlags()).isFalse() + assertThat(testedRepository.hasConfiguration()).isFalse() assertThat(testedRepository.getAssignmentsSnapshot()?.flags.orEmpty()).isEmpty() } @@ -1861,11 +1867,12 @@ internal class EvaluationsManagerTest { val persistedEntry = FlagsStateEntry( evaluationContext = context, flags = mapOf("cached-flag" to flag), - lastUpdateTimestamp = 0L + lastUpdateTimestamp = 0L, + flagSelection = null ) // Configure DataStore to fire callback synchronously during DefaultFlagsRepository - // construction, so the persistence latch is counted down before hasFlags() is called. + // construction, so the persistence latch is counted down before hasConfiguration() is called. val mockDataStore = mock() whenever( mockDataStore.value( @@ -1886,6 +1893,7 @@ internal class EvaluationsManagerTest { val realRepository = DefaultFlagsRepository( featureSdkCore = mockSdkCore, dataStore = mockDataStore, + flagSelection = null, instanceName = "integration-test" ) @@ -1917,6 +1925,200 @@ internal class EvaluationsManagerTest { // endregion @ParameterizedTest + @ValueSource(strings = ["too_many_values", "tag_load_failed", "unknown"]) + fun `M log the reason and install W updateEvaluationsForContext() { flag selection ignored }`( + fakeIgnoredReason: String + ) { + // Given + val context = EvaluationContext(fakeTargetingKey, emptyMap()) + whenever( + mockAssignmentsDownloader.readPrecomputedFlags( + eq(context), + eq(fakeDatadogContext), + isA() + ) + ) + .thenReturn(PrecomputedAssignmentsResponse(EMPTY_FLAGS_RESPONSE_JSON, fakeIgnoredReason)) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) + + // When + evaluationsManager.updateEvaluationsForContext(context) + + // Then + mockInternalLogger.verifyLog( + InternalLogger.Level.ERROR, + InternalLogger.Target.USER, + "flagSelection was ignored by the server ($fakeIgnoredReason). Loaded the full flag configuration." + ) + mockInternalLogger.verifyLog( + InternalLogger.Level.DEBUG, + InternalLogger.Target.TELEMETRY, + "Flag selection ignored", + onlyOnce = true, + additionalProperties = mapOf("reason" to fakeIgnoredReason) + ) + assertThat(capturedLogMessages()).contains("Successfully processed context $fakeTargetingKey with 0 flags") + verify(mockFlagsRepository).installFlagsAndContext(eq(context), eq(emptyMap()), anyOrNull()) + verify(mockFlagsStateManager).updateState(FlagsClientState.Ready) + } + + @Test + fun `M not log the ignored reason W updateEvaluationsForContext() { ignored reason, invalid body }`( + @StringForgery fakeInvalidBody: String + ) { + // Given + val context = EvaluationContext(fakeTargetingKey, emptyMap()) + whenever( + mockAssignmentsDownloader.readPrecomputedFlags( + eq(context), + eq(fakeDatadogContext), + isA() + ) + ).thenReturn(PrecomputedAssignmentsResponse(fakeInvalidBody, "too_many_values")) + whenever(mockPrecomputeMapper.map(fakeInvalidBody)).thenReturn(null) + + // When + evaluationsManager.updateEvaluationsForContext(context) + + // Then + assertThat(capturedLogMessages()).noneMatch { "flagSelection" in it || "Flag selection" in it } + verify(mockFlagsRepository, never()).installFlagsAndContext(any(), any(), anyOrNull()) + verify(mockFlagsStateManager).updateState(isA()) + } + + @Test + fun `M not log the ignored reason W updateEvaluationsForContext() { ignored reason, unsupported encoding }`() { + // Given + fakeDatadogContext = fakeDatadogContext.copy(source = "react-native") + val context = EvaluationContext(fakeTargetingKey, emptyMap()) + whenever( + mockAssignmentsDownloader.readPrecomputedFlags( + eq(context), + eq(fakeDatadogContext), + isA() + ) + ).thenReturn(PrecomputedAssignmentsResponse(ENCODED_RESPONSE_JSON, "too_many_values")) + whenever(mockPrecomputeMapper.map(ENCODED_RESPONSE_JSON)).thenReturn( + PrecomputedAssignments(emptyMap(), FlagKeyObfuscation.fromSalt("000102030405060708090a0b0c0d0e0f")) + ) + + // When + evaluationsManager.updateEvaluationsForContext(context) + + // Then + assertThat(capturedLogMessages()).noneMatch { "flagSelection" in it || "Flag selection" in it } + verify(mockFlagsRepository, never()).installFlagsAndContext(any(), any(), anyOrNull()) + verify(mockFlagsStateManager).updateState(isA()) + } + + @ParameterizedTest + @EnumSource(ExperimentationOptOutMode::class, names = ["IMMEDIATE", "FUTURE_EXPERIMENTS_ONLY"]) + fun `M not log the ignored reason W updateEvaluationsForContext() { ignored reason, superseded request }`( + mode: ExperimentationOptOutMode, + forge: Forge + ) { + // Given + val tasks = mutableListOf() + val testedManager = queuedManager(mode, tasks) + val first = EvaluationContext(forge.anAlphabeticalString()) + val firstCallback = mock() + whenever(mockAssignmentsDownloader.readPrecomputedFlags(eq(first), any(), any())) doReturn + PrecomputedAssignmentsResponse("response", "too_many_values") + whenever(mockPrecomputeMapper.map(any(), anyOrNull())) doReturn PrecomputedAssignments(emptyMap(), null) + testedManager.updateEvaluationsForContext(first, firstCallback) + testedManager.updateEvaluationsForContext(EvaluationContext(forge.anAlphabeticalString())) + + // When + tasks[0].run() + + // Then + verify(firstCallback).onFailure(isA()) + assertThat(capturedLogMessages()).noneMatch { + "flagSelection" in it || "Flag selection" in it || "Successfully processed" in it + } + } + + @Test + fun `M not log a flag selection message W updateEvaluationsForContext() { no ignored reason }`() { + // Given + val context = EvaluationContext(fakeTargetingKey, emptyMap()) + whenever( + mockAssignmentsDownloader.readPrecomputedFlags( + eq(context), + eq(fakeDatadogContext), + isA() + ) + ) + .thenReturn(EMPTY_FLAGS_RESPONSE) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) + + // When + evaluationsManager.updateEvaluationsForContext(context) + + // Then + assertThat(capturedLogMessages()).noneMatch { "flagSelection" in it || "Flag selection" in it } + verify(mockFlagsRepository).installFlagsAndContext(eq(context), eq(emptyMap()), anyOrNull()) + } + + @Test + fun `M notify STALE W updateEvaluationsForContext() { failed fetch after an empty configuration }`() { + // Given + val context = EvaluationContext(fakeTargetingKey, emptyMap()) + val mockDataStore = mock() + whenever( + mockDataStore.value( + key = any(), + version = anyOrNull(), + callback = any(), + deserializer = any() + ) + ).doAnswer { + it.getArgument>(2).onFailure() + null + } + whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger + whenever(mockSdkCore.timeProvider) doReturn mock() + val realRepository = DefaultFlagsRepository( + featureSdkCore = mockSdkCore, + dataStore = mockDataStore, + flagSelection = null, + instanceName = "empty-configuration-test" + ) + whenever( + mockAssignmentsDownloader.readPrecomputedFlags( + eq(context), + eq(fakeDatadogContext), + isA() + ) + ) + .thenReturn(EMPTY_FLAGS_RESPONSE, null) + whenever(mockPrecomputeMapper.map(EMPTY_FLAGS_RESPONSE_JSON)).thenReturn(PrecomputedAssignments(emptyMap())) + val testedManager = EvaluationsManager( + sdkCore = mockSdkCore, + executorService = mockExecutorService, + internalLogger = mockInternalLogger, + flagsRepository = realRepository, + assignmentsReader = mockAssignmentsDownloader, + precomputeMapper = mockPrecomputeMapper, + flagStateManager = mockFlagsStateManager, + initializationTimeoutMs = null, + initializationTimeoutScheduler = { _, _ -> {} }, + experimentationOptOut = ExperimentationOptOut() + ) + + // When + testedManager.updateEvaluationsForContext(context) + testedManager.updateEvaluationsForContext(context) + + // Then + inOrder(mockFlagsStateManager) { + verify(mockFlagsStateManager).updateState(FlagsClientState.Reconciling) + verify(mockFlagsStateManager).updateState(FlagsClientState.Ready) + verify(mockFlagsStateManager).updateState(FlagsClientState.Reconciling) + verify(mockFlagsStateManager).updateState(FlagsClientState.Stale) + } + } + @CsvSource("IMMEDIATE,true", "IMMEDIATE,false", "FUTURE_EXPERIMENTS_ONLY,true", "FUTURE_EXPERIMENTS_ONLY,false") fun `M fail superseded callback W updateEvaluationsForContext() {superseded fetch fails}`( mode: ExperimentationOptOutMode, @@ -1930,7 +2132,7 @@ internal class EvaluationsManagerTest { val firstCallback = mock() val newerCallback = mock() whenever(mockAssignmentsDownloader.readPrecomputedFlags(eq(first), any(), any())) doReturn - if (networkFailed) null else "unreadable" + if (networkFailed) null else PrecomputedAssignmentsResponse("unreadable", null) testedManager.updateEvaluationsForContext(first, firstCallback) testedManager.updateEvaluationsForContext(EvaluationContext(forge.anAlphabeticalString()), newerCallback) @@ -2000,7 +2202,8 @@ internal class EvaluationsManagerTest { var cancellations = 0 val testedManager = queuedManager(mode, tasks, timeouts, onCancel = { cancellations++ }) val firstCallback = mock() - whenever(mockAssignmentsDownloader.readPrecomputedFlags(any(), any(), any())) doReturn "response" + whenever(mockAssignmentsDownloader.readPrecomputedFlags(any(), any(), any())) doReturn + PrecomputedAssignmentsResponse("response", null) whenever(mockPrecomputeMapper.map(any(), anyOrNull())) doReturn PrecomputedAssignments(emptyMap(), null) testedManager.updateEvaluationsForContext(EvaluationContext(forge.anAlphabeticalString()), firstCallback) testedManager.updateEvaluationsForContext(EvaluationContext(forge.anAlphabeticalString())) @@ -2037,7 +2240,8 @@ internal class EvaluationsManagerTest { val testedManager = queuedManager(mode, tasks, timeouts) val callback = mock() if (succeeds) { - whenever(mockAssignmentsDownloader.readPrecomputedFlags(any(), any(), any())) doReturn "response" + whenever(mockAssignmentsDownloader.readPrecomputedFlags(any(), any(), any())) doReturn + PrecomputedAssignmentsResponse("response", null) whenever(mockPrecomputeMapper.map(any(), anyOrNull())) doReturn PrecomputedAssignments(emptyMap(), null) } testedManager.updateEvaluationsForContext(EvaluationContext(forge.anAlphabeticalString()), callback) @@ -2138,7 +2342,7 @@ internal class EvaluationsManagerTest { doAnswer { it.getArgument>(2).onFailure() }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) - return DefaultFlagsRepository(mockSdkCore, fakeTargetingKey, mockDataStore) + return DefaultFlagsRepository(mockSdkCore, fakeTargetingKey, mockDataStore, null) } private fun createManagerWithRepository(repository: FlagsRepository): EvaluationsManager = EvaluationsManager( @@ -2171,6 +2375,19 @@ internal class EvaluationsManagerTest { experimentationOptOut = ExperimentationOptOut() ) + private fun capturedLogMessages(): List { + val messages = argumentCaptor<() -> String>() + verify(mockInternalLogger, atLeast(0)).log( + any(), + any(), + messages.capture(), + anyOrNull(), + any(), + anyOrNull() + ) + return messages.allValues.map { it() } + } + companion object { private const val QUEUED_TIMEOUT_MS = 1_000L private val ENCODED_RESPONSE_JSON = """ @@ -2185,6 +2402,7 @@ internal class EvaluationsManagerTest { }}} """.trimIndent() private const val EMPTY_FLAGS_RESPONSE_JSON = "{\"data\": {\"attributes\": {\"flags\": {}}}}" + private val EMPTY_FLAGS_RESPONSE = PrecomputedAssignmentsResponse(EMPTY_FLAGS_RESPONSE_JSON, null) } @ParameterizedTest @@ -2200,9 +2418,10 @@ internal class EvaluationsManagerTest { lateinit var testedManager: EvaluationsManager whenever(mockAssignmentsDownloader.readPrecomputedFlags(eq(first), any(), any())).thenAnswer { testedManager.updateEvaluationsForContext(newer, newerCallback) - "first-response" + PrecomputedAssignmentsResponse("first-response", null) } - whenever(mockAssignmentsDownloader.readPrecomputedFlags(eq(newer), any(), any())) doReturn "newer-response" + whenever(mockAssignmentsDownloader.readPrecomputedFlags(eq(newer), any(), any())) doReturn + PrecomputedAssignmentsResponse("newer-response", null) whenever(mockPrecomputeMapper.map(any(), anyOrNull())) doReturn PrecomputedAssignments(emptyMap(), null) testedManager = EvaluationsManager( mockSdkCore, mockExecutorService, mockInternalLogger, mockFlagsRepository, @@ -2229,7 +2448,7 @@ internal class EvaluationsManagerTest { // Given doAnswer { null }.whenever(mockExecutorService).execute(any()) val fakeNewer = EvaluationContext("newer-subject") - whenever(mockFlagsRepository.hasLoadedFlagsForContext(fakeNewer)) doReturn cached + whenever(mockFlagsRepository.hasConfigurationForContext(fakeNewer)) doReturn cached lateinit var timeout: () -> Unit val manager = EvaluationsManager( mockSdkCore, @@ -2260,6 +2479,6 @@ internal class EvaluationsManagerTest { } else { verify(mockFlagsStateManager).updateState(isA()) } - verify(mockFlagsRepository).hasLoadedFlagsForContext(fakeNewer) + verify(mockFlagsRepository).hasConfigurationForContext(fakeNewer) } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsDownloaderTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsDownloaderTest.kt index 2d97e41432..1348a73465 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsDownloaderTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsDownloaderTest.kt @@ -18,6 +18,7 @@ import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import okhttp3.Call +import okhttp3.Headers import okhttp3.MediaType.Companion.toMediaType import okhttp3.Protocol import okhttp3.Request @@ -29,6 +30,8 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings @@ -110,10 +113,111 @@ internal class PrecomputedAssignmentsDownloaderTest { val result = testedDownloader.readPrecomputedFlags(fakeEvaluationContext, fakeDatadogContext, fakeDiagnostics) // Then - assertThat(result).isEqualTo(fakeResponseBody) + assertThat(result).isEqualTo(PrecomputedAssignmentsResponse(fakeResponseBody, null)) verify(mockRequestFactory).create(fakeEvaluationContext, fakeDatadogContext, fakeDiagnostics) } + @ParameterizedTest + @ValueSource( + strings = ["malformed_selection", "too_many_values", "invalid_key_pattern", "invalid_tag", "tag_load_failed"] + ) + fun `M return the known reason W readPrecomputedFlags() { selection sent, ignored header }`( + fakeIgnoredReason: String, + forge: Forge + ) { + // Given + val fakeResponseBody = forge.anAlphabeticalString() + val fakeUrl = "https://${forge.anAlphabeticalString()}.com/precompute" + stubSuccessfulCall(fakeResponseBody, fakeUrl, ignoredReason = fakeIgnoredReason) + whenever(mockRequestFactory.sendsFlagSelection) doReturn true + + // When + val result = testedDownloader.readPrecomputedFlags(fakeEvaluationContext, fakeDatadogContext, fakeDiagnostics) + + // Then + assertThat(result).isEqualTo(PrecomputedAssignmentsResponse(fakeResponseBody, fakeIgnoredReason)) + } + + @ParameterizedTest + @ValueSource(strings = ["Some Future-Reason", "Too_Many_Values", "too_many_values\u0001", "too_many_values\u00E9"]) + fun `M return the fallback reason W readPrecomputedFlags() { selection sent, unknown ignored header }`( + fakeIgnoredReason: String, + @StringForgery fakeResponseBody: String, + @StringForgery(regex = "https://[a-z]+\\.(com|net)/[a-z]+") fakeUrl: String + ) { + // Given + val fakeRequest = Request.Builder().url(fakeUrl).build() + val fakeResponse = createSuccessfulResponse(fakeResponseBody, fakeUrl) + .newBuilder() + .headers(Headers.Builder().addUnsafeNonAscii("DD-Flag-Selection-Ignored", fakeIgnoredReason).build()) + .build() + whenever(mockRequestFactory.create(fakeEvaluationContext, fakeDatadogContext, fakeDiagnostics)) + .doReturn(fakeRequest) + whenever(mockCallFactory.newCall(fakeRequest)).doReturn(mockCall) + whenever(mockCall.execute()).doReturn(fakeResponse) + whenever(mockRequestFactory.sendsFlagSelection) doReturn true + + // When + val result = testedDownloader.readPrecomputedFlags(fakeEvaluationContext, fakeDatadogContext, fakeDiagnostics) + + // Then + assertThat(result).isEqualTo(PrecomputedAssignmentsResponse(fakeResponseBody, "unknown")) + } + + @Test + fun `M return the ignored reason W readPrecomputedFlags() { selection sent, lowercase header name }`( + @StringForgery fakeResponseBody: String, + @StringForgery(regex = "https://[a-z]+\\.(com|net)/[a-z]+") fakeUrl: String + ) { + // Given + stubSuccessfulCall( + fakeResponseBody, + fakeUrl, + ignoredReason = "too_many_values", + headerName = "dd-flag-selection-ignored" + ) + whenever(mockRequestFactory.sendsFlagSelection) doReturn true + + // When + val result = testedDownloader.readPrecomputedFlags(fakeEvaluationContext, fakeDatadogContext, fakeDiagnostics) + + // Then + assertThat(result).isEqualTo(PrecomputedAssignmentsResponse(fakeResponseBody, "too_many_values")) + } + + @Test + fun `M return no ignored reason W readPrecomputedFlags() { selection sent, empty ignored header }`( + @StringForgery fakeResponseBody: String, + @StringForgery(regex = "https://[a-z]+\\.(com|net)/[a-z]+") fakeUrl: String + ) { + // Given + stubSuccessfulCall(fakeResponseBody, fakeUrl, ignoredReason = "") + whenever(mockRequestFactory.sendsFlagSelection) doReturn true + + // When + val result = testedDownloader.readPrecomputedFlags(fakeEvaluationContext, fakeDatadogContext, fakeDiagnostics) + + // Then + assertThat(result).isEqualTo(PrecomputedAssignmentsResponse(fakeResponseBody, null)) + } + + @Test + fun `M return no ignored reason W readPrecomputedFlags() { no selection sent, ignored header }`( + @StringForgery fakeResponseBody: String, + @StringForgery(regex = "[a-z_]+") fakeIgnoredReason: String, + @StringForgery(regex = "https://[a-z]+\\.(com|net)/[a-z]+") fakeUrl: String + ) { + // Given + stubSuccessfulCall(fakeResponseBody, fakeUrl, ignoredReason = fakeIgnoredReason) + whenever(mockRequestFactory.sendsFlagSelection) doReturn false + + // When + val result = testedDownloader.readPrecomputedFlags(fakeEvaluationContext, fakeDatadogContext, fakeDiagnostics) + + // Then + assertThat(result).isEqualTo(PrecomputedAssignmentsResponse(fakeResponseBody, null)) + } + @Test fun `M call factory with correct parameters W readPrecomputedFlags()`( @StringForgery fakeResponseBody: String, @@ -398,6 +502,23 @@ internal class PrecomputedAssignmentsDownloaderTest { // region Helper methods + private fun stubSuccessfulCall( + body: String, + url: String, + ignoredReason: String, + headerName: String = "DD-Flag-Selection-Ignored" + ) { + val fakeRequest = Request.Builder().url(url).build() + val fakeResponse = createSuccessfulResponse(body, url) + .newBuilder() + .header(headerName, ignoredReason) + .build() + whenever(mockRequestFactory.create(fakeEvaluationContext, fakeDatadogContext, fakeDiagnostics)) + .doReturn(fakeRequest) + whenever(mockCallFactory.newCall(fakeRequest)) doReturn mockCall + whenever(mockCall.execute()) doReturn fakeResponse + } + private fun createSuccessfulResponse(body: String, url: String): Response = Response.Builder() .request(Request.Builder().url(url).build()) .protocol(Protocol.HTTP_1_1) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt index 2ebd857fbf..ccbe3fabbb 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/net/PrecomputedAssignmentsRequestFactoryTest.kt @@ -15,6 +15,7 @@ import com.datadog.android.flags.internal.evaluation.ExperimentationOptOut import com.datadog.android.flags.internal.model.PrecomputedAssignmentsRequest import com.datadog.android.flags.internal.model.PrecomputedAssignmentsRequest.Diagnostics import com.datadog.android.flags.internal.model.PrecomputedAssignmentsRequest.Trigger +import com.datadog.android.flags.internal.selection.CanonicalFlagSelection import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.utils.forge.ForgeConfigurator import com.datadog.android.internal.rum.RumContext @@ -33,7 +34,9 @@ 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.CsvSource import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings @@ -67,7 +70,7 @@ internal class PrecomputedAssignmentsRequestFactoryTest { ) ).withRumContext(fakeRumContext) - testedFactory = PrecomputedAssignmentsRequestFactory(mockInternalLogger, null) + testedFactory = PrecomputedAssignmentsRequestFactory(mockInternalLogger, null, null) } @Test @@ -199,7 +202,7 @@ internal class PrecomputedAssignmentsRequestFactoryTest { ) { // Given val fakeCustomEndpoint = "https://custom-proxy.example.com/flags/assignments" - testedFactory = PrecomputedAssignmentsRequestFactory(mockInternalLogger, fakeCustomEndpoint) + testedFactory = PrecomputedAssignmentsRequestFactory(mockInternalLogger, fakeCustomEndpoint, null) val context = EvaluationContext( targetingKey = fakeTargetingKey, attributes = emptyMap() @@ -220,7 +223,7 @@ internal class PrecomputedAssignmentsRequestFactoryTest { @EnumSource(ExperimentationOptOutMode::class) fun `M omit evaluation options W create() {timestamp absent}`(mode: ExperimentationOptOutMode) { // Given - testedFactory = PrecomputedAssignmentsRequestFactory(mockInternalLogger, null, mode) + testedFactory = PrecomputedAssignmentsRequestFactory(mockInternalLogger, null, null, mode) val context = EvaluationContext("subject") // When @@ -238,7 +241,7 @@ internal class PrecomputedAssignmentsRequestFactoryTest { mode: ExperimentationOptOutMode ) { // Given - testedFactory = PrecomputedAssignmentsRequestFactory(mockInternalLogger, null, mode) + testedFactory = PrecomputedAssignmentsRequestFactory(mockInternalLogger, null, null, mode) val context = EvaluationContext( "subject", mapOf(ExperimentationOptOut.ATTRIBUTE to "2026-10-01T00:00:00Z") @@ -324,6 +327,111 @@ internal class PrecomputedAssignmentsRequestFactoryTest { assertThat(targetingAttributes.length()).isEqualTo(0) } + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `M add flag_selection to attributes W create() { flag selection configured }`( + useCustomEndpoint: Boolean, + @StringForgery fakeTargetingKey: String + ) { + // Given + val fakeCustomEndpoint = "https://custom-proxy.example.com/flags/assignments".takeIf { useCustomEndpoint } + testedFactory = PrecomputedAssignmentsRequestFactory( + mockInternalLogger, + fakeCustomEndpoint, + CanonicalFlagSelection(tags = listOf("mobile", "web"), keyPatterns = listOf("checkout-*")) + ) + val context = EvaluationContext(targetingKey = fakeTargetingKey, attributes = emptyMap()) + + // When + val request = testedFactory.create(context, fakeDatadogContext, fakeDiagnostics) + + // Then + checkNotNull(request) + val flagSelection = JSONObject(extractRequestBodyAsString(request)) + .getJSONObject("data") + .getJSONObject("attributes") + .getJSONObject("flag_selection") + assertThat(flagSelection.keys().asSequence().toList()).containsExactlyInAnyOrder("tags", "key_patterns") + assertThat(flagSelection.getJSONArray("tags").toString()).isEqualTo("""["mobile","web"]""") + assertThat(flagSelection.getJSONArray("key_patterns").toString()).isEqualTo("""["checkout-*"]""") + assertThat(testedFactory.sendsFlagSelection).isTrue() + } + + @ParameterizedTest + @CsvSource("IMMEDIATE,immediate", "FUTURE_EXPERIMENTS_ONLY,future_experiments_only") + fun `M send flag_selection and evaluation_options W create() { selection and opt-out }`( + mode: ExperimentationOptOutMode, + expectedWireMode: String + ) { + // Given + testedFactory = PrecomputedAssignmentsRequestFactory( + mockInternalLogger, + null, + CanonicalFlagSelection(tags = listOf("mobile"), keyPatterns = listOf("checkout-*")), + mode + ) + val context = EvaluationContext( + "subject", + mapOf(ExperimentationOptOut.ATTRIBUTE to "2026-10-01T00:00:00Z") + ) + + // When + val request = checkNotNull(testedFactory.create(context, fakeDatadogContext, fakeDiagnostics)) + + // Then + val attributes = JSONObject(extractRequestBodyAsString(request)) + .getJSONObject("data").getJSONObject("attributes") + val flagSelection = attributes.getJSONObject("flag_selection") + assertThat(flagSelection.getJSONArray("tags").toString()).isEqualTo("""["mobile"]""") + assertThat(flagSelection.getJSONArray("key_patterns").toString()).isEqualTo("""["checkout-*"]""") + assertThat(attributes.getJSONObject("evaluation_options").getString("experimentation_opt_out_mode")) + .isEqualTo(expectedWireMode) + } + + @Test + fun `M send both lists W create() { flag selection with key patterns only }`( + @StringForgery fakeTargetingKey: String + ) { + // Given + testedFactory = PrecomputedAssignmentsRequestFactory( + mockInternalLogger, + null, + CanonicalFlagSelection(tags = emptyList(), keyPatterns = listOf("checkout-*")) + ) + val context = EvaluationContext(targetingKey = fakeTargetingKey, attributes = emptyMap()) + + // When + val request = testedFactory.create(context, fakeDatadogContext, fakeDiagnostics) + + // Then + checkNotNull(request) + val flagSelection = JSONObject(extractRequestBodyAsString(request)) + .getJSONObject("data") + .getJSONObject("attributes") + .getJSONObject("flag_selection") + assertThat(flagSelection.getJSONArray("tags").length()).isZero() + assertThat(flagSelection.getJSONArray("key_patterns").toString()).isEqualTo("""["checkout-*"]""") + } + + @Test + fun `M not add flag_selection W create() { no flag selection }`( + @StringForgery fakeTargetingKey: String + ) { + // Given + val context = EvaluationContext(targetingKey = fakeTargetingKey, attributes = emptyMap()) + + // When + val request = testedFactory.create(context, fakeDatadogContext, fakeDiagnostics) + + // Then + checkNotNull(request) + val attributes = JSONObject(extractRequestBodyAsString(request)) + .getJSONObject("data") + .getJSONObject("attributes") + assertThat(attributes.keys().asSequence().toList()).containsExactlyInAnyOrder("env", "source", "subject") + assertThat(testedFactory.sendsFlagSelection).isFalse() + } + // endregion // region create() - Error cases diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializerTest.kt index 7c39cebf7e..df9cedd096 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateDeserializerTest.kt @@ -12,6 +12,7 @@ import com.google.gson.JsonParser import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat +import org.json.JSONArray import org.json.JSONObject import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -358,6 +359,33 @@ internal class FlagsStateDeserializerTest { assertThat(result.evaluationContext.attributes["valid_boolean"]).isEqualTo(validBoolean) } + @Test + fun `M return no flag selection W deserialize() { state persisted without a flag selection }`(forge: Forge) { + // Given + val json = buildStateJson(forge.anAlphabeticalString(), JSONObject().put("flag1", buildFlagJson())) + + // When + val result = testedDeserializer.deserialize(json) + + // Then + checkNotNull(result) + assertThat(result.flagSelection).isNull() + } + + @Test + fun `M return null W deserialize() { malformed flag selection }`(forge: Forge) { + // Given + val json = JSONObject(buildStateJson(forge.anAlphabeticalString(), JSONObject())) + .put("flagSelection", JSONObject().put("tags", JSONArray())) + .toString() + + // When + val result = testedDeserializer.deserialize(json) + + // Then + assertThat(result).isNull() + } + @ParameterizedTest @ValueSource(strings = ["null", "[]", "true", "0", "\"text\""]) fun `M reject nonobject cache W deserialize() { generated model }`(json: String) { diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializerTest.kt index f774526dd0..106ebfae07 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/persistence/FlagsStateSerializerTest.kt @@ -10,6 +10,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.flags.internal.model.FlagKeyObfuscation import com.datadog.android.flags.internal.model.FlagsStateEntry import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.selection.CanonicalFlagSelection import com.datadog.android.flags.model.EvaluationContext import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.LongForgery @@ -188,4 +189,44 @@ internal class FlagsStateSerializerTest { SerializedFlagsStateAssert.assertThatSerializedFlagsState(serialized) .hasEmptyFlags() } + + @Test + fun `M round trip the flag selection W serialize() + deserialize()`(forge: Forge) { + // Given + val flagSelection = CanonicalFlagSelection( + tags = listOf("mobile", "\u00E9t\u00E9"), + keyPatterns = listOf("checkout-*") + ) + val flagsState = FlagsStateEntry( + EvaluationContext(forge.anAlphabeticalString(), emptyMap()), + emptyMap(), + fakeTimestamp, + flagSelection = flagSelection + ) + + // When + val serialized = testedSerializer.serialize(flagsState) + val deserialized = FlagsStateDeserializer(mockInternalLogger).deserialize(serialized) + + // Then + checkNotNull(deserialized) + assertThat(deserialized.flagSelection).isEqualTo(flagSelection) + } + + @Test + fun `M omit the flag selection W serialize() { no flag selection }`(forge: Forge) { + // Given + val flagsState = FlagsStateEntry( + EvaluationContext(forge.anAlphabeticalString(), emptyMap()), + emptyMap(), + fakeTimestamp, + null + ) + + // When + val serialized = testedSerializer.serialize(flagsState) + + // Then + assertThat(JSONObject(serialized).has("flagSelection")).isFalse() + } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryObfuscationTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryObfuscationTest.kt index 0031ac0edf..d0fc7c3914 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryObfuscationTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryObfuscationTest.kt @@ -91,7 +91,7 @@ internal class DefaultFlagsRepositoryObfuscationTest { whenever(mockSdkCore.source) doReturn "android" doAnswer { it.getArgument>(2).onFailure() } .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) - testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) + testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore, null) } @Test @@ -102,7 +102,7 @@ internal class DefaultFlagsRepositoryObfuscationTest { .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) // When - val testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) + val testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore, null) // Then val (flag, context) = checkNotNull(testedRepository.getPrecomputedFlagWithContext(fakeFlagKey)) @@ -125,7 +125,7 @@ internal class DefaultFlagsRepositoryObfuscationTest { .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) whenever(mockSdkCore.source) doReturn source val entry = cachedEntry(encoded) - testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) + testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore, null) val firstFlags = mutableListOf>() testedRepository.firstFlags.whenComplete { firstFlags.add(it) } @@ -136,8 +136,8 @@ internal class DefaultFlagsRepositoryObfuscationTest { // Then val readable = !encoded || source == "android" assertThat(result?.variationValue).isEqualTo(if (readable) fakeFlag.variationValue else null) - assertThat(testedRepository.hasFlags()).isEqualTo(readable) - assertThat(testedRepository.hasLoadedFlagsForContext(fakeContext)).isEqualTo(readable) + assertThat(testedRepository.hasConfiguration()).isEqualTo(readable) + assertThat(testedRepository.hasConfigurationForContext(fakeContext)).isEqualTo(readable) assertThat(testedRepository.getEvaluationContext()).isEqualTo(if (readable) fakeContext else null) assertThat(testedRepository.getPrecomputedFlagWithContext(fakeFlagKey) != null).isEqualTo(readable) assertThat(testedRepository.getFlagsSnapshot().isEmpty()).isEqualTo(encoded) @@ -170,7 +170,7 @@ internal class DefaultFlagsRepositoryObfuscationTest { whenever(mockSdkCore.source) doReturn "react-native" doAnswer { it.getArgument>(2).onSuccess(DataStoreContent(0, entry)) } .whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) - testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore) + testedRepository = DefaultFlagsRepository(mockSdkCore, fakeInstanceName, mockDataStore, null) val firstFlags = mutableListOf>() testedRepository.firstFlags.whenComplete { firstFlags.add(it) } assertThat(firstFlags).isEmpty() 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 2c57bb9858..d6bee059e3 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 @@ -11,13 +11,19 @@ import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.api.storage.datastore.DataStoreHandler import com.datadog.android.api.storage.datastore.DataStoreReadCallback import com.datadog.android.core.persistence.datastore.DataStoreContent +import com.datadog.android.flags.ExperimentationOptOutMode import com.datadog.android.flags.internal.model.FlagsStateEntry import com.datadog.android.flags.internal.model.PrecomputedAssignments import com.datadog.android.flags.internal.model.PrecomputedFlag +import com.datadog.android.flags.internal.selection.CanonicalFlagSelection +import com.datadog.android.flags.internal.selection.FlagSelection +import com.datadog.android.flags.internal.selection.FlagSelectionCanonicalizer +import com.datadog.android.flags.internal.selection.FlagSelectionResult import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.ResolutionReason import com.datadog.android.flags.utils.forge.ForgeConfigurator import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -27,6 +33,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension @@ -36,6 +43,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -90,10 +98,11 @@ internal class DefaultFlagsRepositoryTest { testedRepository = DefaultFlagsRepository( featureSdkCore = mockFeatureSdkCore, dataStore = mockDataStore, + flagSelection = null, instanceName = "default" ) - // Setup test fixtures for hasFlags tests + // Setup test fixtures for hasConfiguration tests testContext = EvaluationContext(forge.anAlphabeticalString(), emptyMap()) singleFlagMap = mapOf( @@ -154,6 +163,7 @@ internal class DefaultFlagsRepositoryTest { val testedRepository = DefaultFlagsRepository( featureSdkCore = mockFeatureSdkCore, dataStore = mockDataStore, + flagSelection = null, instanceName = forge.anAlphabeticalString(), persistenceLoadTimeoutMs = TimeUnit.SECONDS.toMillis(10) ) @@ -204,7 +214,7 @@ internal class DefaultFlagsRepositoryTest { } @Test - fun `M not wait for persistence W hasLoadedFlagsForContext() { persistence is loading }`(forge: Forge) { + fun `M not wait for persistence W hasConfigurationForContext() { persistence is loading }`(forge: Forge) { // Given doAnswer { null }.whenever(mockDataStore).value( key = any(), @@ -215,6 +225,7 @@ internal class DefaultFlagsRepositoryTest { val testedRepository = DefaultFlagsRepository( featureSdkCore = mockFeatureSdkCore, dataStore = mockDataStore, + flagSelection = null, instanceName = "loading", persistenceLoadTimeoutMs = TimeUnit.SECONDS.toMillis(10) ) @@ -222,7 +233,7 @@ internal class DefaultFlagsRepositoryTest { val completed = CountDownLatch(1) val context = EvaluationContext(forge.anAlphabeticalString(), emptyMap()) val readThread = Thread { - result.set(testedRepository.hasLoadedFlagsForContext(context)) + result.set(testedRepository.hasConfigurationForContext(context)) completed.countDown() }.apply { isDaemon = true } @@ -255,6 +266,7 @@ internal class DefaultFlagsRepositoryTest { val testedRepository = DefaultFlagsRepository( featureSdkCore = mockFeatureSdkCore, dataStore = mockDataStore, + flagSelection = null, instanceName = "async" ) val asyncThread = Thread { @@ -287,6 +299,7 @@ internal class DefaultFlagsRepositoryTest { val testedRepository = DefaultFlagsRepository( featureSdkCore = mockFeatureSdkCore, dataStore = mockDataStore, + flagSelection = null, instanceName = "timeout", persistenceLoadTimeoutMs = 1L ) @@ -411,7 +424,7 @@ internal class DefaultFlagsRepositoryTest { // Then assertThat(testedRepository.getEvaluationContext()).isEqualTo(context) assertThat(testedRepository.getFlagsSnapshot()).isEmpty() - assertThat(testedRepository.hasFlags()).isFalse() + assertThat(testedRepository.hasConfiguration()).isTrue() verify(mockDataStore, never()).setValue( key = any(), data = any(), @@ -456,26 +469,336 @@ internal class DefaultFlagsRepositoryTest { assertThat(entry.firstValue.flags).isEmpty() } - private fun preparePersistenceLoad(forge: Forge): DataStoreReadCallback { + @ParameterizedTest + @ValueSource(booleans = [true, false]) + fun `M ignore persisted state W initial disk read completes { different flag selection }`( + configureSelection: Boolean, + forge: Forge + ) { + // Given + val configuredSelection = CanonicalFlagSelection(listOf("mobile"), emptyList()).takeIf { configureSelection } + val persistedSelection = CanonicalFlagSelection(listOf("web"), emptyList()) + val flags = mapOf(forge.anAlphabeticalString() to forge.getForgery()) + val callback = preparePersistenceLoad(forge, configuredSelection) + + // When + callback.onSuccess( + DataStoreContent( + forge.anInt(), + FlagsStateEntry(forge.getForgery(), flags, forge.aLong(), flagSelection = persistedSelection) + ) + ) + + // Then + assertThat(testedRepository.getEvaluationContext()).isNull() + assertThat(testedRepository.hasConfiguration()).isFalse() + } + + @Test + fun `M ignore persisted state W initial disk read completes { state saved without a flag selection }`( + forge: Forge + ) { + // Given + val flags = mapOf(forge.anAlphabeticalString() to forge.getForgery()) + val callback = preparePersistenceLoad(forge, CanonicalFlagSelection(listOf("mobile"), emptyList())) + + // When + callback.onSuccess( + DataStoreContent(forge.anInt(), FlagsStateEntry(forge.getForgery(), flags, forge.aLong())) + ) + + // Then + assertThat(testedRepository.getEvaluationContext()).isNull() + assertThat(testedRepository.hasConfiguration()).isFalse() + } + + @Test + fun `M install persisted state W initial disk read completes { same flag selection }`(forge: Forge) { + // Given + val context = forge.getForgery() + val flagKey = forge.anAlphabeticalString() + val callback = preparePersistenceLoad(forge, CanonicalFlagSelection(listOf("mobile"), listOf("checkout-*"))) + + // When + callback.onSuccess( + DataStoreContent( + forge.anInt(), + FlagsStateEntry( + context, + mapOf(flagKey to forge.getForgery()), + forge.aLong(), + flagSelection = CanonicalFlagSelection(listOf("mobile"), listOf("checkout-*")) + ) + ) + ) + + // Then + assertThat(testedRepository.getEvaluationContext()).isEqualTo(context) + assertThat(testedRepository.getPrecomputedFlag(flagKey)).isNotNull() + } + + @Test + fun `M install persisted state W initial disk read completes { equivalent configured selection }`(forge: Forge) { + // Given + val context = forge.getForgery() + val flagKey = forge.anAlphabeticalString() + val persistedSelection = canonical(FlagSelection(tags = listOf("checkout"), keyPatterns = null)) + val configuredSelection = canonical(FlagSelection(tags = listOf(" checkout", "checkout"), keyPatterns = null)) + val callback = preparePersistenceLoad(forge, configuredSelection) + + // When + callback.onSuccess( + DataStoreContent( + forge.anInt(), + FlagsStateEntry( + context, + mapOf(flagKey to forge.getForgery()), + forge.aLong(), + flagSelection = persistedSelection + ) + ) + ) + + // Then + assertThat(testedRepository.getEvaluationContext()).isEqualTo(context) + assertThat(testedRepository.getPrecomputedFlag(flagKey)).isNotNull() + } + + @Test + fun `M persist the state under the selection key W setFlagsAndContext() { flag selection }`( + @StringForgery(regex = "fake-[a-z]{4,8}") fakeInstanceName: String, + forge: Forge + ) { + // Given + testedRepository = DefaultFlagsRepository( + mockFeatureSdkCore, + fakeInstanceName, + mockDataStore, + CanonicalFlagSelection(listOf("mobile"), emptyList()) + ) + + // When + testedRepository.setFlagsAndContext(forge.getForgery(), emptyMap()) + + // Then + verify(mockDataStore).setValue( + key = eq("flags-selection-state-$fakeInstanceName"), + data = any(), + version = any(), + callback = anyOrNull(), + serializer = any() + ) + verify(mockDataStore, never()).setValue( + key = eq("flags-state-$fakeInstanceName"), + data = any(), + version = any(), + callback = anyOrNull(), + serializer = any() + ) + } + + @Test + fun `M persist the state under the unselected key W setFlagsAndContext() { no flag selection }`( + @StringForgery(regex = "fake-[a-z]{4,8}") fakeInstanceName: String, + forge: Forge + ) { + // Given + testedRepository = DefaultFlagsRepository(mockFeatureSdkCore, fakeInstanceName, mockDataStore, null) + + // When + testedRepository.setFlagsAndContext(forge.getForgery(), emptyMap()) + + // Then + verify(mockDataStore).setValue( + key = eq("flags-state-$fakeInstanceName"), + data = any(), + version = any(), + callback = anyOrNull(), + serializer = any() + ) + } + + @Test + fun `M read the selection key and remove the unselected state W init { flag selection }`( + @StringForgery(regex = "fake-[a-z]{4,8}") fakeInstanceName: String + ) { + // When + testedRepository = DefaultFlagsRepository( + mockFeatureSdkCore, + fakeInstanceName, + mockDataStore, + CanonicalFlagSelection(listOf("mobile"), emptyList()) + ) + + // Then + verify(mockDataStore).value( + key = eq("flags-selection-state-$fakeInstanceName"), + version = anyOrNull(), + callback = any(), + deserializer = any() + ) + verify(mockDataStore).removeValue(eq("flags-state-$fakeInstanceName"), anyOrNull()) + } + + @Test + fun `M read the unselected key and remove the selected state W init { no flag selection }`( + @StringForgery(regex = "fake-[a-z]{4,8}") fakeInstanceName: String + ) { + // When + testedRepository = DefaultFlagsRepository(mockFeatureSdkCore, fakeInstanceName, mockDataStore, null) + + // Then + verify(mockDataStore).value( + key = eq("flags-state-$fakeInstanceName"), + version = anyOrNull(), + callback = any(), + deserializer = any() + ) + verify(mockDataStore).removeValue(eq("flags-selection-state-$fakeInstanceName"), anyOrNull()) + } + + @ParameterizedTest + @CsvSource( + "DISABLED,false,flags-state,flags-selection-state", + "DISABLED,true,flags-selection-state,flags-state", + "IMMEDIATE,false,flags-immediate-v1-state,flags-selection-immediate-v1-state", + "IMMEDIATE,true,flags-selection-immediate-v1-state,flags-immediate-v1-state", + "FUTURE_EXPERIMENTS_ONLY,false,flags-future-v1-state,flags-selection-future-v1-state", + "FUTURE_EXPERIMENTS_ONLY,true,flags-selection-future-v1-state,flags-future-v1-state" + ) + fun `M use the opt-out mode key for the selection W init and setFlagsAndContext()`( + mode: ExperimentationOptOutMode, + selected: Boolean, + expectedKeyPrefix: String, + siblingKeyPrefix: String, + @StringForgery(regex = "fake-[a-z]{4,8}") fakeInstanceName: String, + forge: Forge + ) { + // Given + val expectedKey = "$expectedKeyPrefix-$fakeInstanceName" + val siblingKey = "$siblingKeyPrefix-$fakeInstanceName" + testedRepository = DefaultFlagsRepository( + featureSdkCore = mockFeatureSdkCore, + instanceName = fakeInstanceName, + dataStore = mockDataStore, + flagSelection = if (selected) CanonicalFlagSelection(listOf("mobile"), emptyList()) else null, + experimentationOptOutMode = mode + ) + + // When + testedRepository.setFlagsAndContext(forge.getForgery(), emptyMap()) + + // Then + verify(mockDataStore).value( + key = eq(expectedKey), + version = anyOrNull(), + callback = any(), + deserializer = any() + ) + verify(mockDataStore).removeValue(eq(siblingKey), anyOrNull()) + verify(mockDataStore).setValue( + key = eq(expectedKey), + data = any(), + version = any(), + callback = anyOrNull(), + serializer = any() + ) + verify(mockDataStore, never()).setValue( + key = eq(siblingKey), + data = any(), + version = any(), + callback = anyOrNull(), + serializer = any() + ) + } + + @Test + fun `M persist the configured flag selection W setFlagsAndContext()`(forge: Forge) { + // Given + val flagSelection = CanonicalFlagSelection(listOf("mobile"), listOf("checkout-*")) + preparePersistenceLoad(forge, flagSelection) + + // When + testedRepository.setFlagsAndContext(forge.getForgery(), emptyMap()) + + // Then + val entry = argumentCaptor() + verify(mockDataStore).setValue( + key = any(), + data = entry.capture(), + version = any(), + callback = anyOrNull(), + serializer = any() + ) + assertThat(entry.firstValue.flagSelection).isEqualTo(flagSelection) + } + + @Test + fun `M return no flag with the context W getPrecomputedFlagWithContext() { empty flags map }`(forge: Forge) { + // Given + val context = forge.getForgery() + testedRepository.setFlagsAndContext(context, emptyMap()) + + // When + val result = testedRepository.getPrecomputedFlagWithContext(forge.anAlphabeticalString()) + + // Then + assertThat(result).isEqualTo(null to context) + } + + @Test + fun `M return true W hasConfigurationForContext() { empty flags map for the same context }`(forge: Forge) { + // Given + val context = forge.getForgery() + testedRepository.setFlagsAndContext(context, emptyMap()) + + // When / Then + assertThat(testedRepository.hasConfigurationForContext(context)).isTrue() + } + + @Test + fun `M return false W hasConfigurationForContext() { different context }`(forge: Forge) { + // Given + val context = forge.getForgery() + testedRepository.setFlagsAndContext(context, singleFlagMap) + + // When / Then + assertThat( + testedRepository.hasConfigurationForContext(context.copy(targetingKey = context.targetingKey + "-other")) + ).isFalse() + } + + private fun canonical(selection: FlagSelection): CanonicalFlagSelection = + checkNotNull(FlagSelectionCanonicalizer.canonicalize(selection) as? FlagSelectionResult.Selected).selection + + private fun preparePersistenceLoad( + forge: Forge, + flagSelection: CanonicalFlagSelection? = null + ): DataStoreReadCallback { lateinit var callback: DataStoreReadCallback doAnswer { callback = it.getArgument(2) null }.whenever(mockDataStore).value(any(), anyOrNull(), any(), any()) - testedRepository = DefaultFlagsRepository(mockFeatureSdkCore, forge.anAlphabeticalString(), mockDataStore) + testedRepository = DefaultFlagsRepository( + mockFeatureSdkCore, + forge.anAlphabeticalString(), + mockDataStore, + flagSelection + ) return callback } - // region hasFlags + // region hasConfiguration @Test - fun `M return false W hasFlags() { no state set }`() { + fun `M return false W hasConfiguration() { no state set }`() { // When + Then - assertThat(testedRepository.hasFlags()).isFalse() + assertThat(testedRepository.hasConfiguration()).isFalse() } @Test - fun `M return false W hasFlags() { empty flags map }`(forge: Forge) { + fun `M return true W hasConfiguration() { empty flags map }`(forge: Forge) { // Given testedRepository.setFlagsAndContext( EvaluationContext(forge.anAlphabeticalString(), emptyMap()), @@ -483,34 +806,35 @@ internal class DefaultFlagsRepositoryTest { ) // When + Then - assertThat(testedRepository.hasFlags()).isFalse() + assertThat(testedRepository.hasConfiguration()).isTrue() } @Test - fun `M return true W hasFlags() { single flag }`() { + fun `M return true W hasConfiguration() { single flag }`() { // Given testedRepository.setFlagsAndContext(testContext, singleFlagMap) // When + Then - assertThat(testedRepository.hasFlags()).isTrue() + assertThat(testedRepository.hasConfiguration()).isTrue() } @Test - fun `M return true W hasFlags() { multiple flags }`() { + fun `M return true W hasConfiguration() { multiple flags }`() { // Given testedRepository.setFlagsAndContext(testContext, multipleFlagsMap) // When + Then - assertThat(testedRepository.hasFlags()).isTrue() + assertThat(testedRepository.hasConfiguration()).isTrue() } @Test - fun `M return true W hasFlags() { persistence loads with non-empty flags }`() { + fun `M return true W hasConfiguration() { persistence loads with non-empty flags }`() { // Given val persistedEntry = FlagsStateEntry( flags = singleFlagMap, evaluationContext = testContext, - lastUpdateTimestamp = 0L + lastUpdateTimestamp = 0L, + flagSelection = null ) doAnswer { it.getArgument>(2) @@ -525,15 +849,16 @@ internal class DefaultFlagsRepositoryTest { val repository = DefaultFlagsRepository( featureSdkCore = mockFeatureSdkCore, dataStore = mockDataStore, + flagSelection = null, instanceName = "with-flags" ) // When + Then - assertThat(repository.hasFlags()).isTrue() + assertThat(repository.hasConfiguration()).isTrue() } @Test - fun `M return false W hasFlags() { persistence loads with no data }`() { + fun `M return false W hasConfiguration() { persistence loads with no data }`() { // Given doAnswer { it.getArgument>(2) @@ -548,15 +873,16 @@ internal class DefaultFlagsRepositoryTest { val repository = DefaultFlagsRepository( featureSdkCore = mockFeatureSdkCore, dataStore = mockDataStore, + flagSelection = null, instanceName = "no-data" ) // When + Then - assertThat(repository.hasFlags()).isFalse() + assertThat(repository.hasConfiguration()).isFalse() } @Test - fun `M return false W hasFlags() { persistence callback never fires within timeout }`() { + fun `M return false W hasConfiguration() { persistence callback never fires within timeout }`() { // Given doAnswer { // Never call the callback @@ -570,12 +896,13 @@ internal class DefaultFlagsRepositoryTest { val timeoutRepository = DefaultFlagsRepository( featureSdkCore = mockFeatureSdkCore, dataStore = mockDataStore, + flagSelection = null, instanceName = "timeout", persistenceLoadTimeoutMs = 1L ) // When - val result = timeoutRepository.hasFlags() + val result = timeoutRepository.hasConfiguration() // Then assertThat(result).isFalse() diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/FirstFlagsInstallationTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/FirstFlagsInstallationTest.kt index b09729cf71..1be385adc9 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/FirstFlagsInstallationTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/FirstFlagsInstallationTest.kt @@ -209,7 +209,7 @@ internal class FirstFlagsInstallationTest { assertThat(tested.getPrecomputedFlag("unknown")).isNull() assertThat(tested.getEvaluationContext()).isNull() assertThat(tested.getFlagsSnapshot()).isEmpty() - assertThat(tested.hasFlags()).isFalse() + assertThat(tested.hasConfiguration()).isFalse() assertThat(tested.getPrecomputedFlagWithContext("unknown")).isNull() } if (missing) disk.onSuccess(null) else disk.onFailure() @@ -366,7 +366,7 @@ internal class FirstFlagsInstallationTest { disk = it.getArgument(2) null }.whenever(store).value(any(), anyOrNull(), any(), any()) - return DefaultFlagsRepository(core, "first", store, persistenceLoadTimeoutMs = timeoutMs).also { + return DefaultFlagsRepository(core, "first", store, null, persistenceLoadTimeoutMs = timeoutMs).also { it.firstFlags.whenComplete { keys -> events.add(keys) } } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/selection/FlagSelectionCanonicalizerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/selection/FlagSelectionCanonicalizerTest.kt new file mode 100644 index 0000000000..5026efe905 --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/selection/FlagSelectionCanonicalizerTest.kt @@ -0,0 +1,486 @@ +/* + * 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.selection + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.Locale + +internal class FlagSelectionCanonicalizerTest { + + @Test + fun `M return None W canonicalize() { no selection }`() { + // When / Then + assertThat(FlagSelectionCanonicalizer.canonicalize(null)).isEqualTo(FlagSelectionResult.None) + } + + @Test + fun `M return None W canonicalize() { lists empty after canonicalization }`() { + // Given + val selection = FlagSelection(tags = listOf("", " \t"), keyPatterns = listOf("\u3000")) + + // When / Then + assertThat(FlagSelectionCanonicalizer.canonicalize(selection)).isEqualTo(FlagSelectionResult.None) + } + + @Test + fun `M return None W canonicalize() { both lists null }`() { + // Given + val selection = FlagSelection(tags = null, keyPatterns = null) + + // When / Then + assertThat(FlagSelectionCanonicalizer.canonicalize(selection)).isEqualTo(FlagSelectionResult.None) + } + + @Test + fun `M treat a null list as empty W canonicalize() { one list null }`() { + // Given + val tagsOnly = FlagSelection(tags = listOf("mobile"), keyPatterns = null) + val keyPatternsOnly = FlagSelection(tags = null, keyPatterns = listOf("checkout-*")) + + // When + val tagsOnlyResult = FlagSelectionCanonicalizer.canonicalize(tagsOnly) + val keyPatternsOnlyResult = FlagSelectionCanonicalizer.canonicalize(keyPatternsOnly) + + // Then + assertThat(tagsOnlyResult).isEqualTo( + FlagSelectionResult.Selected(CanonicalFlagSelection(tags = listOf("mobile"), keyPatterns = emptyList())) + ) + assertThat(keyPatternsOnlyResult).isEqualTo( + FlagSelectionResult.Selected(CanonicalFlagSelection(tags = emptyList(), keyPatterns = listOf("checkout-*"))) + ) + } + + @Test + fun `M trim the Unicode White_Space set W canonicalize()`() { + // Given + val selection = FlagSelection( + tags = listOf("${UNICODE_WHITE_SPACE}mobile$UNICODE_WHITE_SPACE"), + keyPatterns = listOf("\uFEFFcheckout\uFEFF", "\u001Cpayment\u001C") + ) + + // When + val result = FlagSelectionCanonicalizer.canonicalize(selection) + + // Then + assertThat(result).isEqualTo( + FlagSelectionResult.Selected( + CanonicalFlagSelection( + tags = listOf("mobile"), + keyPatterns = listOf("\u001Cpayment\u001C", "\uFEFFcheckout\uFEFF") + ) + ) + ) + } + + @Test + fun `M keep interior Unicode White_Space W canonicalize()`() { + assertThat(UNICODE_WHITE_SPACE).hasSize(25) + UNICODE_WHITE_SPACE.forEach { whiteSpace -> + // Given + val selection = tags("${whiteSpace}a${whiteSpace}b$whiteSpace") + + // When + val result = FlagSelectionCanonicalizer.canonicalize(selection) + + // Then + assertThat(result) + .describedAs("U+%04X", whiteSpace.code) + .isEqualTo(selected(listOf("a${whiteSpace}b"), emptyList())) + } + } + + @Test + fun `M drop empty values and dedupe after trimming W canonicalize()`() { + // Given + val selection = FlagSelection( + tags = listOf("web", " mobile", "", "mobile ", "web"), + keyPatterns = emptyList() + ) + + // When + val result = FlagSelectionCanonicalizer.canonicalize(selection) + + // Then + assertThat(result).isEqualTo( + FlagSelectionResult.Selected( + CanonicalFlagSelection(tags = listOf("mobile", "web"), keyPatterns = emptyList()) + ) + ) + } + + @Test + fun `M sort in UTF-8 byte order W canonicalize() { UTF-16 order differs }`() { + // Given + val fullwidthTilde = "\uFF5E" + val grinningFace = "\uD83D\uDE00" + val selection = FlagSelection( + tags = listOf(grinningFace, fullwidthTilde, "a"), + keyPatterns = listOf("b*", "B*", "\u00E9*") + ) + + // When + val result = FlagSelectionCanonicalizer.canonicalize(selection) + + // Then + assertThat(result).isEqualTo( + FlagSelectionResult.Selected( + CanonicalFlagSelection( + tags = listOf("a", fullwidthTilde, grinningFace), + keyPatterns = listOf("B*", "b*", "\u00E9*") + ) + ) + ) + } + + @ParameterizedTest(name = "{0}") + @MethodSource("acceptedSelections") + fun `M keep the selection W canonicalize() { at a limit }`( + description: String, + selection: FlagSelection + ) { + // When + val result = FlagSelectionCanonicalizer.canonicalize(selection) + + // Then + assertThat(result).describedAs(description).isInstanceOf(FlagSelectionResult.Selected::class.java) + } + + @ParameterizedTest(name = "{0}") + @MethodSource("droppedSelections") + fun `M drop the selection W canonicalize() { invalid selection }`( + description: String, + selection: FlagSelection, + expectedError: FlagSelectionError + ) { + // When + val result = FlagSelectionCanonicalizer.canonicalize(selection) + + // Then + assertThat(result).describedAs(description).isEqualTo(FlagSelectionResult.Dropped(expectedError)) + } + + @ParameterizedTest(name = "{0}") + @MethodSource("sharedVectorSelections") + fun `M match the shared selection vector W canonicalize()`( + vectorName: String, + selection: FlagSelection, + expectedResult: FlagSelectionResult + ) { + // When + val result = FlagSelectionCanonicalizer.canonicalize(selection) + + // Then + assertThat(result).describedAs(vectorName).isEqualTo(expectedResult) + } + + companion object { + private const val GRINNING_FACE = "\uD83D\uDE00" + + // https://www.unicode.org/Public/UCD/latest/ucd/PropList.txt lists 25 White_Space code points. + private const val UNICODE_WHITE_SPACE = "\t\n\u000B\u000C\r \u0085\u00A0\u1680" + + "\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000" + + private fun numbered(prefix: String, count: Int) = List(count) { "$prefix%02d".format(Locale.US, it + 1) } + + private fun selected(tags: List, keyPatterns: List) = + FlagSelectionResult.Selected(CanonicalFlagSelection(tags, keyPatterns)) + + private fun values(count: Int) = List(count) { "value-$it" } + + // Java callers can pass a raw list, so the elements are not checked to be strings. + @Suppress("UNCHECKED_CAST") + private fun uncheckedStrings(vararg values: Any?) = values.toList() as List + + private fun tags(vararg tags: String?) = FlagSelection(tags = tags.toList(), keyPatterns = emptyList()) + + private fun keyPatterns(vararg keyPatterns: String?) = + FlagSelection(tags = emptyList(), keyPatterns = keyPatterns.toList()) + + @JvmStatic + fun acceptedSelections() = listOf( + Arguments.of("31 tags", FlagSelection(values(31), emptyList())), + Arguments.of("31 key patterns", FlagSelection(emptyList(), values(31))), + Arguments.of("32 tags with one duplicate", FlagSelection(values(31) + "value-0", emptyList())), + Arguments.of("200 one-byte characters", tags("a".repeat(200))), + Arguments.of("100 two-byte characters", tags("\u00E9".repeat(100))), + Arguments.of("66 three-byte characters and 2 bytes", tags("\u20AC".repeat(66) + "ab")), + Arguments.of("50 four-byte characters", keyPatterns(GRINNING_FACE.repeat(50))), + Arguments.of("200 bytes after trimming", keyPatterns(" ${"a".repeat(200)}\u3000")) + ) + + // Every case with an `sdk` expectation in DataDog/ffe-system-test-data@e8777633 + // flag-selection/selection-vectors.json, in file order and under the same name. An empty + // expected selection is no selection on Android. + @JvmStatic + fun sharedVectorSelections() = listOf( + Arguments.of( + "no parameters selects every flag", + FlagSelection(emptyList(), emptyList()), + FlagSelectionResult.None + ), + Arguments.of( + "tag selects flags having any of the tags", + FlagSelection(listOf("team:search", "mobile"), emptyList()), + selected(listOf("mobile", "team:search"), emptyList()) + ), + Arguments.of( + "tags and key patterns are combined with OR", + FlagSelection(listOf("mobile"), listOf("checkout.*")), + selected(listOf("mobile"), listOf("checkout.*")) + ), + Arguments.of( + "key pattern must match the whole key", + FlagSelection(emptyList(), listOf("checkout")), + selected(emptyList(), listOf("checkout")) + ), + Arguments.of( + "key pattern without a star selects exactly that key", + FlagSelection(emptyList(), listOf("checkout.new-flow")), + selected(emptyList(), listOf("checkout.new-flow")) + ), + Arguments.of( + "star matches an empty run and multiple stars are allowed", + FlagSelection(emptyList(), listOf("*search*auto*complete*")), + selected(emptyList(), listOf("*search*auto*complete*")) + ), + Arguments.of( + "leading star", + FlagSelection(emptyList(), listOf("*-v2")), + selected(emptyList(), listOf("*-v2")) + ), + Arguments.of( + "lone star matches every key", + FlagSelection(emptyList(), listOf("*")), + selected(emptyList(), listOf("*")) + ), + Arguments.of( + "question mark and brackets are literal", + FlagSelection(emptyList(), listOf("search.ranking-v?", "[a]*", "{a,b}")), + selected(emptyList(), listOf("[a]*", "search.ranking-v?", "{a,b}")) + ), + Arguments.of( + "braces are literal even when an alternative names a flag", + FlagSelection(emptyList(), listOf("{mobile.dark-mode,missing}")), + selected(emptyList(), listOf("{mobile.dark-mode,missing}")) + ), + Arguments.of( + "tags are never globs", + FlagSelection(listOf("team:*"), emptyList()), + selected(listOf("team:*"), emptyList()) + ), + Arguments.of( + "tags match case-sensitively", + FlagSelection(listOf("Team:Payments", "MOBILE"), emptyList()), + selected(listOf("MOBILE", "Team:Payments"), emptyList()) + ), + Arguments.of( + "key patterns match case-sensitively", + FlagSelection(emptyList(), listOf("CHECKOUT.*", "Mobile.Dark-Mode")), + selected(emptyList(), listOf("CHECKOUT.*", "Mobile.Dark-Mode")) + ), + Arguments.of( + "values are trimmed, empties dropped and duplicates removed", + FlagSelection(listOf(" mobile ", "mobile", "", " "), emptyList()), + selected(listOf("mobile"), emptyList()) + ), + Arguments.of( + "lists that are empty after normalization select every flag", + FlagSelection(listOf("", " "), listOf(" ")), + FlagSelectionResult.None + ), + Arguments.of( + "values are trimmed of Unicode white space", + FlagSelection(listOf("\u00A0mobile\u3000", "\u0085ui\u2028", "\u0009 team:search \u000A"), emptyList()), + selected(listOf("mobile", "team:search", "ui"), emptyList()) + ), + Arguments.of( + "characters outside Unicode White_Space are not trimmed", + FlagSelection(listOf("\uFEFFmobile", "mobile\u200B"), emptyList()), + selected(listOf("mobile\u200B", "\uFEFFmobile"), emptyList()) + ), + Arguments.of( + "values sort by UTF-8 bytes", + FlagSelection(listOf(GRINNING_FACE, "\uFF5E", "a", "Z"), emptyList()), + selected(listOf("Z", "a", "\uFF5E", GRINNING_FACE), emptyList()) + ), + Arguments.of( + "partial match serves only the matching flags", + FlagSelection(listOf("mobile", "missing"), listOf("nothing.*")), + selected(listOf("missing", "mobile"), listOf("nothing.*")) + ), + Arguments.of( + "31 values in one list are allowed", + FlagSelection(numbered("t", 31), emptyList()), + selected(numbered("t", 31), emptyList()) + ), + Arguments.of( + "32 values in one list are rejected", + FlagSelection(emptyList(), numbered("k", 32)), + FlagSelectionResult.Dropped(FlagSelectionError.TOO_MANY_VALUES) + ), + Arguments.of( + "31 values in each list are allowed", + FlagSelection(numbered("t", 31), numbered("p", 31)), + selected(numbered("t", 31), numbered("p", 31)) + ), + Arguments.of( + "32 values in one list are rejected even when the other is within the limit", + FlagSelection(numbered("t", 31), numbered("p", 32)), + FlagSelectionResult.Dropped(FlagSelectionError.TOO_MANY_VALUES) + ), + Arguments.of( + "duplicates do not count toward the limit", + FlagSelection(numbered("t", 31) + listOf("t01", " t02 "), emptyList()), + selected(numbered("t", 31), emptyList()) + ), + Arguments.of( + "key pattern of 200 bytes is allowed", + FlagSelection(emptyList(), listOf("*" + "x".repeat(199))), + selected(emptyList(), listOf("*" + "x".repeat(199))) + ), + Arguments.of( + "key pattern of 201 bytes is invalid", + FlagSelection(emptyList(), listOf("*" + "x".repeat(200))), + FlagSelectionResult.Dropped(FlagSelectionError.INVALID_KEY_PATTERN) + ), + Arguments.of( + "key pattern length is measured after trimming", + FlagSelection(emptyList(), listOf(" *" + "x".repeat(199) + " ")), + selected(emptyList(), listOf("*" + "x".repeat(199))) + ), + Arguments.of( + "key pattern length counts UTF-8 bytes: 100 two-byte characters are allowed", + FlagSelection(emptyList(), listOf("\u00E9".repeat(100))), + selected(emptyList(), listOf("\u00E9".repeat(100))) + ), + Arguments.of( + "key pattern length counts UTF-8 bytes: 201 bytes are invalid", + FlagSelection(emptyList(), listOf("x" + "\u00E9".repeat(100))), + FlagSelectionResult.Dropped(FlagSelectionError.INVALID_KEY_PATTERN) + ), + Arguments.of( + "tag of 200 bytes is allowed", + FlagSelection(listOf("a".repeat(200)), emptyList()), + selected(listOf("a".repeat(200)), emptyList()) + ), + Arguments.of( + "tag of 201 bytes is invalid", + FlagSelection(listOf("a".repeat(201)), emptyList()), + FlagSelectionResult.Dropped(FlagSelectionError.INVALID_TAG) + ), + Arguments.of( + "tag length is measured after trimming", + FlagSelection(listOf(" " + "a".repeat(200) + " "), emptyList()), + selected(listOf("a".repeat(200)), emptyList()) + ), + Arguments.of( + "tag length counts UTF-8 bytes: 100 two-byte characters are allowed", + FlagSelection(listOf("\u00E9".repeat(100)), emptyList()), + selected(listOf("\u00E9".repeat(100)), emptyList()) + ), + Arguments.of( + "tag length counts UTF-8 bytes: 201 bytes are invalid", + FlagSelection(listOf("x" + "\u00E9".repeat(100)), emptyList()), + FlagSelectionResult.Dropped(FlagSelectionError.INVALID_TAG) + ), + Arguments.of( + "a long tag invalidates the selection even when other tags match", + FlagSelection(listOf("mobile", "a".repeat(201)), emptyList()), + FlagSelectionResult.Dropped(FlagSelectionError.INVALID_TAG) + ), + Arguments.of( + "too_many_values takes precedence over invalid_key_pattern", + FlagSelection(numbered("t", 32), listOf("*" + "x".repeat(200))), + FlagSelectionResult.Dropped(FlagSelectionError.TOO_MANY_VALUES) + ), + Arguments.of( + "too_many_values takes precedence over invalid_tag", + FlagSelection(numbered("t", 31) + "a".repeat(201), emptyList()), + FlagSelectionResult.Dropped(FlagSelectionError.TOO_MANY_VALUES) + ), + Arguments.of( + "invalid_key_pattern takes precedence over invalid_tag", + FlagSelection(listOf("a".repeat(201)), listOf("a".repeat(201))), + FlagSelectionResult.Dropped(FlagSelectionError.INVALID_KEY_PATTERN) + ) + ) + + @JvmStatic + fun droppedSelections() = listOf( + Arguments.of("32 tags", FlagSelection(values(32), emptyList()), FlagSelectionError.TOO_MANY_VALUES), + Arguments.of( + "32 key patterns", + FlagSelection(emptyList(), values(32)), + FlagSelectionError.TOO_MANY_VALUES + ), + Arguments.of("201-byte tag", tags("a".repeat(201)), FlagSelectionError.INVALID_TAG), + Arguments.of( + "201-byte tag with two-byte characters", + tags("\u00E9".repeat(100) + "a"), + FlagSelectionError.INVALID_TAG + ), + Arguments.of( + "201-byte tag with three-byte characters", + tags("\u20AC".repeat(67)), + FlagSelectionError.INVALID_TAG + ), + Arguments.of( + "201-byte key pattern with four-byte characters", + keyPatterns(GRINNING_FACE.repeat(50) + "*"), + FlagSelectionError.INVALID_KEY_PATTERN + ), + Arguments.of("lone high surrogate", tags("a\uD83D"), FlagSelectionError.MALFORMED_SELECTION), + Arguments.of("lone low surrogate", keyPatterns("\uDE00a"), FlagSelectionError.MALFORMED_SELECTION), + Arguments.of( + "reversed surrogate pair", + tags("\uDE00\uD83D"), + FlagSelectionError.MALFORMED_SELECTION + ), + Arguments.of("null tag", tags("mobile", null), FlagSelectionError.MALFORMED_SELECTION), + Arguments.of("null key pattern", keyPatterns(null), FlagSelectionError.MALFORMED_SELECTION), + Arguments.of( + "non-string tag from an unchecked cast", + FlagSelection(tags = uncheckedStrings("mobile", 1), keyPatterns = emptyList()), + FlagSelectionError.MALFORMED_SELECTION + ), + Arguments.of( + "non-string key pattern from an unchecked cast", + FlagSelection(tags = emptyList(), keyPatterns = uncheckedStrings(true)), + FlagSelectionError.MALFORMED_SELECTION + ), + Arguments.of( + "malformed before too many values", + FlagSelection(values(32) + null, emptyList()), + FlagSelectionError.MALFORMED_SELECTION + ), + Arguments.of( + "malformed before invalid key pattern", + FlagSelection(listOf("a\uD83D"), listOf("a".repeat(201))), + FlagSelectionError.MALFORMED_SELECTION + ), + Arguments.of( + "malformed before invalid tag", + FlagSelection(listOf("a".repeat(201)), listOf(null)), + FlagSelectionError.MALFORMED_SELECTION + ), + Arguments.of( + "too many values before invalid key pattern", + FlagSelection(values(32), listOf("a".repeat(201))), + FlagSelectionError.TOO_MANY_VALUES + ), + Arguments.of( + "invalid key pattern before invalid tag", + FlagSelection(listOf("a".repeat(201)), listOf("a".repeat(201))), + FlagSelectionError.INVALID_KEY_PATTERN + ) + ) + } +} diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/selection/FlagSelectionIgnoredReasonTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/selection/FlagSelectionIgnoredReasonTest.kt new file mode 100644 index 0000000000..4a51f18fbf --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/selection/FlagSelectionIgnoredReasonTest.kt @@ -0,0 +1,60 @@ +/* + * 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.selection + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.ValueSource + +internal class FlagSelectionIgnoredReasonTest { + + @ParameterizedTest + @ValueSource( + strings = ["malformed_selection", "too_many_values", "invalid_key_pattern", "invalid_tag", "tag_load_failed"] + ) + fun `M keep the reason W sanitize() { known reason }`(reason: String) { + // When / Then + assertThat(FlagSelectionIgnoredReason.sanitize(reason)).isEqualTo(reason) + } + + @ParameterizedTest + @EnumSource(ServerIgnoredReason::class) + fun `M keep the reason W sanitize() { any server ignored reason }`(reason: ServerIgnoredReason) { + // When / Then + assertThat(FlagSelectionIgnoredReason.sanitize(reason.reason)).isEqualTo(reason.reason) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "Some Future-Reason", + "unknown_reason", + "Too_Many_Values", + "TAG_LOAD_FAILED", + " too_many_values", + "too_many_values\u0001", + "invalid_tag\n", + "too_many_values\u00E9", + "\uD83D\uDE00" + ] + ) + fun `M return the fallback W sanitize() { unknown reason }`(reason: String) { + // When / Then + assertThat(FlagSelectionIgnoredReason.sanitize(reason)).isEqualTo("unknown") + } + + @ParameterizedTest + @ValueSource(ints = [33, 200, 10_000]) + fun `M return the fallback W sanitize() { value longer than 32 characters }`(length: Int) { + // Given + val reason = "too_many_values".padEnd(length, '_') + + // When / Then + assertThat(FlagSelectionIgnoredReason.sanitize(reason)).isEqualTo("unknown") + } +}