diff --git a/Confidence/src/main/java/com/spotify/confidence/Confidence.kt b/Confidence/src/main/java/com/spotify/confidence/Confidence.kt index c2fd3c29..e02518df 100644 --- a/Confidence/src/main/java/com/spotify/confidence/Confidence.kt +++ b/Confidence/src/main/java/com/spotify/confidence/Confidence.kt @@ -20,9 +20,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.coroutines.yield -import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json -import kotlinx.serialization.json.encodeToJsonElement import okhttp3.OkHttpClient import java.util.concurrent.TimeUnit @@ -124,9 +124,10 @@ class Confidence internal constructor( } } // we are using a custom serializer so that the Json is serialized correctly in the logs - val newMap: Map = + val contextJson = Json.encodeToJsonElement( + MapSerializer(String.serializer(), NetworkConfidenceValueSerializer), evaluationContext - val contextJson = Json.encodeToJsonElement(newMap) + ) val flag = key.splitToSequence(".").first() debugLogger?.logResolve(flag, contextJson) return eval diff --git a/Confidence/src/test/java/com/spotify/confidence/DebugLoggerIntegrationTest.kt b/Confidence/src/test/java/com/spotify/confidence/DebugLoggerIntegrationTest.kt new file mode 100644 index 00000000..e9cead77 --- /dev/null +++ b/Confidence/src/test/java/com/spotify/confidence/DebugLoggerIntegrationTest.kt @@ -0,0 +1,144 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.spotify.confidence + +import android.content.Context +import android.util.Base64 +import android.util.Log +import io.mockk.CapturingSlot +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.io.File +import java.nio.file.Files + +@OptIn(ExperimentalCoroutinesApi::class) +class DebugLoggerIntegrationTest { + + @get:Rule + var tmpFile = TemporaryFolder() + + private lateinit var filesDir: File + private val mockContext: Context = mock() + private val clientSecret = "test-client-secret" + private val logMessageSlot = CapturingSlot() + private val capturedLogMessages = mutableListOf() + + @Before + fun setup() { + mockkStatic(Log::class) + mockkStatic(Base64::class) + + // Capture debug log messages that contain base64 data + every { Log.d("Confidence", capture(logMessageSlot)) } answers { + val message = logMessageSlot.captured + capturedLogMessages.add(message) + 0 + } + every { Log.v(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.e(any(), any()) } returns 0 + every { Base64.encodeToString(any(), any()) } answers { + val input = firstArg() + java.util.Base64.getEncoder().encodeToString(input) + } + + filesDir = Files.createTempDirectory("tmpTests").toFile() + whenever(mockContext.filesDir).thenReturn(filesDir) + whenever(mockContext.getDir(any(), any())).thenReturn(Files.createTempDirectory("events").toFile()) + whenever(mockContext.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)) + .thenReturn(InMemorySharedPreferences()) + } + + @After + fun tearDown() { + unmockkAll() + filesDir.delete() + capturedLogMessages.clear() + } + + @Test + fun testDebugLoggerBase64OutputWithVanillaConfidence() = runTest(UnconfinedTestDispatcher()) { + val confidence = ConfidenceFactory.create( + context = mockContext, + clientSecret = clientSecret, + loggingLevel = LoggingLevel.VERBOSE + ) + + // Set context for the flag evaluation + confidence.putContext( + mapOf( + "visitor_id" to ConfidenceValue.String("myVistorId"), + "targeting_key" to ConfidenceValue.String("test-user-123"), + "user" to ConfidenceValue.Struct( + mapOf( + "country" to ConfidenceValue.String("SE"), + "age" to ConfidenceValue.Integer(25), + "product" to ConfidenceValue.String("premium"), + "fraud-score" to ConfidenceValue.Double(0.7) + ) + ) + ) + ) + + // Get a flag through native Confidence, which should trigger debugLogger.logResolve + // Even if the flag doesn't exist, it should still trigger logging + val result = confidence.getFlag("test-flag.value", "default") + + // The flag doesn't exist, so we get the default value, but logging should still happen + assertEquals("default", result.value) + + // Verify that debug logging was called with base64 data + verify { Log.d("Confidence", any()) } + + // Find the log message containing base64 data + val base64LogMessage = capturedLogMessages.find { + it.contains("Check your flag evaluation") && it.contains("by copy pasting the payload") + } + assertTrue("Expected to find a log message with base64 data", base64LogMessage != null) + + // Extract the base64 data from the log message + val base64Pattern = "'([A-Za-z0-9+/=]+)'$".toRegex() + val matchResult = base64Pattern.find(base64LogMessage!!) + assertTrue("Expected to find base64 data in log message", matchResult != null) + + val base64Data = matchResult!!.groupValues[1] + assertTrue("Base64 data should not be empty", base64Data.isNotEmpty()) + + // Decode and verify the JSON structure + val decodedJson = String(java.util.Base64.getDecoder().decode(base64Data)) + + // Expected JSON with clean format (no type wrappers) + assertEquals( + """{ + "flag": "flags/test-flag", + "context": { + "visitor_id": "myVistorId", + "targeting_key": "test-user-123", + "user": { + "country": "SE", + "age": 25, + "product": "premium", + "fraud-score": 0.7 + } + }, + "clientKey": "test-client-secret" +}""".replace("\n", "").replace(" ", ""), + decodedJson + ) + } +} \ No newline at end of file diff --git a/Provider/src/test/java/com/spotify/confidence/openfeature/DebugLoggerOpenFeatureIntegrationTest.kt b/Provider/src/test/java/com/spotify/confidence/openfeature/DebugLoggerOpenFeatureIntegrationTest.kt new file mode 100644 index 00000000..43503410 --- /dev/null +++ b/Provider/src/test/java/com/spotify/confidence/openfeature/DebugLoggerOpenFeatureIntegrationTest.kt @@ -0,0 +1,156 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.spotify.confidence.openfeature + +import android.content.Context +import android.util.Base64 +import android.util.Log +import com.spotify.confidence.ConfidenceFactory +import com.spotify.confidence.ConfidenceValue +import com.spotify.confidence.LoggingLevel +import dev.openfeature.kotlin.sdk.ImmutableContext +import dev.openfeature.kotlin.sdk.OpenFeatureAPI +import dev.openfeature.kotlin.sdk.Value +import io.mockk.CapturingSlot +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.io.File +import java.nio.file.Files + +class DebugLoggerOpenFeatureIntegrationTest { + + @get:Rule + var tmpFile = TemporaryFolder() + + private lateinit var filesDir: File + private val mockContext: Context = mock() + private val clientSecret = "test-client-secret" + private val logMessageSlot = CapturingSlot() + private val capturedLogMessages = mutableListOf() + + @Before + fun setup() = runTest(UnconfinedTestDispatcher()) { + mockkStatic(Log::class) + + // Capture debug log messages that contain base64 data + every { Log.d("Confidence", capture(logMessageSlot)) } answers { + val message = logMessageSlot.captured + capturedLogMessages.add(message) + 0 + } + every { Log.v(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.e(any(), any()) } returns 0 + + // Mock Base64 encoding since we're in unit test environment + mockkStatic(Base64::class) + every { Base64.encodeToString(any(), any()) } answers { + val input = firstArg() + java.util.Base64.getEncoder().encodeToString(input) + } + + filesDir = Files.createTempDirectory("tmpTests").toFile() + whenever(mockContext.filesDir).thenReturn(filesDir) + whenever(mockContext.getDir(any(), any())).thenReturn(Files.createTempDirectory("events").toFile()) + whenever(mockContext.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)) + .thenReturn(InMemorySharedPreferences()) + } + + @After + fun tearDown() = runTest(UnconfinedTestDispatcher()) { + unmockkStatic(Log::class) + filesDir.delete() + OpenFeatureAPI.shutdown() + capturedLogMessages.clear() + } + + @Test + fun testDebugLoggerBase64OutputWithOpenFeature() = runTest(UnconfinedTestDispatcher()) { + val confidence = ConfidenceFactory.create( + context = mockContext, + clientSecret = clientSecret, + initialContext = mapOf("visitor_id" to ConfidenceValue.String("myVistorId")), + loggingLevel = LoggingLevel.VERBOSE + ) + + OpenFeatureAPI.setProviderAndWait( + ConfidenceFeatureProvider.create( + confidence = confidence, + initialisationStrategy = InitialisationStrategy.ActivateAndFetchAsync + ), + ImmutableContext( + targetingKey = "test-user-123", + attributes = mutableMapOf( + "user" to Value.Structure( + mapOf( + "country" to Value.String("SE"), + "age" to Value.Integer(25), + "product" to Value.String("premium"), + "fraud-score" to Value.Double(0.7) + ) + ) + ) + ) + ) + + // Get a flag through OpenFeature, which should trigger debugLogger.logResolve + // Even if the flag doesn't exist, it should still trigger logging + val client = OpenFeatureAPI.getClient() + val result = client.getStringDetails("test-flag.value", "default") + + // The flag doesn't exist, so we get the default value, but logging should still happen + assertEquals("default", result.value) + + // Verify that debug logging was called with base64 data + verify { Log.d("Confidence", any()) } + + // Find the log message containing base64 data + val base64LogMessage = capturedLogMessages.find { + it.contains("Check your flag evaluation") && it.contains("by copy pasting the payload") + } + assertTrue("Expected to find a log message with base64 data", base64LogMessage != null) + + // Extract the base64 data from the log message + val base64Pattern = "'([A-Za-z0-9+/=]+)'$".toRegex() + val matchResult = base64Pattern.find(base64LogMessage!!) + assertTrue("Expected to find base64 data in log message", matchResult != null) + + val base64Data = matchResult!!.groupValues[1] + assertTrue("Base64 data should not be empty", base64Data.isNotEmpty()) + + // Decode and verify the JSON structure + val decodedJson = String(java.util.Base64.getDecoder().decode(base64Data)) + assertEquals( + """{ + "flag": "flags/test-flag", + "context": { + "visitor_id": "myVistorId", + "targeting_key": "test-user-123", + "user": { + "country": "SE", + "age": 25, + "product": "premium", + "fraud-score": 0.7 + } + }, + "clientKey": "test-client-secret" +}""".replace("\n", "").replace(" ", ""), + decodedJson + ) + } +} \ No newline at end of file