diff --git a/android-client-sdk/src/main/java/com/devcycle/sdk/android/api/DevCycleClient.kt b/android-client-sdk/src/main/java/com/devcycle/sdk/android/api/DevCycleClient.kt index b1250c0e..2ad378d7 100644 --- a/android-client-sdk/src/main/java/com/devcycle/sdk/android/api/DevCycleClient.kt +++ b/android-client-sdk/src/main/java/com/devcycle/sdk/android/api/DevCycleClient.kt @@ -101,13 +101,17 @@ class DevCycleClient private constructor( } init { - val cacheHit = useCachedConfigForUser(user) + useCachedConfigForUser(user) - if (cacheHit) { - isInitialized.set(true) - initializeJob = CompletableDeferred(Unit) - - coroutineScope.launch(coroutineContext) { + initializeJob = coroutineScope.async(coroutineContext) { + isExecuting.set(true) + try { + fetchConfig(user) + isInitialized.set(true) + } catch (t: Throwable) { + DevCycleLogger.e(t, "DevCycle SDK Failed to Initialize!") + throw t + } finally { withContext(Dispatchers.IO) { initEventSource() val application: Application = context.applicationContext as Application @@ -119,38 +123,13 @@ class DevCycleClient private constructor( ) application.registerActivityLifecycleCallbacks(lifecycleCallbacks) } - // Fetch fresh config in background (ADR 0009). SSE only fires on - // server-side changes, so an explicit fetch is needed to verify the cache. - performBackgroundRefresh() - } - } else { - initializeJob = coroutineScope.async(coroutineContext) { - isExecuting.set(true) - try { - fetchConfig(user) - isInitialized.set(true) - withContext(Dispatchers.IO) { - initEventSource() - val application: Application = context.applicationContext as Application - val lifecycleCallbacks = DVCLifecycleCallbacks( - onPauseApplication, - onResumeApplication, - config?.sse?.inactivityDelay?.toLong(), - customLifecycleHandler - ) - application.registerActivityLifecycleCallbacks(lifecycleCallbacks) - } - } catch (t: Throwable) { - DevCycleLogger.e(t, "DevCycle SDK Failed to Initialize!") - throw t - } } + } - initializeJob.invokeOnCompletion { - coroutineScope.launch(coroutineContext) { - handleQueuedConfigRequests() - isExecuting.set(false) - } + initializeJob.invokeOnCompletion { + coroutineScope.launch(coroutineContext) { + handleQueuedConfigRequests() + isExecuting.set(false) } } } @@ -214,12 +193,12 @@ class DevCycleClient private constructor( } } - internal val isUsingCachedConfig: Boolean + val hasUsableCachedConfig: Boolean @Synchronized get() = config != null && isConfigCached.get() /** * Atomically updates [config] and [isConfigCached] under the intrinsic lock so that - * [isUsingCachedConfig] never observes a torn write where one field reflects the new value + * [hasUsableCachedConfig] never observes a torn write where one field reflects the new value * and the other still holds the old one. */ @Synchronized @@ -267,22 +246,14 @@ class DevCycleClient private constructor( override fun onError(error: Throwable) { DevCycleLogger.d("Error fetching config for user_id %s: %s", updatedUser.userId, error.message) - - if (error is DVCRequestException && (error.isAuthError || error.statusCode == 400)) { - dvcSharedPrefs.clearConfigForUser(updatedUser) - DevCycleLogger.w("Config error during identifyUser (${error.statusCode}). Persisted cache cleared.") - latestIdentifiedUser = previousUser - callback?.onError(error) - return - } - // In the event that the config request fails (i.e. the device is offline) // Fallback to using a Cached Configuration for the User if available val hasCachedConfig = tryLoadCachedConfigForUser(updatedUser) if (hasCachedConfig) { // Successfully used cached config, return success + DevCycleLogger.i("Using cached config for identifyUser due to network error: $error") + this@DevCycleClient.user = updatedUser config?.variables?.let { callback?.onSuccess(it) } - performBackgroundRefresh() } else { // No cached config available, restore previous state and return error latestIdentifiedUser = previousUser @@ -611,7 +582,12 @@ class DevCycleClient private constructor( fetchConfig(latestIdentifiedUser, sse, lastModified, etag) config?.variables?.let { callback?.onSuccess(it) } } catch (t: Throwable) { - callback?.onError(t) + if (callback != null) { + callback.onError(t) + } else { + // SSE-triggered refetch — no caller callback; surface error via onConfigUpdated listeners. + notifyConfigError(t) + } } finally { handleQueuedConfigRequests() isExecuting.set(false) @@ -651,10 +627,20 @@ class DevCycleClient private constructor( return false } - internal fun onConfigUpdated(callback: DevCycleCallback>) { + fun onConfigUpdated(callback: DevCycleCallback>) { configUpdatedCallbacks.add(callback) } + private fun notifyConfigError(t: Throwable) { + configUpdatedCallbacks.forEach { callback -> + try { + callback.onError(t) + } catch (e: Exception) { + DevCycleLogger.e(e, "Error in config updated error callback") + } + } + } + private fun notifyConfigUpdated(variables: Map?) { variables?.let { vars -> configUpdatedCallbacks.forEach { callback -> @@ -667,40 +653,6 @@ class DevCycleClient private constructor( } } - private fun notifyConfigError(error: Throwable) { - configUpdatedCallbacks.forEach { callback -> - try { - callback.onError(error) - } catch (e: Exception) { - DevCycleLogger.e(e, "Error in config error callback") - } - } - } - - private fun performBackgroundRefresh() { - val userAtRefreshStart = latestIdentifiedUser - refetchConfig(false, null, null, object : DevCycleCallback> { - override fun onSuccess(result: Map) { - DevCycleLogger.d("Background refresh succeeded") - } - - override fun onError(error: Throwable) { - val isConfigError = error is DVCRequestException && - (error.isAuthError || error.statusCode == 400) - - if (isConfigError) { - dvcSharedPrefs.clearConfigForUser(userAtRefreshStart) - DevCycleLogger.w("Background refresh config error (${(error as DVCRequestException).statusCode}). Persisted cache cleared.") - if (configUpdatedCallbacks.isNotEmpty()) { - notifyConfigError(error) - } - } else { - DevCycleLogger.w("Background refresh failed: ${error.message}. Keeping caches.") - } - } - }) - } - class DevCycleClientBuilder { private var context: Context? = null private var customLifecycleHandler: Handler? = null diff --git a/android-client-sdk/src/main/java/com/devcycle/sdk/android/openfeature/DevCycleProvider.kt b/android-client-sdk/src/main/java/com/devcycle/sdk/android/openfeature/DevCycleProvider.kt index f08698e6..ea3d6432 100644 --- a/android-client-sdk/src/main/java/com/devcycle/sdk/android/openfeature/DevCycleProvider.kt +++ b/android-client-sdk/src/main/java/com/devcycle/sdk/android/openfeature/DevCycleProvider.kt @@ -44,16 +44,14 @@ class DevCycleProvider( ) /** - * Helper function to create a ProviderEvaluation from a DevCycle variable. - * - * [isUsingCachedConfig] must be captured from the same client reference used to retrieve - * [variable], so the reason reflects the config state at the moment of evaluation rather than - * a later read of the mutable [_devcycleClient] field. + * [hasUsableCachedConfig] must be captured from the same client reference used to retrieve + * [variable] so the reason reflects the config state at evaluation time rather than a later + * read of the mutable [_devcycleClient] field. */ private fun createProviderEvaluation( variable: Variable<*>, value: T, - isUsingCachedConfig: Boolean, + hasUsableCachedConfig: Boolean, ): ProviderEvaluation { val metadataBuilder = EvaluationMetadata.builder() var hasMetadata = false @@ -71,7 +69,7 @@ class DevCycleProvider( val reason = when { variable.isDefaulted == true -> Reason.DEFAULT.toString() - isUsingCachedConfig -> Reason.CACHED.toString() + hasUsableCachedConfig -> Reason.CACHED.toString() else -> variable.eval?.reason ?: Reason.TARGETING_MATCH.toString() } @@ -123,46 +121,76 @@ class DevCycleProvider( _devcycleClient = clientBuilder.build() - _devcycleClient!!.onConfigUpdated(object : DevCycleCallback> { - override fun onSuccess(result: Map) { - _providerEvents.tryEmit(OpenFeatureProviderEvents.ProviderConfigurationChanged) - DevCycleLogger.d("Emitted PROVIDER_CONFIGURATION_CHANGED event") - } - - override fun onError(t: Throwable) { - DevCycleLogger.e("Config error: ${t.message}") - _providerEvents.tryEmit(OpenFeatureProviderEvents.ProviderError( - OpenFeatureError.GeneralError(t.message ?: "Config error") - )) - } - }) - - if (_devcycleClient!!.isUsingCachedConfig) { - DevCycleLogger.d("DevCycle OpenFeature provider initialized from cache (PROVIDER_READY)") - return + // consumeResume guard — ensures continuation.resume is called exactly once. + // On a cache hit the cache check below resolves immediately; onInitialized fires later + // (after network) and emits ProviderConfigurationChanged. + // On a cache miss onInitialized is the sole resolve. + val lock = Any() + var didResume = false + fun consumeResume(): Boolean = synchronized(lock) { + if (didResume) false else { didResume = true; true } } - // Cache miss: block until network fetch completes + // Captured before registering callbacks to avoid a race where onInitialized fires + // first and incorrectly treats a cache-hit init as a fatal error. + val isCacheHit = _devcycleClient!!.hasUsableCachedConfig + suspendCancellableCoroutine { continuation -> + + // SSE-triggered config updates fire post-init whenever the realtime connection + // delivers a new config. + _devcycleClient!!.onConfigUpdated(object : DevCycleCallback> { + override fun onSuccess(result: Map) { + _providerEvents.tryEmit(OpenFeatureProviderEvents.ProviderConfigurationChanged) + } + override fun onError(t: Throwable) { + _providerEvents.tryEmit(OpenFeatureProviderEvents.ProviderError( + OpenFeatureError.GeneralError(t.message ?: "Config error") + )) + } + }) + + // onInitialized always fires after the network fetch completes. + // Cache miss: sole resolve — resume the continuation. + // Cache hit: continuation already resumed below; surface the network result as an event. _devcycleClient!!.onInitialized(object : DevCycleCallback { override fun onSuccess(result: String) { - DevCycleLogger.d("DevCycle OpenFeature provider initialized successfully") - continuation.resume(Unit) + if (consumeResume()) { + // Cache miss: first to resolve — resume the continuation if still active. + if (continuation.isActive) continuation.resume(Unit) + } else { + // Cache hit: continuation already resolved; surface network result as event. + _providerEvents.tryEmit(OpenFeatureProviderEvents.ProviderConfigurationChanged) + } } - override fun onError(t: Throwable) { - DevCycleLogger.e("DevCycle OpenFeature provider initialization failed: ${t.message}") - continuation.resumeWithException( - OpenFeatureError.ProviderFatalError("DevCycle client initialization error: ${t.message}") - ) + if (!isCacheHit) { + // Cache miss: fatal init error — resume with exception if still active. + if (consumeResume() && continuation.isActive) { + continuation.resumeWithException( + OpenFeatureError.ProviderFatalError("DevCycle client initialization error: ${t.message}") + ) + } + } else { + // Cache hit: network error is non-fatal; always emit — continuation is already resolved. + _providerEvents.tryEmit(OpenFeatureProviderEvents.ProviderError( + OpenFeatureError.GeneralError("Background refresh failed: ${t.message}") + )) + } } }) + + // Cache hit: resolve immediately — the network fetch continues in the background. + if (isCacheHit) { + if (continuation.isActive && consumeResume()) { + continuation.resume(Unit) + } + } } } catch (e: OpenFeatureError) { // Re-throw OpenFeature errors as-is throw e } catch (e: Exception) { - DevCycleLogger.e("DevCycle OpenFeature provider initialization failed: ${e.message}") throw OpenFeatureError.ProviderFatalError("DevCycle client initialization error: ${e.message}") } } @@ -213,7 +241,7 @@ class DevCycleProvider( ): ProviderEvaluation { val client = _devcycleClient ?: return createDefaultProviderEvaluation(defaultValue) val variable = client.variable(key, defaultValue) - return createProviderEvaluation(variable, variable.value, client.isUsingCachedConfig) + return createProviderEvaluation(variable, variable.value, client.hasUsableCachedConfig) } override fun getDoubleEvaluation( @@ -223,7 +251,7 @@ class DevCycleProvider( ): ProviderEvaluation { val client = _devcycleClient ?: return createDefaultProviderEvaluation(defaultValue) val variable = client.variable(key, defaultValue) - return createProviderEvaluation(variable, variable.value.toDouble(), client.isUsingCachedConfig) + return createProviderEvaluation(variable, variable.value.toDouble(), client.hasUsableCachedConfig) } override fun getIntegerEvaluation( @@ -233,7 +261,7 @@ class DevCycleProvider( ): ProviderEvaluation { val client = _devcycleClient ?: return createDefaultProviderEvaluation(defaultValue) val variable = client.variable(key, defaultValue) - return createProviderEvaluation(variable, variable.value.toInt(), client.isUsingCachedConfig) + return createProviderEvaluation(variable, variable.value.toInt(), client.hasUsableCachedConfig) } override fun getObjectEvaluation( @@ -242,7 +270,7 @@ class DevCycleProvider( context: EvaluationContext? ): ProviderEvaluation { val client = _devcycleClient ?: return createDefaultProviderEvaluation(defaultValue) - val isUsingCachedConfig = client.isUsingCachedConfig + val hasUsableCachedConfig = client.hasUsableCachedConfig val (result, variable) = when { defaultValue is Value.Structure -> { @@ -274,7 +302,7 @@ class DevCycleProvider( } } - return createProviderEvaluation(variable, result, isUsingCachedConfig) + return createProviderEvaluation(variable, result, hasUsableCachedConfig) } override fun getStringEvaluation( @@ -284,7 +312,7 @@ class DevCycleProvider( ): ProviderEvaluation { val client = _devcycleClient ?: return createDefaultProviderEvaluation(defaultValue) val variable = client.variable(key, defaultValue) - return createProviderEvaluation(variable, variable.value, client.isUsingCachedConfig) + return createProviderEvaluation(variable, variable.value, client.hasUsableCachedConfig) } override fun track( diff --git a/android-client-sdk/src/main/java/com/devcycle/sdk/android/util/DVCSharedPrefs.kt b/android-client-sdk/src/main/java/com/devcycle/sdk/android/util/DVCSharedPrefs.kt index 8a2857dd..b408cb3a 100644 --- a/android-client-sdk/src/main/java/com/devcycle/sdk/android/util/DVCSharedPrefs.kt +++ b/android-client-sdk/src/main/java/com/devcycle/sdk/android/util/DVCSharedPrefs.kt @@ -187,21 +187,6 @@ internal class DVCSharedPrefs(context: Context, private val configCacheTTL: Long - @Synchronized - fun clearConfigForUser(user: PopulatedUser) { - try { - val userKey = generateUserConfigKey(user.userId, user.isAnonymous) - val userExpiryDateKey = generateUserExpiryDateKey(user.userId, user.isAnonymous) - val editor = preferences.edit() - editor.remove(userKey) - editor.remove(userExpiryDateKey) - editor.apply() - DevCycleLogger.d("Cleared persisted config for user_id %s", user.userId) - } catch (e: Exception) { - DevCycleLogger.e(e, "Error clearing config for user: ${e.message}") - } - } - @Synchronized fun remove(key: String?) { try { diff --git a/android-client-sdk/src/test/java/com/devcycle/sdk/android/api/DevCycleClientTests.kt b/android-client-sdk/src/test/java/com/devcycle/sdk/android/api/DevCycleClientTests.kt index 829fb913..4e0e0d81 100644 --- a/android-client-sdk/src/test/java/com/devcycle/sdk/android/api/DevCycleClientTests.kt +++ b/android-client-sdk/src/test/java/com/devcycle/sdk/android/api/DevCycleClientTests.kt @@ -2000,7 +2000,11 @@ class DevCycleClientTests { fun `DevCycleClient initializes successfully from cached config`() { primeCachedConfig() - // No mockWebServer.enqueue() — a cache hit must not require a network fetch. + // Init always fetches from network; use a distinct network value to verify the + // final state reflects the network response, not the primed cache. + val config = generateConfig("cached-flag", "network-value", Variable.TypeEnum.STRING) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(objectMapper.writeValueAsString(config))) + val client = createClient(TEST_SDK_KEY, mockWebServer.url("/").toString()) try { @@ -2022,8 +2026,7 @@ class DevCycleClientTests { handleFinally(calledBack, error) } - Assertions.assertTrue(client.isUsingCachedConfig, "Client should report a usable cached config") - Assertions.assertEquals("cached-value", client.variableValue("cached-flag", "default")) + Assertions.assertEquals("network-value", client.variableValue("cached-flag", "default")) client.close() } @@ -2053,7 +2056,7 @@ class DevCycleClientTests { } Assertions.assertTrue( - provider.devcycleClient.isUsingCachedConfig, + provider.devcycleClient.hasUsableCachedConfig, "Provider's underlying client should report a usable cached config" ) val eval = provider.getStringEvaluation("cached-flag", "default", null) @@ -2439,95 +2442,6 @@ class DevCycleClientTests { client.close() } - @Test - fun `cache-first init resolves immediately when cache exists`() { - val config = generateConfig("cached-flag", "Cached value!", Variable.TypeEnum.STRING, targetingMatch) - - // First response for background refresh - mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(objectMapper.writeValueAsString(config))) - - // Pre-populate the cache by using the editor stubs - val configJson = objectMapper.writeValueAsString(config) - `when`(sharedPreferences?.getString(ArgumentMatchers.eq("IDENTIFIED_CONFIG.nic_test"), ArgumentMatchers.isNull())).thenReturn(configJson) - `when`(sharedPreferences?.getLong(ArgumentMatchers.eq("IDENTIFIED_CONFIG.nic_test.EXPIRY_DATE"), ArgumentMatchers.eq(0L))).thenReturn(System.currentTimeMillis() + 86400000L) - - val client = createClient(TEST_SDK_KEY, mockWebServer.url("/").toString()) - - var initCalledBack = false - val initLatch = CountDownLatch(1) - - client.onInitialized(object : DevCycleCallback { - override fun onSuccess(result: String) { - initCalledBack = true - initLatch.countDown() - } - - override fun onError(t: Throwable) { - initLatch.countDown() - } - }) - - // onInitialized should resolve immediately on cache hit - Assertions.assertTrue(initLatch.await(3000, TimeUnit.MILLISECONDS), "onInitialized should resolve on cache hit") - Assertions.assertTrue(initCalledBack, "onInitialized should call success callback on cache hit") - client.close() - } - - @Test - fun `identifyUser with auth error clears persisted cache`() { - val config = generateConfig("activate-flag", "Flag activated!", Variable.TypeEnum.STRING, targetingMatch) - - val requestCount = AtomicInteger(0) - mockWebServer.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse { - if (request.path == "/v1/events") { - return MockResponse().setResponseCode(201).setBody("{\"message\": \"Success\"}") - } else if (request.path?.contains("/v1/mobileSDKConfig") == true) { - val count = requestCount.incrementAndGet() - return if (count == 1) { - MockResponse().setResponseCode(200).setBody(objectMapper.writeValueAsString(config)) - } else { - MockResponse().setResponseCode(401).setBody("{\"message\":[\"Unauthorized\"],\"statusCode\":401}") - } - } - return MockResponse().setResponseCode(404) - } - } - - val client = createClient(TEST_SDK_KEY, mockWebServer.url("/").toString()) - - val initLatch = CountDownLatch(1) - client.onInitialized(object : DevCycleCallback { - override fun onSuccess(result: String) { - initLatch.countDown() - } - override fun onError(t: Throwable) { - initLatch.countDown() - } - }) - initLatch.await(3000, TimeUnit.MILLISECONDS) - - val identifyLatch = CountDownLatch(1) - var identifyError: Throwable? = null - val newUser = DevCycleUser.builder().withUserId("auth_error_user").build() - client.identifyUser(newUser, object : DevCycleCallback> { - override fun onSuccess(result: Map) { - identifyLatch.countDown() - } - override fun onError(t: Throwable) { - identifyError = t - identifyLatch.countDown() - } - }) - - identifyLatch.await(3000, TimeUnit.MILLISECONDS) - Assertions.assertNotNull(identifyError, "identifyUser should return error on 401") - - // Verify persisted cache was cleared for the new user - Mockito.verify(editor, Mockito.atLeastOnce())?.remove(ArgumentMatchers.contains("IDENTIFIED_CONFIG")) - client.close() - } - @Test fun `onConfigUpdated callback fires on config change after init`() { val config = generateConfig("activate-flag", "Flag activated!", Variable.TypeEnum.STRING, targetingMatch) diff --git a/android-client-sdk/src/test/java/com/devcycle/sdk/android/openfeature/DevCycleProviderTest.kt b/android-client-sdk/src/test/java/com/devcycle/sdk/android/openfeature/DevCycleProviderTest.kt index 23e2c179..767f6ee2 100644 --- a/android-client-sdk/src/test/java/com/devcycle/sdk/android/openfeature/DevCycleProviderTest.kt +++ b/android-client-sdk/src/test/java/com/devcycle/sdk/android/openfeature/DevCycleProviderTest.kt @@ -10,11 +10,16 @@ import dev.openfeature.kotlin.sdk.EvaluationMetadata import dev.openfeature.kotlin.sdk.ImmutableContext import dev.openfeature.kotlin.sdk.TrackingEventDetails import dev.openfeature.kotlin.sdk.Value +import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertDoesNotThrow import org.junit.jupiter.api.Assertions.assertEquals @@ -177,7 +182,7 @@ class DevCycleProviderTest { @Test fun `initialize returns immediately when cached config is available`() { - every { mockDevCycleClient.isUsingCachedConfig } returns true + every { mockDevCycleClient.hasUsableCachedConfig } returns true assertDoesNotThrow { runBlocking { @@ -185,13 +190,14 @@ class DevCycleProviderTest { } } - // onInitialized should NOT have been called since we short-circuited - io.mockk.verify(exactly = 0) { mockDevCycleClient.onInitialized(any()) } + // onInitialized SHOULD be registered even on cache hit — it handles the post-cache-hit + // background network result (ProviderConfigurationChanged or ProviderError). + io.mockk.verify(exactly = 1) { mockDevCycleClient.onInitialized(any()) } } @Test fun `initialize blocks on network when no cached config`() { - every { mockDevCycleClient.isUsingCachedConfig } returns false + every { mockDevCycleClient.hasUsableCachedConfig } returns false assertDoesNotThrow { runBlocking { @@ -310,7 +316,7 @@ class DevCycleProviderTest { every { mockEvalReason.details } returns null every { mockEvalReason.targetId } returns null - every { mockDevCycleClient.isUsingCachedConfig } returns true + every { mockDevCycleClient.hasUsableCachedConfig } returns true every { mockDevCycleClient.variable("test-variable", "default") } returns mockVariable val result = provider.getStringEvaluation("test-variable", "default", null) @@ -334,7 +340,7 @@ class DevCycleProviderTest { every { mockEvalReason.details } returns "User Not Targeted" every { mockEvalReason.targetId } returns null - every { mockDevCycleClient.isUsingCachedConfig } returns true + every { mockDevCycleClient.hasUsableCachedConfig } returns true every { mockDevCycleClient.variable("missing-key", "default") } returns mockVariable val result = provider.getStringEvaluation("missing-key", "default", null) @@ -343,10 +349,137 @@ class DevCycleProviderTest { assertEquals("DEFAULT", result.reason) } + @Test + fun `configurationChanged event emitted when background refresh succeeds after cache hit init`() { + every { mockDevCycleClient.hasUsableCachedConfig } returns true + + // Capture the onInitialized callback without invoking it yet (async background refresh) + var capturedInitCallback: DevCycleCallback? = null + every { mockDevCycleClient.onInitialized(any()) } answers { + capturedInitCallback = firstArg() + } + + val emittedEvents = mutableListOf() + runBlocking { + val collectJob = launch { + provider.observe().take(1).collect { emittedEvents.add(it) } + } + // yield() ensures the collector is subscribed before initialize() emits any events. + // SharedFlow has replay=0, so events emitted before subscription are dropped. + yield() + provider.initialize(ImmutableContext(targetingKey = "cached-user")) + + // Simulate background network refresh completing successfully + capturedInitCallback?.onSuccess("initialized") + collectJob.join() + } + + assertTrue( + emittedEvents.any { it is OpenFeatureProviderEvents.ProviderConfigurationChanged }, + "ProviderConfigurationChanged should be emitted after background refresh succeeds" + ) + } + + @Test + fun `provider emits error event when background refresh fails with non-retryable error after cache hit init`() { + every { mockDevCycleClient.hasUsableCachedConfig } returns true + + var capturedInitCallback: DevCycleCallback? = null + every { mockDevCycleClient.onInitialized(any()) } answers { + capturedInitCallback = firstArg() + } + + val emittedEvents = mutableListOf() + runBlocking { + val collectJob = launch { + provider.observe().take(1).collect { emittedEvents.add(it) } + } + // yield() ensures the collector is subscribed before initialize() emits any events. + // SharedFlow has replay=0, so events emitted before subscription are dropped. + yield() + provider.initialize(ImmutableContext(targetingKey = "cached-user")) + + // Simulate a non-retryable background refresh error (e.g. 401) + capturedInitCallback?.onError(RuntimeException("401 Unauthorized")) + collectJob.join() + } + + assertTrue( + emittedEvents.any { it is OpenFeatureProviderEvents.ProviderError }, + "ProviderError should be emitted when background refresh fails with a non-retryable error" + ) + } + + @Test + fun `provider stays usable after non-retryable background refresh error following cache hit init`() { + every { mockDevCycleClient.hasUsableCachedConfig } returns true + + var capturedInitCallback: DevCycleCallback? = null + every { mockDevCycleClient.onInitialized(any()) } answers { + capturedInitCallback = firstArg() + } + + val mockVariable = mockk>(relaxed = true) + every { mockVariable.value } returns true + every { mockVariable.isDefaulted } returns false + every { mockVariable.eval } returns null + every { mockDevCycleClient.variable("my-flag", false) } returns mockVariable + + runBlocking { + provider.initialize(ImmutableContext(targetingKey = "cached-user")) + } + + // Simulate a non-retryable background refresh error + capturedInitCallback?.onError(RuntimeException("401 Unauthorized")) + + // Provider should still serve cached values + val eval = provider.getBooleanEvaluation("my-flag", false, null) + assertEquals(true, eval.value) + } + + @Test + fun `provider resolves fatally on network error when no cache available`() { + every { mockDevCycleClient.hasUsableCachedConfig } returns false + every { mockDevCycleClient.onInitialized(any()) } answers { + val callback = firstArg>() + callback.onError(RuntimeException("Network failure")) + } + + assertThrows(Exception::class.java) { + runBlocking { + provider.initialize(ImmutableContext(targetingKey = "fresh-user")) + } + } + } + + @Test + fun `evaluate immediately after cache-hit initialize reports CACHED reason`() { + every { mockDevCycleClient.hasUsableCachedConfig } returns true + + val mockVariable = mockk>(relaxed = true) + val mockEvalReason = mockk(relaxed = true) + every { mockVariable.key } returns "my-flag" + every { mockVariable.value } returns "cached-value" + every { mockVariable.isDefaulted } returns false + every { mockVariable.eval } returns mockEvalReason + every { mockEvalReason.reason } returns "TARGETING_MATCH" + every { mockEvalReason.details } returns null + every { mockEvalReason.targetId } returns null + every { mockDevCycleClient.variable("my-flag", "default") } returns mockVariable + + runBlocking { + provider.initialize(ImmutableContext(targetingKey = "cached-user")) + } + + val eval = provider.getStringEvaluation("my-flag", "default", null) + assertEquals("cached-value", eval.value) + assertEquals("CACHED", eval.reason) + } + private fun setupInitializedProvider() { // Make the devCycleClient available (simulate successful initialization) val providerField = DevCycleProvider::class.java.getDeclaredField("_devcycleClient") providerField.isAccessible = true providerField.set(provider, mockDevCycleClient) } -} \ No newline at end of file +} \ No newline at end of file diff --git a/android-client-sdk/src/test/java/com/devcycle/sdk/android/util/DVCSharedPrefsTests.kt b/android-client-sdk/src/test/java/com/devcycle/sdk/android/util/DVCSharedPrefsTests.kt index 2f5b528b..15cae0cc 100644 --- a/android-client-sdk/src/test/java/com/devcycle/sdk/android/util/DVCSharedPrefsTests.kt +++ b/android-client-sdk/src/test/java/com/devcycle/sdk/android/util/DVCSharedPrefsTests.kt @@ -513,28 +513,6 @@ class DVCSharedPrefsTests { verify(mockEditor, never()).apply() } - @Test - fun `should clear config and expiry for a specific user`() { - val user = createPopulatedUser(testUserId, false) - - dvcSharedPrefs.clearConfigForUser(user) - - verify(mockEditor).remove(eq("IDENTIFIED_CONFIG.$testUserId")) - verify(mockEditor).remove(eq("IDENTIFIED_CONFIG.$testUserId.EXPIRY_DATE")) - verify(mockEditor).apply() - } - - @Test - fun `should clear config for anonymous user`() { - val user = createPopulatedUser(testAnonUserId, true) - - dvcSharedPrefs.clearConfigForUser(user) - - verify(mockEditor).remove(eq("ANONYMOUS_CONFIG.$testAnonUserId")) - verify(mockEditor).remove(eq("ANONYMOUS_CONFIG.$testAnonUserId.EXPIRY_DATE")) - verify(mockEditor).apply() - } - private fun createPopulatedUser(userId: String, isAnonymous: Boolean): PopulatedUser { return PopulatedUser( userId = userId,