diff --git a/build.gradle.kts b/build.gradle.kts index 20280f7..8b77144 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { // Uncomment when needed testImplementation("io.mockk:mockk:1.13.8") testImplementation("io.kotest:kotest-assertions-core:5.7.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.0") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Fetch.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Fetch.kt index ea950c8..1c237d6 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Fetch.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Fetch.kt @@ -8,9 +8,12 @@ import kotlinx.serialization.json.Json import okhttp3.HttpUrl.Companion.toHttpUrl import java.lang.IllegalArgumentException +const val BODY_BYTE_COUNT = 1000000L +val client = OkHttpClient() + // MARK: - Fetch datafile content @Throws(IOException::class) -internal fun FeaturevisorInstance.fetchDatafileContent( +suspend fun FeaturevisorInstance.fetchDatafileContent( url: String, handleDatafileFetch: DatafileFetchHandler? = null, completion: (Result) -> Unit, @@ -40,12 +43,10 @@ private fun fetchDatafileContentFromUrl( } } -const val BODY_BYTE_COUNT = 1000000L private inline fun fetch( request: Request, crossinline completion: (Result) -> Unit, ) { - val client = OkHttpClient() val call = client.newCall(request) call.enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { @@ -59,7 +60,7 @@ private inline fun fetch( try { val content = json.decodeFromString(responseBodyString) completion(Result.success(content)) - } catch(throwable: Throwable) { + } catch (throwable: Throwable) { completion( Result.failure( FeaturevisorError.UnparsableJson( diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Refresh.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Refresh.kt index f3094e6..a8e09f1 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Refresh.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Refresh.kt @@ -17,7 +17,7 @@ fun FeaturevisorInstance.startRefreshing() = when { refreshJob != null -> logger?.warn("refreshing has already started") refreshInterval == null -> logger?.warn("no `refreshInterval` option provided") else -> { - refreshJob = CoroutineScope(Dispatchers.Unconfined).launch { + refreshJob = coroutineScope.launch { while (isActive) { refresh() delay(refreshInterval) @@ -32,7 +32,7 @@ fun FeaturevisorInstance.stopRefreshing() { logger?.warn("refreshing has stopped") } -private fun FeaturevisorInstance.refresh() { +private suspend fun FeaturevisorInstance.refresh() { logger?.debug("refreshing datafile") when { statuses.refreshInProgress -> logger?.warn("refresh in progress, skipping") @@ -40,12 +40,10 @@ private fun FeaturevisorInstance.refresh() { else -> { statuses.refreshInProgress = true fetchDatafileContent( - datafileUrl, - handleDatafileFetch, + url = datafileUrl, + handleDatafileFetch = handleDatafileFetch, ) { result -> - - if (result.isSuccess) { - val datafileContent = result.getOrThrow() + result.onSuccess { datafileContent -> val currentRevision = getRevision() val newRevision = datafileContent.revision val isNotSameRevision = currentRevision != newRevision @@ -59,7 +57,7 @@ private fun FeaturevisorInstance.refresh() { } statuses.refreshInProgress = false - } else { + }.onFailure { logger?.error( "failed to refresh datafile", mapOf("error" to result) diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Status.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Status.kt index 9cb74d6..350e9b0 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Status.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Status.kt @@ -2,6 +2,6 @@ package com.featurevisor.sdk data class Statuses(var ready: Boolean, var refreshInProgress: Boolean) -internal fun FeaturevisorInstance.isReady(): Boolean { +fun FeaturevisorInstance.isReady(): Boolean { return statuses.ready } diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt index 30fda18..6d2e9b9 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt @@ -1,16 +1,11 @@ package com.featurevisor.sdk +import com.featurevisor.testRunner.getVariableValues import com.featurevisor.types.Context import com.featurevisor.types.FeatureKey import com.featurevisor.types.VariableKey import com.featurevisor.types.VariableValue -import com.featurevisor.types.VariableValue.ArrayValue -import com.featurevisor.types.VariableValue.BooleanValue -import com.featurevisor.types.VariableValue.DoubleValue -import com.featurevisor.types.VariableValue.IntValue -import com.featurevisor.types.VariableValue.JsonValue -import com.featurevisor.types.VariableValue.ObjectValue -import com.featurevisor.types.VariableValue.StringValue +import com.featurevisor.types.VariableValue.* import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromJsonElement @@ -76,8 +71,14 @@ inline fun FeaturevisorInstance.getVariableObject( context: Context, ): T? { val objectValue = getVariable(featureKey, variableKey, context) as? ObjectValue + val actualValue = objectValue?.value?.keys?.map { + mapOf( + it to getVariableValues(objectValue.value[it]).toString() + ) + }?.firstOrNull() + return try { - val encoded = Json.encodeToJsonElement(objectValue?.value) + val encoded = Json.encodeToJsonElement(actualValue) return Json.decodeFromJsonElement(encoded) } catch (e: Exception) { null diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance.kt b/src/main/kotlin/com/featurevisor/sdk/Instance.kt index af7ff2a..4b357cf 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance.kt @@ -6,9 +6,10 @@ package com.featurevisor.sdk import com.featurevisor.sdk.FeaturevisorError.MissingDatafileOptions import com.featurevisor.types.* import com.featurevisor.types.EventName.* -import kotlinx.coroutines.Job +import kotlinx.coroutines.* import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import kotlin.coroutines.resume typealias ConfigureBucketKey = (Feature, Context, BucketKey) -> BucketKey typealias ConfigureBucketValue = (Feature, Context, BucketValue) -> BucketValue @@ -16,7 +17,7 @@ typealias InterceptContext = (Context) -> Context typealias DatafileFetchHandler = (datafileUrl: String) -> Result var emptyDatafile = DatafileContent( - schemaVersion = "1", + schemaVersion = "1", revision = "unknown", attributes = emptyList(), segments = emptyList(), @@ -56,6 +57,8 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) { internal var configureBucketKey = options.configureBucketKey internal var configureBucketValue = options.configureBucketValue internal var refreshJob: Job? = null + private var fetchJob: Job? = null + internal val coroutineScope = CoroutineScope(Dispatchers.IO) init { with(options) { @@ -99,16 +102,24 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) { } datafileUrl != null -> { - datafileReader = DatafileReader(options.datafile?: emptyDatafile) - fetchDatafileContent(datafileUrl, handleDatafileFetch) { result -> - if (result.isSuccess) { - datafileReader = DatafileReader(result.getOrThrow()) - statuses.ready = true - emitter.emit(READY, result.getOrThrow()) - if (refreshInterval != null) startRefreshing() - } else { - logger?.error("Failed to fetch datafile: $result") - emitter.emit(ERROR) + if (::datafileReader.isInitialized.not()) { + datafileReader = DatafileReader(options.datafile ?: emptyDatafile) + } + fetchJob = coroutineScope.launch { + fetchDatafileContent( + url = datafileUrl, + handleDatafileFetch = handleDatafileFetch, + ) { result -> + result.onSuccess { datafileContent -> + datafileReader = DatafileReader(datafileContent) + statuses.ready = true + emitter.emit(READY, datafileContent) + if (refreshInterval != null) startRefreshing() + }.onFailure { error -> + logger?.error("Failed to fetch datafile: $error") + emitter.emit(ERROR) + } + cancelFetchJob() } } } @@ -118,10 +129,31 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) { } } + // Provide a mechanism to cancel the fetch job if retry count is more than one + private fun cancelFetchJob() { + fetchJob?.cancel() + fetchJob = null + } + fun setLogLevels(levels: List) { this.logger?.setLevels(levels) } + suspend fun onReady(): FeaturevisorInstance { + return suspendCancellableCoroutine { continuation -> + if (this.statuses.ready) { + continuation.resume(this) + } + + val cb :(result:Array) -> Unit = { + this.emitter.removeListener(READY) + continuation.resume(this) + } + + this.emitter.addListener(READY,cb) + } + } + fun setDatafile(datafileJSON: String) { val data = datafileJSON.toByteArray(Charsets.UTF_8) try { diff --git a/src/main/kotlin/com/featurevisor/testRunner/Utils.kt b/src/main/kotlin/com/featurevisor/testRunner/Utils.kt index d83a328..e16d980 100644 --- a/src/main/kotlin/com/featurevisor/testRunner/Utils.kt +++ b/src/main/kotlin/com/featurevisor/testRunner/Utils.kt @@ -4,6 +4,7 @@ import com.featurevisor.sdk.FeaturevisorInstance import com.featurevisor.sdk.InstanceOptions import com.featurevisor.sdk.emptyDatafile import com.featurevisor.types.* +import com.featurevisor.types.VariableValue.* import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -177,6 +178,18 @@ fun getContextValues(contextValue: AttributeValue?) = null -> null } +fun getVariableValues(variableValue: VariableValue?) = + when (variableValue) { + is IntValue -> variableValue.value + is DoubleValue -> variableValue.value + is StringValue -> variableValue.value + is BooleanValue -> variableValue.value + is ArrayValue -> variableValue.values + is JsonValue -> variableValue.value + is ObjectValue -> variableValue.value + null -> null + } + fun checkIfArraysAreEqual(a: Array, b: Array): Boolean { if (a.size != b.size) return false diff --git a/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt b/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt index e3e505d..2e091c9 100644 --- a/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt +++ b/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt @@ -3,25 +3,36 @@ */ package com.featurevisor.sdk -import com.featurevisor.types.DatafileContent +import com.featurevisor.sdk.FeaturevisorInstance.Companion.createInstance +import com.featurevisor.sdk.Logger.Companion.createLogger +import com.featurevisor.sdk.Logger.LogLevel +import com.featurevisor.types.* import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.instanceOf import io.mockk.coEvery -import io.mockk.spyk +import io.mockk.mockk +import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.* +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +@OptIn(ExperimentalCoroutinesApi::class) class InstanceTest { private val datafileUrl = "https://www.testmock.com" - private val fetchHandler = object : (String) -> Result { - override fun invoke(param: String): Result = Result.failure(Throwable()) - } - private val mockDatafileFetchHandler: DatafileFetchHandler = spyk(fetchHandler) + private val mockDatafileFetchHandler: DatafileFetchHandler = mockk(relaxed = true) private val datafileContent = DatafileContent( - schemaVersion = "0", - revision = "0", - attributes = listOf(), - segments = listOf(), - features = listOf() + schemaVersion = "1", + revision = "1.0", + attributes = emptyList(), + segments = emptyList(), + features = emptyList() ) private var instanceOptions = InstanceOptions( bucketKeySeparator = "", @@ -41,31 +52,1463 @@ class InstanceTest { stickyFeatures = mapOf(), onError = {}, ) - private val systemUnderTest = FeaturevisorInstance.createInstance( - options = instanceOptions - ) + + private val dispatcher = TestCoroutineDispatcher() + private val testScope = TestCoroutineScope(dispatcher) + + @BeforeEach + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `sdk should be a function`() { + val sdk = createInstance( + instanceOptions + ) + + sdk shouldBe instanceOf() + } + + @Test + fun `sdk should create instance with datafile content`() { + val sdk = FeaturevisorInstance.createInstance( + instanceOptions + ) + + sdk shouldBe instanceOf() + sdk.statuses.ready shouldBe true + } + @Test fun `instance initialised properly`() { - systemUnderTest.statuses.ready shouldBe true + val sdk = createInstance( + options = instanceOptions + ) + + sdk.statuses.ready shouldBe true } @Test fun `instance fetches data using handleDatafileFetch`() { - coEvery { mockDatafileFetchHandler(datafileUrl) } returns Result.success(datafileContent) - instanceOptions = instanceOptions.copy( - datafileUrl = datafileUrl, - datafile = null, - handleDatafileFetch = mockDatafileFetchHandler, + testScope.launch { + coEvery { mockDatafileFetchHandler(datafileUrl) } returns Result.success(datafileContent) + + val sdk = createInstance( + options = instanceOptions.copy( + datafileUrl = datafileUrl, + datafile = null, + handleDatafileFetch = mockDatafileFetchHandler, + ) + ) + + sdk.statuses.ready shouldBe true + + verify(exactly = 1) { + mockDatafileFetchHandler(datafileUrl) + } + } + } + + @Test + fun `should trigger onReady event once`() { + testScope.launch { + var readyCount = 0 + + val sdk = createInstance( + instanceOptions.copy( + onReady = { + readyCount += 1 + } + ) + ) + + delay(0) + + readyCount shouldBe 1 + sdk.isReady() shouldBe true + } + } + + @Test + fun `should resolve onReady method as Promise when initialized synchronously`() { + testScope.launch { + var readyCount = 0 + + var sdk = createInstance( + instanceOptions.copy( + onReady = { + readyCount += 1 + } + ) + + ) + + delay(0) + + sdk = sdk.onReady() + + sdk.isReady() shouldBe true + readyCount shouldBe 1 + } + } + + @Test + fun `should resolve onReady method as Promise when fetching datafile remotely`() { + testScope.launch { + var readyCount = 0 + + var sdk = createInstance( + instanceOptions.copy( + datafileUrl = datafileUrl, + onReady = { + readyCount += 1 + } + ) + ) + + sdk = sdk.onReady() + + sdk.isReady() shouldBe true + readyCount shouldBe 1 + } + + } + + @Test + fun `should configure plain bucketBy`() { + var capturedBucketKey = "" + + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation(variation = "control", range = listOf(0, 100000)), + Allocation(variation = "treatment", range = listOf(0, 0)) + ) + ) + ) + ) + ) + ), + configureBucketKey = { _, _, bucketKey -> + capturedBucketKey = bucketKey + bucketKey + } + ) ) - FeaturevisorInstance.createInstance( - options = instanceOptions + val featureKey = "test" + val context = mapOf("userId" to AttributeValue.StringValue("123")) + + sdk.isEnabled(featureKey, context) shouldBe true + sdk.getVariation(featureKey, context) shouldBe "control" + "${AttributeValue.StringValue("123")}${AttributeValue.StringValue("test")}" shouldBe capturedBucketKey + } + + @Test + fun `should configure and bucketBy`() { + var capturedBucketKey = "" + + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.And(listOf("userId", "organizationId")), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation(variation = "control", range = listOf(0, 100000)), + Allocation(variation = "treatment", range = listOf(0, 0)) + ) + ) + ) + ) + ) + ), + configureBucketKey = { feature, context, bucketKey -> + capturedBucketKey = bucketKey + bucketKey + } + ) + + ) + + val featureKey = "test" + val context = mapOf( + "userId" to AttributeValue.StringValue("123"), + "organizationId" to AttributeValue.StringValue("456") + ) + + sdk.getVariation(featureKey, context) shouldBe "control" + capturedBucketKey shouldBe "${AttributeValue.StringValue("123")}${AttributeValue.StringValue("456")}${ + AttributeValue.StringValue( + "test" + ) + }" + } + + @Test + fun `should configure or bucketBy`() { + var capturedBucketKey = "" + + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Or(listOf("userId", "deviceId")), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation(variation = "control", range = listOf(0, 100000)), + Allocation(variation = "treatment", range = listOf(0, 0)) + ) + ) + ) + ) + ) + ), + configureBucketKey = { _, _, bucketKey -> + capturedBucketKey = bucketKey + bucketKey + } + ) + ) + + val context1 = mapOf( + "userId" to AttributeValue.StringValue("123"), + "deviceId" to AttributeValue.StringValue("456") + ) + + sdk.isEnabled("test", context1) shouldBe true + sdk.getVariation("test", context1) shouldBe "control" + capturedBucketKey shouldBe "${AttributeValue.StringValue("123")}${AttributeValue.StringValue("test")}" + + val context2 = mapOf( + "deviceId" to AttributeValue.StringValue("456") + ) + + sdk.getVariation("test", context2) shouldBe "control" + capturedBucketKey shouldBe "${AttributeValue.StringValue("456")}${AttributeValue.StringValue("test")}" + } + + @Test + fun `should intercept context`() { + var intercepted = false + + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 100000)), + Allocation("treatment", listOf(0, 0)) + ) + ) + ) + ) + ) + ), + interceptContext = { context -> + intercepted = true + context // Return the context as is (modify if needed) + } + ) ) - verify(exactly = 1) { - mockDatafileFetchHandler(datafileUrl) + val variation = sdk.getVariation( + "test", + mapOf("userId" to AttributeValue.StringValue("123")) + ) + + variation shouldBe "control" + intercepted shouldBe true + } + + @Test + fun `should activate feature`() { + var activated = false + + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 100000)), + Allocation("treatment", listOf(0, 0)) + ) + ) + ) + ) + ) + ), + onActivation = { + activated = true + } + ) + + ) + + val variation = sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("123"))) + + activated shouldBe false + variation shouldBe "control" + + val activatedVariation = sdk.activate("test", mapOf("userId" to AttributeValue.StringValue("123"))) + + activated shouldBe true + activatedVariation shouldBe "control" + } + + @Test + fun `should refresh datafile`() { + testScope.launch { + var refreshed = false + var updatedViaOption = false + + val sdk = createInstance( + instanceOptions.copy( + datafileUrl = datafileUrl, + datafile = null, + refreshInterval = 2L, + onReady = { + println("ready") + }, + onRefresh = { + refreshed = true + }, + onUpdate = { + updatedViaOption = true + } + ) + + ) + + sdk.isReady() shouldBe false + + delay(3) + + refreshed shouldBe true + updatedViaOption shouldBe true + sdk.isReady() shouldBe true + + sdk.stopRefreshing() } - systemUnderTest.statuses.ready shouldBe true + } + + @Test + fun `should initialize with sticky features`() { + + testScope.launch { + val sdk = createInstance( + instanceOptions.copy( + stickyFeatures = mapOf( + "test" to OverrideFeature( + enabled = true, + variation = "control", + variables = mapOf("color" to VariableValue.StringValue("red")) + ) + ), + datafile = datafileContent, + handleDatafileFetch = { + val content = DatafileContent( + schemaVersion = "1", + revision = "1.0", + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 0)), + Allocation("treatment", listOf(0, 100000)) + ) + ) + ) + ) + ), + attributes = emptyList(), + segments = emptyList() + ) + + runBlocking { delay(50) } + Result.success(content) + } + ) + + ) + + sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe "control" + sdk.getVariable("test", "color", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe "red" + + delay(75) + + sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe "control" + + sdk.setStickyFeatures(emptyMap()) + + sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe "treatment" + + } + } + + @Test + fun `should initialize with initial features`() { + testScope.launch { + val sdk = createInstance( + instanceOptions.copy( + initialFeatures = mapOf( + "test" to OverrideFeature( + enabled = true, + variation = "control", + variables = mapOf("color" to VariableValue.StringValue("red")) + ) + ), + datafileUrl = datafileUrl, + handleDatafileFetch = { + Result.success( + datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 0)), + Allocation("treatment", listOf(0, 100000)) + ) + ) + ) + ) + ) + ) + ) + } + ) + ) + + sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe "control" + sdk.getVariable("test", "color", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe "red" + + sdk.fetchDatafileContent(url = datafileUrl) { + sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe "treatment" + } + } + } + + + @Test + fun `should honour simple required features`() { + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "requiredKey", + bucketBy = BucketBy.Single("userId"), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 0, // disabled + allocation = emptyList() + ) + ) + ), + Feature( + key = "myKey", + bucketBy = BucketBy.Single("userId"), + required = listOf( + Required.FeatureKey("requiredKey") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = emptyList() + ) + ) + ) + ) + ) + ) + ) + + sdk.isEnabled("myKey") shouldBe false + + // enabling required should enable the feature too + val sdk2 = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "requiredKey", + bucketBy = BucketBy.Single("userId"), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, // enabled + allocation = emptyList() + ) + ) + ), + Feature( + key = "myKey", + bucketBy = BucketBy.Single("userId"), + required = listOf(Required.FeatureKey("requiredKey")), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = emptyList() + ) + ) + ) + ) + ) + ) + ) + + sdk2.isEnabled("myKey") shouldBe true + } + + @Test + fun `should honour required features with variation`() { + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "requiredKey", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 0)), + Allocation("treatment", listOf(0, 100000)) + ) + ) + ) + ), + Feature( + key = "myKey", + bucketBy = BucketBy.Single("userId"), + required = listOf( + Required.WithVariation( + RequiredWithVariation( + "requiredKey", + "control" + ) + ) // different variation + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = emptyList() + ) + ) + ) + ) + ) + ) + ) + + sdk.isEnabled("myKey") shouldBe false + + // child should be enabled because required has desired variation + val sdk2 = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "requiredKey", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 0)), + Allocation("treatment", listOf(0, 100000)) + ) + ) + ) + ), + Feature( + key = "myKey", + bucketBy = BucketBy.Single("userId"), + required = listOf( + Required.WithVariation( + RequiredWithVariation( + "requiredKey", + "treatment" + ) + ) // desired variation + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = emptyList() + ) + ) + ) + ) + ) + ) + ) + + sdk2.isEnabled("myKey") shouldBe true + } + + + @Test + fun `should emit warnings for deprecated feature`() { + var deprecatedCount = 0 + + val sdk = createInstance( + instanceOptions.copy( + datafile = + datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 100000)), + Allocation("treatment", listOf(0, 0)) + ) + ) + ) + ), + Feature( + key = "deprecatedTest", + deprecated = true, + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 100000)), + Allocation("treatment", listOf(0, 0)) + ) + ) + ) + ) + ) + ), + logger = createLogger { level, message, _ -> + if (level == LogLevel.WARN && message.contains("is deprecated")) { + deprecatedCount += 1 + } + } + ) + ) + + val testVariation = sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("123"))) + val deprecatedTestVariation = + sdk.getVariation("deprecatedTest", mapOf("userId" to AttributeValue.StringValue("123"))) + + testVariation shouldBe "control" + deprecatedTestVariation shouldBe "control" + deprecatedCount shouldBe 1 + } + + + @Test + fun `should check if enabled for overridden flags from rules`() { + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + traffic = listOf( + Traffic( + key = "2", + segments = GroupSegment.Multiple( + listOf( + GroupSegment.Plain("netherlands") + ) + ), + percentage = 100000, + enabled = false, + allocation = emptyList() + ), + Traffic( + key = "1", + segments = GroupSegment.Multiple( + listOf( + GroupSegment.Plain("*") + ) + ), + percentage = 100000, + allocation = emptyList() + ) + ) + ) + ), + segments = listOf( + Segment( + key = "netherlands", + conditions = Condition.Plain( + "country", Operator.EQUALS, ConditionValue.StringValue("nl") + ) + ) + ) + ) + ) + + ) + + sdk.isEnabled( + "test", mapOf( + "userId" to AttributeValue.StringValue("user-123"), + "country" to AttributeValue.StringValue("de") + ) + ) shouldBe true + sdk.isEnabled( + "test", mapOf( + "userId" to AttributeValue.StringValue("user-123"), + "country" to AttributeValue.StringValue("nl") + ) + ) shouldBe false + } + + @Test + fun `should check if enabled for mutually exclusive features`() { + var bucketValue = 10000 + + val sdk = createInstance( + instanceOptions.copy( + configureBucketValue = { _, _, _ -> + bucketValue + }, + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "mutex", + bucketBy = BucketBy.Single("userId"), + ranges = listOf(listOf(0, 50000)), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Multiple( + listOf( + GroupSegment.Plain("*") + ) + ), + percentage = 50000, + allocation = emptyList() + ) + ) + ) + ) + ) + ) + + ) + + sdk.isEnabled("test") shouldBe false + sdk.isEnabled("test", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe false + + bucketValue = 40000 + sdk.isEnabled("mutex", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe true + + bucketValue = 60000 + sdk.isEnabled("mutex", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe false + } + + @Test + fun `should get variation`() { + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + force = listOf( + Force( + conditions = Condition.And( + listOf( + Condition.Plain( + "userId", + Operator.EQUALS, + ConditionValue.StringValue("user-gb") + ) + ) + ), + enabled = false + ), + Force( + segments = GroupSegment.Multiple(listOf(GroupSegment.Plain("netherlands"))), + enabled = false + ) + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Multiple(listOf(GroupSegment.Plain("*"))), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 0)), + Allocation("treatment", listOf(0, 100000)) + ) + ) + ) + ), + Feature( + key = "testWithNoVariation", + bucketBy = BucketBy.Single("userId"), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Multiple(listOf(GroupSegment.Plain("*"))), + percentage = 100000, + allocation = emptyList() + ) + ) + ) + ), + segments = listOf( + Segment( + key = "netherlands", + conditions = Condition.And( + listOf( + Condition.Plain( + "country", + Operator.EQUALS, + ConditionValue.StringValue("nl") + ) + ) + ) + ) + ) + ) + ) + + ) + + val context = mapOf("userId" to AttributeValue.StringValue("123")) + + sdk.getVariation("test", context) shouldBe "treatment" + sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("user-ch"))) shouldBe "treatment" + + // non-existing feature + sdk.getVariation("nonExistingFeature", context) shouldBe null + + // disabled features + sdk.getVariation("nonExistingFeature", mapOf("userId" to AttributeValue.StringValue("user-gb"))) shouldBe null + sdk.getVariation( + "nonExistingFeature", mapOf( + "userId" to AttributeValue.StringValue("user-gb"), + "country" to AttributeValue.StringValue("nl") + ) + ) shouldBe null + + // no variation + sdk.getVariation("testWithNoVariation", context) shouldBe null + } + + @Test + fun `should get variable`() { + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variablesSchema = listOf( + VariableSchema( + key = "color", + type = VariableType.STRING, + defaultValue = VariableValue.StringValue("red") + ), + VariableSchema( + key = "showSidebar", + type = VariableType.BOOLEAN, + defaultValue = VariableValue.BooleanValue(false) + ), + VariableSchema( + key = "sidebarTitle", + type = VariableType.STRING, + defaultValue = VariableValue.StringValue("sidebar title") + ), + VariableSchema( + key = "count", + type = VariableType.INTEGER, + defaultValue = VariableValue.IntValue(0) + ), VariableSchema( + key = "price", + type = VariableType.DOUBLE, + defaultValue = VariableValue.DoubleValue(9.99) + ), + + VariableSchema( + key = "paymentMethods", + type = VariableType.ARRAY, + defaultValue = VariableValue.ArrayValue(listOf("paypal", "creditcard")) + ), + VariableSchema( + key = "flatConfig", + type = VariableType.OBJECT, + defaultValue = VariableValue.ObjectValue(mapOf("key" to VariableValue.StringValue("value"))) + ), + VariableSchema( + key = "nestedConfig", + type = VariableType.JSON, + defaultValue = VariableValue.JsonValue("""{"key":{"nested":"value"}}""") + ) + ), + variations = listOf( + Variation( + value = "control" + ), + Variation( + value = "treatment", + variables = listOf( + Variable( + key = "showSidebar", + value = VariableValue.BooleanValue(true), + overrides = listOf( + VariableOverride( + segments = + GroupSegment.Multiple( + listOf( + GroupSegment.Plain( + "netherlands" + ) + ) + ), + value = VariableValue.BooleanValue(false) + ), + VariableOverride( + conditions = Condition.Plain( + "country", + Operator.EQUALS, + ConditionValue.StringValue("de") + ), + value = VariableValue.BooleanValue(false) + ) + ) + ), + Variable( + key = "sidebarTitle", + value = VariableValue.StringValue("sidebar title from variation"), + overrides = listOf( + VariableOverride( + segments = + GroupSegment.Multiple( + listOf( + GroupSegment.Plain( + "netherlands" + ) + ) + ), + value = VariableValue.StringValue("Dutch title"), + + + ), + VariableOverride( + conditions = Condition.Plain( + "country", + Operator.EQUALS, + ConditionValue.StringValue("de") + ), + value = VariableValue.StringValue("German title") + ), + ) + ) + ) + ), + ), + force = listOf( + Force( + conditions = Condition.And( + listOf( + Condition.Plain( + attributeKey = "userId", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("user-ch") + ) + ) + ), + enabled = true, + variation = "control", + variables = mapOf("color" to VariableValue.StringValue("red and white")) + ), + Force( + conditions = Condition.And( + listOf( + Condition.Plain( + attributeKey = "userId", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("user-gb") + ) + ) + ), + enabled = false + ), + Force( + conditions = Condition.And( + listOf( + Condition.Plain( + attributeKey = "userId", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("user-forced-variation") + ) + ) + ), + enabled = true, + variation = "treatment" + ) + ), + traffic = listOf( + Traffic( + key = "2", + segments = GroupSegment.Multiple( + listOf( + GroupSegment.Plain( + "belgium" + ) + ) + ), + percentage = 100000, + allocation = listOf( + Allocation( + variation = "control", + range = listOf(0, 0) + ), + Allocation( + variation = "treatment", + range = listOf(0, 100000) + ) + ), + variation = "control", + variables = mapOf("color" to VariableValue.StringValue("black")) + ), + Traffic( + key = "1", + segments = GroupSegment.Plain( + "*" + ), + percentage = 100000, + allocation = listOf( + Allocation( + variation = "control", + range = listOf(0, 0) + ), + Allocation( + variation = "treatment", + range = listOf(0, 100000) + ) + ) + ) + ) + ) + ), + attributes = listOf( + Attribute(key = "userId", type = "string", capture = true), + Attribute(key = "country", type = "string") + ), + segments = listOf( + Segment( + key = "netherlands", + conditions = Condition.Plain( + attributeKey = "country", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("nl") + ) + ), + Segment( + key = "belgium", + conditions = Condition.Plain( + attributeKey = "country", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("be") + ) + ) + ) + ) + ) + ) + + val context = mapOf("userId" to AttributeValue.StringValue("123")) + + sdk.getVariation("test", context) shouldBe "treatment" + sdk.getVariation("test", context + mapOf("country" to AttributeValue.StringValue("be"))) shouldBe "control" + sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("user-ch"))) shouldBe "control" + + (sdk.getVariable("test", "color", context) as VariableValue.StringValue).value shouldBe "red" + sdk.getVariableString("test", "color", context) shouldBe "red" + (sdk.getVariable( + "test", + "color", + context.toMutableMap().apply { putAll(mapOf("country" to AttributeValue.StringValue("be"))) } + ) as VariableValue.StringValue).value shouldBe "black" + (sdk.getVariable( + "test", + "color", + mapOf("userId" to AttributeValue.StringValue("user-ch")) + ) as VariableValue.StringValue).value shouldBe "red and white" + + (sdk.getVariable("test", "showSidebar", context) as VariableValue.BooleanValue).value shouldBe true + sdk.getVariableBoolean("test", "showSidebar", context) shouldBe true + sdk.getVariableBoolean( + "test", + "showSidebar", + context + mapOf("country" to AttributeValue.StringValue("nl")) + ) shouldBe false + sdk.getVariableBoolean( + "test", + "showSidebar", + context + mapOf("country" to AttributeValue.StringValue("de")) + ) shouldBe false + + sdk.getVariableString( + "test", "sidebarTitle", + mapOf( + "userId" to AttributeValue.StringValue("user-forced-variation"), + "country" to AttributeValue.StringValue("de") + ) + ) shouldBe "German title" + sdk.getVariableString( + "test", + "sidebarTitle", + mapOf( + "userId" to AttributeValue.StringValue("user-forced-variation"), + "country" to AttributeValue.StringValue("nl") + ) + ) shouldBe "Dutch title" + sdk.getVariableString( + "test", + "sidebarTitle", + mapOf( + "userId" to AttributeValue.StringValue("user-forced-variation"), + "country" to AttributeValue.StringValue("be") + ) + ) shouldBe "sidebar title from variation" + + (sdk.getVariable("test", "count", context) as VariableValue.IntValue).value shouldBe 0 + sdk.getVariableInteger("test", "count", context) shouldBe 0 + + (sdk.getVariable("test", "price", context) as VariableValue.DoubleValue).value shouldBe 9.99 + sdk.getVariableDouble("test", "price", context) shouldBe 9.99 + + (sdk.getVariable( + "test", + "paymentMethods", + context + ) as VariableValue.ArrayValue).values shouldBe listOf("paypal", "creditcard") + sdk.getVariableArray("test", "paymentMethods", context) shouldBe listOf("paypal", "creditcard") + + (sdk.getVariable( + "test", + "flatConfig", + context + ) as VariableValue.ObjectValue).value shouldBe mapOf("key" to VariableValue.StringValue(value = "value")) + sdk.getVariableObject>("test", "flatConfig", context) shouldBe mapOf("key" to "value") + + (sdk.getVariable( + "test", + "nestedConfig", + context + ) as VariableValue.JsonValue).value shouldBe "{\"key\":{\"nested\":\"value\"}}" + mapOf("key" to mapOf("nested" to "value")) shouldBe sdk.getVariableJSON("test", "nestedConfig", context) + + // Non-existing + sdk.getVariable("test", "nonExisting", context) shouldBe null + sdk.getVariable("nonExistingFeature", "nonExisting", context) shouldBe null + + // Disabled + sdk.getVariable("test", "color", mapOf("userId" to AttributeValue.StringValue("user-gb"))) shouldBe null + } + + @Test + fun `should get variables without any variations`() { + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + schemaVersion = "1", + revision = "1.0", + attributes = listOf( + Attribute(key = "userId", type = "string", capture = true), + Attribute(key = "country", type = "string") + ), + segments = listOf( + Segment( + key = "netherlands", + conditions = Condition.Plain( + attributeKey = "country", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("nl") + ), + + ) + ), + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variablesSchema = listOf( + VariableSchema( + key = "color", + type = VariableType.STRING, + defaultValue = VariableValue.StringValue("red") + ) + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("netherlands"), + percentage = 100000, + variables = mapOf("color" to VariableValue.StringValue("orange")), + allocation = emptyList() + ), + Traffic( + key = "2", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = emptyList() + ) + ) + ) + ) + ) + ) + ) + + val defaultContext = mapOf("userId" to AttributeValue.StringValue("123")) + + // Test default value + (sdk.getVariable("test", "color", defaultContext) as VariableValue.StringValue).value shouldBe "red" + + // Test override + (sdk.getVariable( + "test", + "color", + defaultContext + mapOf("country" to AttributeValue.StringValue("nl")) + ) as VariableValue.StringValue).value shouldBe "orange" + } + + @Test + fun `should check if enabled for individually named segments`() { + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + schemaVersion = "1", + revision = "1.0", + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("netherlands"), + percentage = 100000, + allocation = emptyList() + ), + Traffic( + key = "2", + segments = GroupSegment.Multiple( + listOf( + GroupSegment.Plain("iphone"), + GroupSegment.Plain("unitedStates") + ) + ), + percentage = 100000, + allocation = emptyList() + ) + ) + ) + ), + attributes = emptyList(), + segments = listOf( + Segment( + key = "netherlands", + conditions = Condition.Plain( + attributeKey = "country", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("nl") + ) + ), + Segment( + key = "iphone", + conditions = Condition.Plain( + attributeKey = "device", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("iphone") + ) + + ), + Segment( + key = "unitedStates", + conditions = Condition.Plain( + attributeKey = "country", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("us") + ) + ) + ) + ) + ) + ) + + // Check if enabled + assertEquals(false, sdk.isEnabled("test")) + assertEquals(false, sdk.isEnabled("test", mapOf("userId" to AttributeValue.StringValue("123")))) + assertEquals( + false, + sdk.isEnabled( + "test", + mapOf("userId" to AttributeValue.StringValue("123"), "country" to AttributeValue.StringValue("de")) + ) + ) + assertEquals( + false, + sdk.isEnabled( + "test", + mapOf("userId" to AttributeValue.StringValue("123"), "country" to AttributeValue.StringValue("us")) + ) + ) + + assertEquals( + true, + sdk.isEnabled( + "test", + mapOf("userId" to AttributeValue.StringValue("123"), "country" to AttributeValue.StringValue("nl")) + ) + ) + assertEquals( + true, + sdk.isEnabled( + "test", + mapOf( + "userId" to AttributeValue.StringValue("123"), + "country" to AttributeValue.StringValue("us"), + "device" to AttributeValue.StringValue("iphone") + ) + ) + ) } }