Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import com.devcycle.sdk.android.api.DevCycleCallback
import com.devcycle.sdk.android.api.DevCycleClient
import com.devcycle.sdk.android.api.DevCycleOptions
import com.devcycle.sdk.android.model.BaseConfigVariable
import com.devcycle.sdk.android.model.DevCycleEvent
import com.devcycle.sdk.android.model.DevCycleUser
import com.devcycle.sdk.android.model.Variable
import com.devcycle.sdk.android.util.DevCycleLogger
Expand All @@ -14,7 +13,6 @@ import dev.openfeature.sdk.exceptions.OpenFeatureError
import kotlinx.coroutines.suspendCancellableCoroutine
import org.json.JSONArray
import org.json.JSONObject
import java.math.BigDecimal
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

Expand All @@ -29,15 +27,23 @@ class DevCycleProvider(
/**
* The DevCycle client instance - created during initialization
*/
private var devCycleClient: DevCycleClient? = null
private var _devcycleClient: DevCycleClient? = null

val devcycleClient: DevCycleClient
get() = _devcycleClient
?: error(
"""
DevCycleClient is not initialized. Call OpenFeatureAPI.setProvider() / OpenFeatureAPI.setProviderAndWait() with this provider instance to initialize the DevCycleClient.
""".trimIndent()
)

/**
* Helper function to create a ProviderEvaluation from a DevCycle variable
*/
private fun <T> createProviderEvaluation(variable: Variable<*>, value: T): ProviderEvaluation<T> {
val metadataBuilder = EvaluationMetadata.builder()
var hasMetadata = false

// Add evaluation details and target ID if available
variable.eval?.let { evalReason ->
evalReason.details?.let { details ->
Expand All @@ -49,7 +55,7 @@ class DevCycleProvider(
hasMetadata = true
}
}

return ProviderEvaluation(
value = value,
variant = variable.key,
Expand Down Expand Up @@ -93,14 +99,14 @@ class DevCycleProvider(
.withContext(context)
.withSDKKey(sdkKey)
.withUser(user)

options?.let { clientBuilder.withOptions(it) }
devCycleClient = clientBuilder.build()

_devcycleClient = clientBuilder.build()

// Wait for DevCycle client to fully initialize
suspendCancellableCoroutine<Unit> { continuation ->
devCycleClient!!.onInitialized(object : DevCycleCallback<String> {
_devcycleClient!!.onInitialized(object : DevCycleCallback<String> {
override fun onSuccess(result: String) {
DevCycleLogger.d("DevCycle OpenFeature provider initialized successfully")
continuation.resume(Unit)
Expand All @@ -124,8 +130,8 @@ class DevCycleProvider(
}

override suspend fun onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) {
try {
val client = devCycleClient
try {
val client = _devcycleClient
if (client == null) {
DevCycleLogger.w(
"Context set before DevCycleProvider was fully initialized. " +
Expand Down Expand Up @@ -157,15 +163,15 @@ class DevCycleProvider(
}

override fun shutdown() {
devCycleClient?.close()
_devcycleClient?.close()
}

override fun getBooleanEvaluation(
key: String,
defaultValue: Boolean,
context: EvaluationContext?
): ProviderEvaluation<Boolean> {
val client = devCycleClient ?: return createDefaultProviderEvaluation(defaultValue)
val client = _devcycleClient ?: return createDefaultProviderEvaluation(defaultValue)
val variable = client.variable(key, defaultValue)
return createProviderEvaluation(variable, variable.value)
}
Expand All @@ -175,7 +181,7 @@ class DevCycleProvider(
defaultValue: Double,
context: EvaluationContext?
): ProviderEvaluation<Double> {
val client = devCycleClient ?: return createDefaultProviderEvaluation(defaultValue)
val client = _devcycleClient ?: return createDefaultProviderEvaluation(defaultValue)
val variable = client.variable(key, defaultValue)
return createProviderEvaluation(variable, variable.value.toDouble())
}
Expand All @@ -185,7 +191,7 @@ class DevCycleProvider(
defaultValue: Int,
context: EvaluationContext?
): ProviderEvaluation<Int> {
val client = devCycleClient ?: return createDefaultProviderEvaluation(defaultValue)
val client = _devcycleClient ?: return createDefaultProviderEvaluation(defaultValue)
val variable = client.variable(key, defaultValue)
return createProviderEvaluation(variable, variable.value.toInt())
}
Expand All @@ -195,7 +201,7 @@ class DevCycleProvider(
defaultValue: Value,
context: EvaluationContext?
): ProviderEvaluation<Value> {
val client = devCycleClient ?: return createDefaultProviderEvaluation(defaultValue)
val client = _devcycleClient ?: return createDefaultProviderEvaluation(defaultValue)

val (result, variable) = when {
defaultValue is Value.Structure -> {
Expand Down Expand Up @@ -235,7 +241,7 @@ class DevCycleProvider(
defaultValue: String,
context: EvaluationContext?
): ProviderEvaluation<String> {
val client = devCycleClient ?: return createDefaultProviderEvaluation(defaultValue)
val client = _devcycleClient ?: return createDefaultProviderEvaluation(defaultValue)
val variable = client.variable(key, defaultValue)
return createProviderEvaluation(variable, variable.value)
}
Expand All @@ -245,7 +251,7 @@ class DevCycleProvider(
context: EvaluationContext?,
details: TrackingEventDetails?
) {
val client = devCycleClient
val client = _devcycleClient
if (client == null) {
DevCycleLogger.w("Cannot track event '$trackingEventName': DevCycle client not initialized")
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,27 @@ package com.devcycle.sdk.android.openfeature
import android.content.Context
import com.devcycle.sdk.android.api.DevCycleCallback
import com.devcycle.sdk.android.api.DevCycleClient
import com.devcycle.sdk.android.api.DevCycleOptions
import com.devcycle.sdk.android.model.BaseConfigVariable
import com.devcycle.sdk.android.model.DevCycleUser
import com.devcycle.sdk.android.model.EvalReason
import com.devcycle.sdk.android.model.Variable
import dev.openfeature.sdk.*
import dev.openfeature.sdk.exceptions.OpenFeatureError
import io.mockk.*
import dev.openfeature.sdk.EvaluationMetadata
import dev.openfeature.sdk.ImmutableContext
import dev.openfeature.sdk.TrackingEventDetails
import dev.openfeature.sdk.Value
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.runBlocking
import org.json.JSONArray
import org.json.JSONObject
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.AfterEach
import dev.openfeature.sdk.EvaluationMetadata

class DevCycleProviderTest {

Expand All @@ -30,7 +35,7 @@ class DevCycleProviderTest {
fun setup() {
mockContext = mockk<Context>(relaxed = true)
mockDevCycleClient = mockk<DevCycleClient>(relaxed = true)

// Mock the DevCycleClient.builder() static method chain
mockkObject(DevCycleClient.Companion)
val mockBuilder = mockk<DevCycleClient.DevCycleClientBuilder>(relaxed = true)
Expand All @@ -40,19 +45,19 @@ class DevCycleProviderTest {
every { mockBuilder.withUser(any()) } returns mockBuilder
every { mockBuilder.withOptions(any()) } returns mockBuilder
every { mockBuilder.build() } returns mockDevCycleClient

// Mock the onInitialized method to immediately call the success callback
every { mockDevCycleClient.onInitialized(any()) } answers {
val callback = firstArg<DevCycleCallback<String>>()
callback.onSuccess("initialized")
}

// Mock the identifyUser method to immediately call the success callback
every { mockDevCycleClient.identifyUser(any(), any()) } answers {
val callback = secondArg<DevCycleCallback<Map<String, BaseConfigVariable>>>()
callback.onSuccess(emptyMap())
}

// Create the provider - now it won't try to create a real DevCycleClient
provider = DevCycleProvider("test-sdk-key", mockContext)
}
Expand All @@ -67,6 +72,15 @@ class DevCycleProviderTest {
assertEquals("DevCycle", provider.metadata.name)
}

@Test
fun `accessing devcycleClient throws exception when client not initialized`() {
val exception = assertThrows(IllegalStateException::class.java) { provider.devcycleClient }
assertTrue(
exception.message?.contains("DevCycleClient is not initialized") == true,
"Exception message should contain 'DevCycleClient is not initialized'"
)
}

@Test
fun `getBooleanEvaluation returns default when client not initialized`() {
val result = provider.getBooleanEvaluation("test-flag", false, null)
Expand Down Expand Up @@ -121,7 +135,7 @@ class DevCycleProviderTest {
@Test
fun `onContextSet handles uninitialized client gracefully`() {
val newContext = ImmutableContext(targetingKey = "new-user")

// Should not throw an exception
assertDoesNotThrow {
runBlocking {
Expand All @@ -142,7 +156,7 @@ class DevCycleProviderTest {
fun `initialize throws exception with invalid setup`() {
// This test would only work if we had a way to make DevCycleClient construction fail
// For now, we'll just verify that initialize can be called without crashing

// Should not throw when initialize is called with valid params
assertDoesNotThrow {
runBlocking {
Expand All @@ -164,27 +178,27 @@ class DevCycleProviderTest {
@Test
fun `createProviderEvaluation includes metadata when eval details are available`() {
setupInitializedProvider()

// Create a mock variable with eval information
val mockVariable = mockk<Variable<String>>(relaxed = true)
val mockEvalReason = mockk<EvalReason>(relaxed = true)

every { mockVariable.key } returns "test-variable"
every { mockVariable.value } returns "test-value"
every { mockVariable.isDefaulted } returns false
every { mockVariable.eval } returns mockEvalReason
every { mockEvalReason.reason } returns "TARGETING_MATCH"
every { mockEvalReason.details } returns "Test evaluation details"
every { mockEvalReason.targetId } returns "test-target-123"

every { mockDevCycleClient.variable("test-variable", "default") } returns mockVariable

val result = provider.getStringEvaluation("test-variable", "default", null)

assertEquals("test-value", result.value)
assertEquals("test-variable", result.variant)
assertEquals("TARGETING_MATCH", result.reason)

// Check that metadata contains the eval details and target ID
assertNotNull(result.metadata)
assertEquals("Test evaluation details", result.metadata.getString("evalDetails"))
Expand All @@ -194,27 +208,27 @@ class DevCycleProviderTest {
@Test
fun `createProviderEvaluation includes partial metadata when only some eval details are available`() {
setupInitializedProvider()

// Create a mock variable with eval information but only details (no targetId)
val mockVariable = mockk<Variable<String>>(relaxed = true)
val mockEvalReason = mockk<EvalReason>(relaxed = true)

every { mockVariable.key } returns "test-variable"
every { mockVariable.value } returns "test-value"
every { mockVariable.isDefaulted } returns false
every { mockVariable.eval } returns mockEvalReason
every { mockEvalReason.reason } returns "TARGETING_MATCH"
every { mockEvalReason.details } returns "Test evaluation details"
every { mockEvalReason.targetId } returns null // No target ID

every { mockDevCycleClient.variable("test-variable", "default") } returns mockVariable

val result = provider.getStringEvaluation("test-variable", "default", null)

assertEquals("test-value", result.value)
assertEquals("test-variable", result.variant)
assertEquals("TARGETING_MATCH", result.reason)

// Check that metadata contains the eval details but not target ID
assertNotNull(result.metadata)
assertEquals("Test evaluation details", result.metadata.getString("evalDetails"))
Expand All @@ -224,32 +238,38 @@ class DevCycleProviderTest {
@Test
fun `createProviderEvaluation uses empty metadata when no eval details are available`() {
setupInitializedProvider()

// Create a mock variable with no eval information
val mockVariable = mockk<Variable<String>>(relaxed = true)

every { mockVariable.key } returns "test-variable"
every { mockVariable.value } returns "test-value"
every { mockVariable.isDefaulted } returns true
every { mockVariable.eval } returns null // No eval data

every { mockDevCycleClient.variable("test-variable", "default") } returns mockVariable

val result = provider.getStringEvaluation("test-variable", "default", null)

assertEquals("test-value", result.value)
assertEquals("test-variable", result.variant)
assertEquals("DEFAULT", result.reason)

// Check that metadata is EMPTY when no eval data is available
assertEquals(EvaluationMetadata.EMPTY, result.metadata)
assertNull(result.metadata.getString("evalDetails"))
assertNull(result.metadata.getString("evalTargetId"))
}

@Test
fun `accessing devcycleClient returns devcycleClient when client is initialized`() {
setupInitializedProvider()
assertEquals(mockDevCycleClient, provider.devcycleClient)
}

private fun setupInitializedProvider() {
// Make the devCycleClient available (simulate successful initialization)
val providerField = DevCycleProvider::class.java.getDeclaredField("devCycleClient")
val providerField = DevCycleProvider::class.java.getDeclaredField("_devcycleClient")
providerField.isAccessible = true
providerField.set(provider, mockDevCycleClient)
}
Expand Down