Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-19 18:42:56 +04:00
parent 5da87df354
commit e725649059
18 changed files with 661 additions and 421 deletions

View file

@ -9,6 +9,7 @@ import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import timber.log.Timber
import java.lang.reflect.Field
class ApplicationInjectionExecutionRule(
private val toggleStates: Map<String, Boolean>,
@ -17,7 +18,7 @@ class ApplicationInjectionExecutionRule(
private val tangemApplication: TangemApplication
get() = ApplicationProvider.getApplicationContext()
private var originalFeatureTogglesValues: Map<String, String>? = null
private var originalVersionValues: Map<FeatureToggles, String>? = null
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
@ -41,10 +42,9 @@ class ApplicationInjectionExecutionRule(
}
}
@Suppress("UNCHECKED_CAST")
private fun saveOriginalFeatureToggles() {
try {
originalFeatureTogglesValues = FeatureToggles.values as Map<String, String>
originalVersionValues = FeatureToggles.entries.associateWith { it.version }
} catch (e: Exception) {
Timber.e("Failed to save original toggles values: ${e.message}")
}
@ -52,22 +52,14 @@ class ApplicationInjectionExecutionRule(
private fun overrideFeatureToggles() {
try {
val originalValues = originalFeatureTogglesValues ?: FeatureToggles.values
val newValues = originalValues.toMutableMap()
val versionField = getVersionField()
toggleStates.forEach { (toggle, enabled) ->
if (enabled) {
newValues[toggle] = "1.0.0"
} else {
newValues.remove(toggle)
}
FeatureToggles.entries.forEach { toggle ->
val enabled = toggleStates[toggle.rawName] ?: return@forEach
val newVersion = if (enabled) ENABLED_VERSION else DISABLED_VERSION
versionField.set(toggle, newVersion)
}
val companionClass = FeatureToggles.Companion::class.java
val valuesField = companionClass.getDeclaredField("values")
valuesField.isAccessible = true
valuesField.set(FeatureToggles.Companion, newValues)
Timber.i("FeatureToggles.values updated: $toggleStates")
} catch (e: Exception) {
Timber.e("FeatureToggles.values didn't change with error: ${e.message}")
@ -76,15 +68,27 @@ class ApplicationInjectionExecutionRule(
private fun restoreOriginalFeatureToggles() {
try {
if (originalFeatureTogglesValues != null) {
val companionClass = FeatureToggles.Companion::class.java
val valuesField = companionClass.getDeclaredField("values")
valuesField.isAccessible = true
valuesField.set(FeatureToggles.Companion, originalFeatureTogglesValues)
Timber.i("FeatureToggles.values restored")
val saved = originalVersionValues ?: return
val versionField = getVersionField()
saved.forEach { (toggle, originalVersion) ->
versionField.set(toggle, originalVersion)
}
Timber.i("FeatureToggles.values restored")
} catch (e: Exception) {
Timber.e("FeatureToggles.values didn't restored with error: ${e.message}")
}
}
private fun getVersionField(): Field {
val field = FeatureToggles::class.java.getDeclaredField("version")
field.isAccessible = true
return field
}
private companion object {
const val ENABLED_VERSION = "1.0.0"
const val DISABLED_VERSION = "undefined"
}
}

View file

@ -1,8 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>Indentation:ExcludedBlockchainToggles.kt$ExcludedBlockchainToggles$ </ID>
<ID>Indentation:FeatureToggles.kt$FeatureToggles$ </ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -1,7 +1,7 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.provider.ExcludedBlockchainTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.utils.toTableString
@ -12,11 +12,13 @@ import kotlin.properties.Delegates
/**
* [MutableExcludedBlockchainsManager] implementation in dev or mocked build
*
* @property versionProvider application version provider
* @property localTogglesStorage local storage for blockchain toggles
* @property versionProvider application version provider
* @property excludedBlockchainTogglesProvider provider for excluded blockchain toggle entries
* @property localTogglesStorage local storage for blockchain toggles
*/
internal class DevExcludedBlockchainsManager(
private val versionProvider: VersionProvider,
private val excludedBlockchainTogglesProvider: ExcludedBlockchainTogglesProvider,
private val localTogglesStorage: LocalTogglesStorage,
) : MutableExcludedBlockchainsManager {
@ -63,6 +65,7 @@ internal class DevExcludedBlockchainsManager(
private fun getFileBlockchainToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return ExcludedBlockchainToggles.values.defineTogglesAvailability(appVersion = appVersion)
return excludedBlockchainTogglesProvider.getToggles()
.defineTogglesAvailability(appVersion = appVersion)
}
}

View file

@ -1,17 +1,19 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.provider.ExcludedBlockchainTogglesProvider
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.version.VersionProvider
/**
* [ExcludedBlockchainsManager] implementation in PROD build
*
* @property versionProvider application version provider
* @property versionProvider application version provider
* @property excludedBlockchainTogglesProvider provider for excluded blockchain toggle entries
*/
internal class ProdExcludedBlockchainsManager(
private val versionProvider: VersionProvider,
private val excludedBlockchainTogglesProvider: ExcludedBlockchainTogglesProvider,
) : ExcludedBlockchainsManager {
override val excludedBlockchainsIds: Set<String> = getBlockchainToggles()
@ -19,7 +21,7 @@ internal class ProdExcludedBlockchainsManager(
private fun getBlockchainToggles(): Set<String> {
val appVersion = versionProvider.get()
return ExcludedBlockchainToggles.values
return excludedBlockchainTogglesProvider.getToggles()
.defineTogglesAvailability(appVersion = appVersion)
.filterValues { !it }
.keys

View file

@ -0,0 +1,15 @@
package com.tangem.core.configtoggle.blockchain.provider
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import javax.inject.Inject
/**
* Default implementation of [ExcludedBlockchainTogglesProvider] that reads from the generated
* [ExcludedBlockchainToggles] enum.
*/
internal class DefaultExcludedBlockchainTogglesProvider @Inject constructor() : ExcludedBlockchainTogglesProvider {
override fun getToggles(): Map<String, String> {
return ExcludedBlockchainToggles.entries.associate { it.rawName to it.version }
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.core.configtoggle.blockchain.provider
/**
* Provider for excluded blockchain toggle entries.
* This interface allows for easier testing by enabling mock implementations.
*/
internal interface ExcludedBlockchainTogglesProvider {
/**
* Returns a map of blockchain IDs to their version strings.
* The version string is "undefined" for disabled toggles, or a version number like "5.21.0" for enabled ones.
*/
fun getToggles(): Map<String, String>
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.configtoggle.BuildConfig
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.impl.DevExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.impl.ProdExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.provider.DefaultExcludedBlockchainTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.DefaultVersionProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -26,17 +27,22 @@ internal object ExcludedBlockchainsManagerModule {
appPreferencesStore: AppPreferencesStore,
): ExcludedBlockchainsManager {
val versionProvider = DefaultVersionProvider(context)
val excludedBlockchainTogglesProvider = DefaultExcludedBlockchainTogglesProvider()
return if (BuildConfig.TESTER_MENU_ENABLED) {
DevExcludedBlockchainsManager(
versionProvider = versionProvider,
excludedBlockchainTogglesProvider = excludedBlockchainTogglesProvider,
localTogglesStorage = LocalTogglesStorage(
appPreferencesStore = appPreferencesStore,
preferencesKey = LocalTogglesStorage.EXCLUDED_BLOCKCHAINS_KEY,
),
)
} else {
ProdExcludedBlockchainsManager(versionProvider = versionProvider)
ProdExcludedBlockchainsManager(
versionProvider = versionProvider,
excludedBlockchainTogglesProvider = excludedBlockchainTogglesProvider,
)
}
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.configtoggle.BuildConfig
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.feature.provider.DefaultFeatureTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.DefaultVersionProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -26,17 +27,22 @@ internal object FeatureTogglesManagerModule {
appPreferencesStore: AppPreferencesStore,
): FeatureTogglesManager {
val versionProvider = DefaultVersionProvider(context)
val featureTogglesProvider = DefaultFeatureTogglesProvider()
return if (BuildConfig.TESTER_MENU_ENABLED) {
DevFeatureTogglesManager(
versionProvider = versionProvider,
featureTogglesProvider = featureTogglesProvider,
featureTogglesLocalStorage = LocalTogglesStorage(
appPreferencesStore = appPreferencesStore,
preferencesKey = LocalTogglesStorage.FEATURE_TOGGLES_KEY,
),
)
} else {
ProdFeatureTogglesManager(versionProvider = versionProvider)
ProdFeatureTogglesManager(
versionProvider = versionProvider,
featureTogglesProvider = featureTogglesProvider,
)
}
}
}

View file

@ -1,8 +1,8 @@
package com.tangem.core.configtoggle.feature.impl
import androidx.annotation.VisibleForTesting
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager
import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.utils.toTableString
@ -14,10 +14,12 @@ import kotlin.properties.Delegates
* Feature toggles manager implementation in dev or mocked build
*
* @property versionProvider application version provider
* @property featureTogglesProvider provider for feature toggle entries
* @property featureTogglesLocalStorage local storage for feature toggles
*/
internal class DevFeatureTogglesManager(
private val versionProvider: VersionProvider,
private val featureTogglesProvider: FeatureTogglesProvider,
private val featureTogglesLocalStorage: LocalTogglesStorage,
) : MutableFeatureTogglesManager {
@ -60,7 +62,8 @@ internal class DevFeatureTogglesManager(
private fun getFileFeatureToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion)
return featureTogglesProvider.getToggles()
.defineTogglesAvailability(appVersion = appVersion)
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)

View file

@ -1,18 +1,20 @@
package com.tangem.core.configtoggle.feature.impl
import androidx.annotation.VisibleForTesting
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.version.VersionProvider
/**
* Feature toggles manager implementation in PROD build
*
* @property versionProvider application version provider
* @property versionProvider application version provider
* @property featureTogglesProvider provider for feature toggle entries
*/
internal class ProdFeatureTogglesManager(
private val versionProvider: VersionProvider,
private val featureTogglesProvider: FeatureTogglesProvider,
) : FeatureTogglesManager {
private val featureToggles: Map<String, Boolean> = getFileFeatureToggles()
@ -22,7 +24,8 @@ internal class ProdFeatureTogglesManager(
private fun getFileFeatureToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion)
return featureTogglesProvider.getToggles()
.defineTogglesAvailability(appVersion = appVersion)
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)

View file

@ -0,0 +1,14 @@
package com.tangem.core.configtoggle.feature.provider
import com.tangem.core.configtoggle.FeatureToggles
import javax.inject.Inject
/**
* Default implementation of [FeatureTogglesProvider] that reads from the generated [FeatureToggles] enum.
*/
internal class DefaultFeatureTogglesProvider @Inject constructor() : FeatureTogglesProvider {
override fun getToggles(): Map<String, String> {
return FeatureToggles.entries.associate { it.rawName to it.version }
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.core.configtoggle.feature.provider
/**
* Provider for feature toggle entries.
* This interface allows for easier testing by enabling mock implementations.
*/
internal interface FeatureTogglesProvider {
/**
* Returns a map of toggle names to their version strings.
* The version string is "undefined" for disabled toggles, or a version number like "1.0.0" for enabled ones.
*/
fun getToggles(): Map<String, String>
}

View file

@ -1,7 +1,7 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import com.tangem.core.configtoggle.blockchain.provider.ExcludedBlockchainTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
@ -12,42 +12,74 @@ import org.junit.jupiter.api.*
internal class DevExcludedBlockchainsManagerTest {
private val versionProvider = mockk<VersionProvider>()
private val excludedBlockchainTogglesProvider = mockk<ExcludedBlockchainTogglesProvider>()
private val localTogglesStorage = mockk<LocalTogglesStorage>(relaxUnitFun = true)
private val testVersion = "5.30"
private val testToggles = mapOf(
"ENABLED_BLOCKCHAIN" to "5.21.0",
"DISABLED_BLOCKCHAIN" to "undefined",
"FUTURE_BLOCKCHAIN" to "6.0.0",
)
private fun getExpectedFileToggles(appVersion: String?): Map<String, Boolean> {
if (appVersion.isNullOrEmpty()) {
return testToggles.mapValues { false }
}
return testToggles.mapValues { (_, version) ->
version != "undefined" && isVersionSufficient(appVersion, version)
}
}
private fun getExcludedBlockchains(appVersion: String?): Set<String> {
return getExpectedFileToggles(appVersion).filterValues { !it }.keys
}
private fun isVersionSufficient(appVersion: String, requiredVersion: String): Boolean {
val appParts = appVersion.split(".").mapNotNull { it.toIntOrNull() }
val reqParts = requiredVersion.split(".").mapNotNull { it.toIntOrNull() }
for (i in 0 until maxOf(appParts.size, reqParts.size)) {
val app = appParts.getOrElse(i) { 0 }
val req = reqParts.getOrElse(i) { 0 }
if (app > req) return true
if (app < req) return false
}
return true
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@BeforeAll
fun setupAll() {
val toggles = mapOf("CHAIN_1" to "1.0.0", "CHAIN_2" to "2.0.0")
mockkObject(ExcludedBlockchainToggles)
every { ExcludedBlockchainToggles.values } returns toggles
}
@AfterAll
fun tearDownAll() {
unmockkObject(ExcludedBlockchainToggles)
@BeforeEach
fun setupEach() {
every { excludedBlockchainTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, localTogglesStorage)
clearMocks(versionProvider, excludedBlockchainTogglesProvider, localTogglesStorage)
}
@Test
fun `successfully initialize manager`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedToggles = mapOf("CHAIN_1" to false, "CHAIN_2" to true)
val appVersion = testVersion
val fileToggles = getExpectedFileToggles(appVersion)
val savedToggles = fileToggles.mapValues { !it.value }
every { versionProvider.get() } returns appVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } returns savedToggles
// Act
val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds
val actual = DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_1")
val expected = savedToggles.filterValues { !it }.keys
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder {
@ -59,14 +91,20 @@ internal class DevExcludedBlockchainsManagerTest {
@Test
fun `successfully initialize manager if versionProvider returns null`() = runTest {
// Arrange
every { versionProvider.get() } returns null
val appVersion: String? = null
every { versionProvider.get() } returns appVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
// Act
val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds
val actual = DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
).excludedBlockchainsIds
// Assert
Truth.assertThat(actual).containsExactly("CHAIN_1", "CHAIN_2")
val expected = getExcludedBlockchains(appVersion)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder {
versionProvider.get()
@ -77,14 +115,20 @@ internal class DevExcludedBlockchainsManagerTest {
@Test
fun `successfully initialize manager if storage returns empty map`() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val appVersion = testVersion
every { versionProvider.get() } returns appVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
// Act
val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds
val actual = DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
).excludedBlockchainsIds
// Assert
Truth.assertThat(actual).containsExactly("CHAIN_2")
val expected = getExcludedBlockchains(appVersion)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder {
versionProvider.get()
@ -95,13 +139,19 @@ internal class DevExcludedBlockchainsManagerTest {
@Test
fun `failure initialize manager if storage throws exception`() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val appVersion = testVersion
val exception = Exception("Test exception")
every { versionProvider.get() } returns appVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } throws exception
// Act
val actual = runCatching { DevExcludedBlockchainsManager(versionProvider, localTogglesStorage) }
.exceptionOrNull()!!
val actual = runCatching {
DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
)
}.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
@ -118,21 +168,34 @@ internal class DevExcludedBlockchainsManagerTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ExcludeBlockchain {
@BeforeEach
fun setupEach() {
every { excludedBlockchainTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, excludedBlockchainTogglesProvider, localTogglesStorage)
}
@Test
fun excludeBlockchain_changesStatusAndSaves() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
every { versionProvider.get() } returns testVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
manager.excludeBlockchain("CHAIN_1", false)
val manager = DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
)
// Act
manager.excludeBlockchain("ENABLED_BLOCKCHAIN", false)
val actual = manager.excludedBlockchainsIds
// Assert
Truth.assertThat(actual).contains("CHAIN_1")
coVerify { localTogglesStorage.store(match { it["CHAIN_1"] == false }) }
Truth.assertThat(actual).contains("ENABLED_BLOCKCHAIN")
coVerify { localTogglesStorage.store(match { it["ENABLED_BLOCKCHAIN"] == false }) }
}
}
@ -140,13 +203,26 @@ internal class DevExcludedBlockchainsManagerTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsMatchLocalConfig {
@BeforeEach
fun setupEach() {
every { excludedBlockchainTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, excludedBlockchainTogglesProvider, localTogglesStorage)
}
@Test
fun isMatchLocalConfig_returnsTrueIfMatchesFile() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
every { versionProvider.get() } returns testVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
val manager = DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
)
// Act
val actual = manager.isMatchLocalConfig()
@ -158,13 +234,17 @@ internal class DevExcludedBlockchainsManagerTest {
@Test
fun isMatchLocalConfig_returnsFalseIfDiffersFromFile() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
every { versionProvider.get() } returns testVersion
val fileToggles = getExpectedFileToggles(testVersion)
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
manager.excludeBlockchain("CHAIN_1", false)
val manager = DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
)
// Act
manager.excludeBlockchain("ENABLED_BLOCKCHAIN", !fileToggles.getValue("ENABLED_BLOCKCHAIN"))
val actual = manager.isMatchLocalConfig()
// Assert
@ -176,28 +256,36 @@ internal class DevExcludedBlockchainsManagerTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class RecoverLocalConfig {
@BeforeEach
fun setupEach() {
every { excludedBlockchainTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, excludedBlockchainTogglesProvider, localTogglesStorage)
}
@Test
fun recoverLocalConfig_resetsToFileAndSaves() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val toggles = mapOf("CHAIN_1" to "2.0.0", "CHAIN_2" to "2.0.0")
mockkObject(ExcludedBlockchainToggles)
every { ExcludedBlockchainToggles.values } returns toggles
coEvery { localTogglesStorage.getSyncOrEmpty() } returns mapOf("CHAIN_1" to true)
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
manager.recoverLocalConfig()
val appVersion = testVersion
every { versionProvider.get() } returns appVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
)
manager.excludeBlockchain("ENABLED_BLOCKCHAIN", false)
// Act
manager.recoverLocalConfig()
val actual = manager.excludedBlockchainsIds
// Assert
Truth.assertThat(actual).containsExactly("CHAIN_1", "CHAIN_2")
unmockkObject(ExcludedBlockchainToggles)
clearMocks(versionProvider, localTogglesStorage)
val expected = getExcludedBlockchains(appVersion)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
}
}
}

View file

@ -1,9 +1,12 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import com.tangem.core.configtoggle.blockchain.provider.ExcludedBlockchainTogglesProvider
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
import io.mockk.clearMocks
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
@ -11,39 +14,65 @@ import org.junit.jupiter.api.*
internal class ProdExcludedBlockchainsManagerTest {
private val versionProvider = mockk<VersionProvider>()
private val excludedBlockchainTogglesProvider = mockk<ExcludedBlockchainTogglesProvider>()
private val testVersion = "5.30"
private val testToggles = mapOf(
"ENABLED_BLOCKCHAIN" to "5.21.0",
"DISABLED_BLOCKCHAIN" to "undefined",
"FUTURE_BLOCKCHAIN" to "6.0.0",
)
private fun getExcludedBlockchains(appVersion: String?): Set<String> {
if (appVersion.isNullOrEmpty()) {
return testToggles.keys
}
return testToggles.filterValues { version ->
version == "undefined" || !isVersionSufficient(appVersion, version)
}.keys
}
private fun isVersionSufficient(appVersion: String, requiredVersion: String): Boolean {
val appParts = appVersion.split(".").mapNotNull { it.toIntOrNull() }
val reqParts = requiredVersion.split(".").mapNotNull { it.toIntOrNull() }
for (i in 0 until maxOf(appParts.size, reqParts.size)) {
val app = appParts.getOrElse(i) { 0 }
val req = reqParts.getOrElse(i) { 0 }
if (app > req) return true
if (app < req) return false
}
return true
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@BeforeAll
fun setupAll() {
val toggles = mapOf("CHAIN_1" to "1.0.0", "CHAIN_2" to "2.0.0")
mockkObject(ExcludedBlockchainToggles)
every { ExcludedBlockchainToggles.values } returns toggles
}
@AfterAll
fun tearDownAll() {
unmockkObject(ExcludedBlockchainToggles)
@BeforeEach
fun setupEach() {
every { excludedBlockchainTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider)
clearMocks(versionProvider, excludedBlockchainTogglesProvider)
}
@Test
fun `successfully initialize excluded blockchains`() = runTest {
// Arrange
val appVersion = "1.0.0"
val appVersion = testVersion
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds
val actual = ProdExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_2")
val expected = getExcludedBlockchains(appVersion)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder { versionProvider.get() }
@ -52,13 +81,17 @@ internal class ProdExcludedBlockchainsManagerTest {
@Test
fun `all blockchains excluded if versionProvider returns null`() = runTest {
// Arrange
every { versionProvider.get() } returns null
val appVersion: String? = null
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds
val actual = ProdExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_1", "CHAIN_2")
val expected = getExcludedBlockchains(appVersion)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder { versionProvider.get() }
@ -67,13 +100,17 @@ internal class ProdExcludedBlockchainsManagerTest {
@Test
fun `all blockchains excluded if versionProvider returns empty string`() = runTest {
// Arrange
every { versionProvider.get() } returns ""
val appVersion = ""
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds
val actual = ProdExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_1", "CHAIN_2")
val expected = getExcludedBlockchains(appVersion)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder { versionProvider.get() }
@ -86,7 +123,9 @@ internal class ProdExcludedBlockchainsManagerTest {
every { versionProvider.get() } throws exception
// Act
val actual = runCatching { ProdExcludedBlockchainsManager(versionProvider) }.exceptionOrNull()!!
val actual = runCatching {
ProdExcludedBlockchainsManager(versionProvider, excludedBlockchainTogglesProvider)
}.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)

View file

@ -1,9 +1,8 @@
package com.tangem.core.configtoggle.manager
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.manager.ProdFeatureTogglesManagerTest.IsFeatureEnabledModel
import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.test.core.ProvideTestModels
@ -12,51 +11,55 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DevFeatureTogglesManagerTest {
private val versionProvider = mockk<VersionProvider>()
private val featureTogglesProvider = mockk<FeatureTogglesProvider>()
private val featureTogglesLocalStorage = mockk<LocalTogglesStorage>(relaxUnitFun = true)
private val testToggles = mapOf(
"ENABLED_TOGGLE" to "1.0.0",
"DISABLED_TOGGLE" to "undefined",
)
private fun getExpectedFileToggles(appVersion: String?): Map<String, Boolean> =
testToggles.mapValues { (_, version) ->
version != "undefined" && !appVersion.isNullOrEmpty()
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@BeforeAll
fun setupAll() {
val featureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0")
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
}
@AfterAll
fun tearDownAll() {
unmockkObject(FeatureToggles)
@BeforeEach
fun setupEach() {
every { featureTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, featureTogglesLocalStorage)
clearMocks(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
}
@Test
fun `successfully initialize manager`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true)
val fileToggles = getExpectedFileToggles(appVersion)
val savedToggles = fileToggles.mapValues { !it.value }
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
val actual = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
val expected = savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
@ -68,17 +71,21 @@ internal class DevFeatureTogglesManagerTest {
@Test
fun `successfully initialize manager if versionProvider returns null`() = runTest {
// Arrange
val appVersion = null
val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true)
val appVersion: String? = null
val fileToggles = getExpectedFileToggles(appVersion)
val savedToggles = fileToggles.mapValues { !it.value }
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
val actual = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
val expected = savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
@ -91,16 +98,20 @@ internal class DevFeatureTogglesManagerTest {
fun `successfully initialize manager if versionProvider returns empty string`() = runTest {
// Arrange
val appVersion = ""
val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true)
val fileToggles = getExpectedFileToggles(appVersion)
val savedToggles = fileToggles.mapValues { !it.value }
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
val actual = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
val expected = savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
@ -116,8 +127,13 @@ internal class DevFeatureTogglesManagerTest {
every { versionProvider.get() } throws exception
// Act
val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) }
.exceptionOrNull()!!
val actual = runCatching {
DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
)
}.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
@ -131,16 +147,18 @@ internal class DevFeatureTogglesManagerTest {
fun `successfully initialize manager if storage returns empty map`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedFeatureToggles = emptyMap<String, Boolean>()
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
val actual = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).getFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
val expected = getExpectedFileToggles(appVersion)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
@ -153,16 +171,20 @@ internal class DevFeatureTogglesManagerTest {
fun `successfully initialize manager if storage returns unknown toggles`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedFeatureToggles = mapOf("TOGGLE_3" to true)
val fileToggles = getExpectedFileToggles(appVersion)
val unknownSavedToggles = fileToggles + ("UNKNOWN_TOGGLE" to true)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns unknownSavedToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
val actual = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).getFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
val expected = fileToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
@ -176,13 +198,17 @@ internal class DevFeatureTogglesManagerTest {
// Arrange
val appVersion = "1.0.0"
val exception = Exception("Test exception")
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } throws exception
// Act
val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) }
.exceptionOrNull()!!
val actual = runCatching {
DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
)
}.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
@ -203,29 +229,19 @@ internal class DevFeatureTogglesManagerTest {
@BeforeAll
fun setupAll() {
every { featureTogglesProvider.getToggles() } returns testToggles
every { versionProvider.get() } returns "1.0.0"
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
val savedToggles = mapOf(
"DISABLED_TOGGLE" to true,
"ENABLED_TOGGLE" to false,
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
val savedFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedToggles
manager = DevFeatureTogglesManager(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
}
@AfterAll
fun tearDownAll() {
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
clearMocks(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
}
@ParameterizedTest
@ -235,33 +251,42 @@ internal class DevFeatureTogglesManagerTest {
val actual = manager.isFeatureEnabled(name = model.name)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actual).isEqualTo(model.expected)
}
private fun provideTestModels() = listOf(
IsFeatureEnabledModel(name = "ACTIVE2_TEST_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "INACTIVE_TEST_FEATURE_ENABLED", expected = true),
IsFeatureEnabledModel(name = "UNKNOWN_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "", expected = false),
IsFeatureEnabledModel(name = "ENABLED_TOGGLE", expected = false),
IsFeatureEnabledModel(name = "DISABLED_TOGGLE", expected = true),
)
}
data class IsFeatureEnabledModel(val name: String, val expected: Boolean)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsMatchLocalConfig {
@BeforeEach
fun setupEach() {
every { featureTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
}
@ParameterizedTest
@ProvideTestModels
fun isMatchLocalConfig(model: IsMatchLocalConfigModel) = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns model.fileFeatureToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).apply {
val manager = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).apply {
setFeatureToggles(model.storedFeatureToggles.toMutableMap())
}
@ -269,54 +294,45 @@ internal class DevFeatureTogglesManagerTest {
val actual = manager.isMatchLocalConfig()
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actual).isEqualTo(model.expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
private fun provideTestModels() = listOf(
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false),
expected = true,
),
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to true),
expected = false,
),
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_1" to true),
expected = false,
),
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_3" to true, "TOGGLE_4" to false),
expected = false,
),
IsMatchLocalConfigModel(
fileFeatureToggles = emptyMap(),
storedFeatureToggles = emptyMap(),
expected = true,
),
IsMatchLocalConfigModel(
fileFeatureToggles = emptyMap(),
storedFeatureToggles = mapOf("TOGGLE_1" to true),
expected = false,
),
)
private fun provideTestModels(): List<IsMatchLocalConfigModel> {
val appVersion = "1.0.0"
val fileToggles = getExpectedFileToggles(appVersion)
val firstToggleName = "ENABLED_TOGGLE"
return listOf(
IsMatchLocalConfigModel(
storedFeatureToggles = fileToggles,
expected = true,
),
IsMatchLocalConfigModel(
storedFeatureToggles = fileToggles.mapValues { !it.value },
expected = false,
),
IsMatchLocalConfigModel(
storedFeatureToggles = mapOf(firstToggleName to fileToggles.getValue(firstToggleName)),
expected = false,
),
IsMatchLocalConfigModel(
storedFeatureToggles = mapOf("UNKNOWN_TOGGLE" to true),
expected = false,
),
IsMatchLocalConfigModel(
storedFeatureToggles = emptyMap(),
expected = false,
),
)
}
}
data class IsMatchLocalConfigModel(
val fileFeatureToggles: Map<String, String>,
val storedFeatureToggles: Map<String, Boolean>,
val expected: Boolean,
)
@ -325,41 +341,40 @@ internal class DevFeatureTogglesManagerTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetFeatureToggles {
@BeforeEach
fun setupEach() {
every { featureTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
}
@Test
fun getFeatureToggles() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val fileFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
val appVersion = "1.0.0"
val fileToggles = getExpectedFileToggles(appVersion)
val savedToggles = mapOf(
"DISABLED_TOGGLE" to true,
"ENABLED_TOGGLE" to false,
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns fileFeatureToggles
val savedFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedToggles
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
// Act
val actual = manager.getFeatureToggles()
// Assert
val expected = savedFeatureToggles
val expected = fileToggles + savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
}
@ -367,25 +382,30 @@ internal class DevFeatureTogglesManagerTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ChangeToggle {
@BeforeEach
fun setupEach() {
every { featureTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
}
@ParameterizedTest
@ProvideTestModels
fun changeToggle(model: ChangeToggleModel) = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns model.initialToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
// Act
manager.changeToggle(name = model.name, isEnabled = model.isEnabled)
val actual = manager.getFeatureToggles()
// Assert
val expected = model.expectedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
Truth.assertThat(actual).containsExactlyEntriesIn(model.expectedToggles)
coVerifyOrder {
versionProvider.get()
@ -395,77 +415,69 @@ internal class DevFeatureTogglesManagerTest {
featureTogglesLocalStorage.store(model.expectedToggles)
}
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
private fun provideTestModels() = listOf(
ChangeToggleModel(
initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"),
name = "TOGGLE_1",
isEnabled = false,
expectedToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false),
expectedStoreSaving = true,
),
ChangeToggleModel(
initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"),
name = "TOGGLE_2",
isEnabled = true,
expectedToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to true),
expectedStoreSaving = true,
),
ChangeToggleModel(
initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"),
name = "TOGGLE_3",
isEnabled = true,
expectedToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false),
expectedStoreSaving = false,
),
ChangeToggleModel(
initialToggles = emptyMap(),
name = "TOGGLE_1",
isEnabled = true,
expectedToggles = emptyMap(),
expectedStoreSaving = false,
),
)
private fun provideTestModels(): List<ChangeToggleModel> {
val appVersion = "1.0.0"
val fileToggles = getExpectedFileToggles(appVersion)
val enabledName = "ENABLED_TOGGLE"
val disabledName = "DISABLED_TOGGLE"
return listOf(
ChangeToggleModel(
name = enabledName,
isEnabled = false,
expectedToggles = fileToggles + (enabledName to false),
expectedStoreSaving = true,
),
ChangeToggleModel(
name = disabledName,
isEnabled = true,
expectedToggles = fileToggles + (disabledName to true),
expectedStoreSaving = true,
),
ChangeToggleModel(
name = "UNKNOWN_TOGGLE",
isEnabled = true,
expectedToggles = fileToggles,
expectedStoreSaving = false,
),
)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class RecoverLocalConfig {
@BeforeEach
fun setupEach() {
every { featureTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
}
@Test
fun recoverLocalConfig() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val fileFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
val appVersion = "1.0.0"
val savedToggles = mapOf(
"DISABLED_TOGGLE" to true,
"ENABLED_TOGGLE" to false,
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns fileFeatureToggles
val savedFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedToggles
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
// Act
manager.recoverLocalConfig()
val actual = manager.getFeatureToggles()
// Assert
val expected = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
val expected = getExpectedFileToggles(appVersion)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
@ -473,14 +485,10 @@ internal class DevFeatureTogglesManagerTest {
featureTogglesLocalStorage.getSyncOrEmpty()
featureTogglesLocalStorage.store(expected)
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
}
data class ChangeToggleModel(
val initialToggles: Map<String, String>,
val name: String,
val isEnabled: Boolean,
val expectedToggles: Map<String, Boolean>,

View file

@ -1,43 +1,47 @@
package com.tangem.core.configtoggle.manager
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.test.core.ProvideTestModels
import io.mockk.*
import io.mockk.clearMocks
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ProdFeatureTogglesManagerTest {
private val versionProvider = mockk<VersionProvider>()
private val featureTogglesProvider = mockk<FeatureTogglesProvider>()
private val testToggles = mapOf(
"ENABLED_TOGGLE" to "1.0.0",
"DISABLED_TOGGLE" to "undefined",
)
private fun getExpectedToggles(appVersion: String?): Map<String, Boolean> {
return testToggles.mapValues { (_, version) ->
version != "undefined" && !appVersion.isNullOrEmpty()
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@BeforeAll
fun setupAll() {
val featureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0")
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
}
@AfterAll
fun tearDownAll() {
unmockkObject(FeatureToggles)
@BeforeEach
fun setupEach() {
every { featureTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider)
clearMocks(versionProvider, featureTogglesProvider)
}
@Test
@ -47,10 +51,10 @@ internal class ProdFeatureTogglesManagerTest {
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
val actual = ProdFeatureTogglesManager(versionProvider, featureTogglesProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
val expected = getExpectedToggles(appVersion)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
@ -59,14 +63,14 @@ internal class ProdFeatureTogglesManagerTest {
@Test
fun `successfully initialize storage if versionProvider returns null`() = runTest {
// Arrange
val appVersion = null
val appVersion: String? = null
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
val actual = ProdFeatureTogglesManager(versionProvider, featureTogglesProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false)
val expected = getExpectedToggles(appVersion)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
@ -79,10 +83,10 @@ internal class ProdFeatureTogglesManagerTest {
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
val actual = ProdFeatureTogglesManager(versionProvider, featureTogglesProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false)
val expected = getExpectedToggles(appVersion)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
@ -95,7 +99,8 @@ internal class ProdFeatureTogglesManagerTest {
every { versionProvider.get() } throws exception
// Act
val actual = runCatching { ProdFeatureTogglesManager(versionProvider) }.exceptionOrNull()!!
val actual = runCatching { ProdFeatureTogglesManager(versionProvider, featureTogglesProvider) }
.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
@ -113,23 +118,14 @@ internal class ProdFeatureTogglesManagerTest {
@BeforeAll
fun setupAll() {
every { featureTogglesProvider.getToggles() } returns testToggles
every { versionProvider.get() } returns "1.0.0"
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
manager = ProdFeatureTogglesManager(versionProvider)
manager = ProdFeatureTogglesManager(versionProvider, featureTogglesProvider)
}
@AfterAll
fun tearDownAll() {
clearMocks(versionProvider)
unmockkObject(FeatureToggles)
clearMocks(versionProvider, featureTogglesProvider)
}
@ParameterizedTest
@ -139,15 +135,12 @@ internal class ProdFeatureTogglesManagerTest {
val actual = manager.isFeatureEnabled(name = model.name)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actual).isEqualTo(model.expected)
}
private fun provideTestModels() = listOf(
IsFeatureEnabledModel(name = "ACTIVE2_TEST_FEATURE_ENABLED", expected = true),
IsFeatureEnabledModel(name = "INACTIVE_TEST_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "UNKNOWN_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "", expected = false),
IsFeatureEnabledModel(name = "ENABLED_TOGGLE", expected = true),
IsFeatureEnabledModel(name = "DISABLED_TOGGLE", expected = false),
)
}

View file

@ -1,7 +1,6 @@
package com.tangem.plugin.configuration.configurations
import com.squareup.kotlinpoet.*
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
@ -12,8 +11,7 @@ import java.util.Locale
/**
* Generator for toggle configuration Kotlin enum classes from JSON files.
* Parses a JSON array of `{ "name": "...", "version": "..." }` entries
* and generates an enum class with an entry for each toggle and a `values: Map<String, String>`
* in companion object mapping toggle name to its version string.
* and generates an enum class with `rawName` and `version` properties for each toggle.
*
[REDACTED_AUTHOR]
*/
@ -39,31 +37,35 @@ object TogglesGenerator {
ToggleEntry(name = name, enumName = name.toEnumEntryName(), version = version)
}
val mapInitializer = buildCodeBlock {
addStatement("mapOf(")
withIndent {
entries.forEach { entry ->
addStatement("%S to %S,", entry.name, entry.version)
}
}
add(")")
}
val companionBuilder = TypeSpec.companionObjectBuilder()
.addProperty(
PropertySpec.builder("values", MAP.parameterizedBy(STRING, STRING))
.initializer(mapInitializer)
.build(),
)
val enumBuilder = TypeSpec.enumBuilder(fileName)
.addKdoc("Generated from ${inputFile.name}\nAuto-generated - do not edit manually.")
.primaryConstructor(
FunSpec.constructorBuilder()
.addParameter(ParameterSpec.builder("rawName", String::class).build())
.addParameter(ParameterSpec.builder("version", String::class).build())
.build(),
)
.addProperty(
PropertySpec.builder("rawName", String::class)
.initializer("rawName")
.build(),
)
.addProperty(
PropertySpec.builder("version", String::class)
.initializer("version")
.build(),
)
.apply {
entries.forEach { entry ->
addEnumConstant(entry.enumName)
addEnumConstant(
entry.enumName,
TypeSpec.anonymousClassBuilder()
.addSuperclassConstructorParameter("%S", entry.name)
.addSuperclassConstructorParameter("%S", entry.version)
.build(),
)
}
}
.addType(companionBuilder.build())
val fileSpec = FileSpec.builder(packageName = PACKAGE_NAME, fileName = fileName)
.indent(" ")
@ -73,7 +75,6 @@ object TogglesGenerator {
outputDir.mkdirs()
fileSpec.writeTo(outputDir)
// Remove redundant public visibility modifiers
val generatedFile = File(outputDir, "${PACKAGE_NAME.replace('.', '/')}/$fileName.kt")
if (generatedFile.exists()) {
val content = generatedFile.readText()
@ -81,6 +82,7 @@ object TogglesGenerator {
.replace("public enum class ", "enum class ")
.replace("public companion object", "companion object")
.replace("public val ", "val ")
.replace(Regex(""",\s*;\s*\n\s*}"""), ",\n}")
generatedFile.writeText(fixedContent)
}
}

View file

@ -42,11 +42,25 @@ class TogglesGeneratorTest {
)
// Assert
assertThat(code).contains("enum class FeatureToggles {")
assertThat(code).contains("enum class FeatureToggles(")
}
@Test
fun `generate creates enum entries and companion object`() {
fun `generate creates enum with constructor parameters`() {
// Arrange
val json = """[{ "name": "onramp/ios", "version": "1.0.0" }]"""
// Act
val code = generateAndReadOutput(json, "Toggles")
// Assert
assertThat(code).contains("ONRAMP_IOS(")
assertThat(code).contains("val rawName: String")
assertThat(code).contains("val version: String")
}
@Test
fun `generate creates enum entries without companion object`() {
// Arrange & Act
val code = generateAndReadOutput(
json = SINGLE_ENTRY_JSON,
@ -54,21 +68,23 @@ class TogglesGeneratorTest {
)
// Assert
assertThat(code).contains("STAKING_ETH_ENABLED,")
assertThat(code).contains("companion object {")
assertThat(code).contains("STAKING_ETH_ENABLED(")
assertThat(code).contains("val rawName: String")
assertThat(code).contains("val version: String")
assertThat(code).doesNotContain("companion object")
assertThat(code).doesNotContain("val values:")
}
@Test
fun `generate creates values map with string literal keys`() {
fun `generate creates enum entries with constructor arguments`() {
// Arrange & Act
val code = generateAndReadOutput(
json = SINGLE_ENTRY_JSON,
fileName = "FeatureToggles",
)
// Assert
assertThat(code).contains("val values: Map<String, String> = mapOf(")
assertThat(code).contains(""""STAKING_ETH_ENABLED" to "undefined"""")
// Assert - verify enum entry includes constructor arguments
assertThat(code).contains(""""STAKING_ETH_ENABLED", "undefined"""")
}
@Test
@ -86,12 +102,12 @@ class TogglesGeneratorTest {
val code = generateAndReadOutput(json, "TestToggles")
// Assert
assertThat(code).contains("FEATURE_A,")
assertThat(code).contains("FEATURE_B,")
assertThat(code).contains("FEATURE_C,")
assertThat(code).contains(""""FEATURE_A" to "1.0"""")
assertThat(code).contains(""""FEATURE_B" to "2.0"""")
assertThat(code).contains(""""FEATURE_C" to "undefined"""")
assertThat(code).contains("FEATURE_A(")
assertThat(code).contains("FEATURE_B(")
assertThat(code).contains("FEATURE_C(")
assertThat(code).contains(""""FEATURE_A", "1.0"""")
assertThat(code).contains(""""FEATURE_B", "2.0"""")
assertThat(code).contains(""""FEATURE_C", "undefined"""")
}
@Test
@ -103,8 +119,8 @@ class TogglesGeneratorTest {
)
// Assert
assertThat(code).contains("enum class EmptyToggles {")
assertThat(code).contains("val values: Map<String, String> = mapOf(")
assertThat(code).contains("enum class EmptyToggles(")
assertThat(code).doesNotContain("companion object")
}
@Test
@ -116,8 +132,8 @@ class TogglesGeneratorTest {
val code = generateAndReadOutput(json, "Toggles")
// Assert
assertThat(code).contains("NEXA_TEST,")
assertThat(code).contains(""""NEXA/test" to "undefined"""")
assertThat(code).contains("NEXA_TEST(")
assertThat(code).contains(""""NEXA/test", "undefined"""")
}
@Test
@ -129,8 +145,8 @@ class TogglesGeneratorTest {
val code = generateAndReadOutput(json, "Toggles")
// Assert
assertThat(code).contains("VANAR_CHAIN,")
assertThat(code).contains(""""vanar-chain" to "undefined"""")
assertThat(code).contains("VANAR_CHAIN(")
assertThat(code).contains(""""vanar-chain", "undefined"""")
}
@Test
@ -142,8 +158,8 @@ class TogglesGeneratorTest {
val code = generateAndReadOutput(json, "Toggles")
// Assert
assertThat(code).contains("SONIC,")
assertThat(code).contains(""""sonic" to "5.21.0"""")
assertThat(code).contains("SONIC(")
assertThat(code).contains(""""sonic", "5.21.0"""")
}
@Test
@ -169,7 +185,6 @@ class TogglesGeneratorTest {
// Assert
assertThat(code).doesNotContain("public enum class")
assertThat(code).doesNotContain("public companion object")
assertThat(code).doesNotContain("public val")
}
@ -188,12 +203,12 @@ class TogglesGeneratorTest {
val code = generateAndReadOutput(json, "FeatureToggles")
// Assert
assertThat(code).contains("NEW_CARD_SCANNING_ENABLED,")
assertThat(code).contains("HOT_WALLET_CREATION_RESTRICTION_ENABLED,")
assertThat(code).contains("SWAP_MARKET_LIST_ENABLED,")
assertThat(code).contains(""""NEW_CARD_SCANNING_ENABLED" to "undefined"""")
assertThat(code).contains(""""HOT_WALLET_CREATION_RESTRICTION_ENABLED" to "5.32.0"""")
assertThat(code).contains(""""SWAP_MARKET_LIST_ENABLED" to "5.34"""")
assertThat(code).contains("NEW_CARD_SCANNING_ENABLED(")
assertThat(code).contains("HOT_WALLET_CREATION_RESTRICTION_ENABLED(")
assertThat(code).contains("SWAP_MARKET_LIST_ENABLED(")
assertThat(code).contains(""""NEW_CARD_SCANNING_ENABLED", "undefined"""")
assertThat(code).contains(""""HOT_WALLET_CREATION_RESTRICTION_ENABLED", "5.32.0"""")
assertThat(code).contains(""""SWAP_MARKET_LIST_ENABLED", "5.34"""")
}
@Test
@ -211,13 +226,15 @@ class TogglesGeneratorTest {
val code = generateAndReadOutput(json, "ExcludedBlockchainToggles")
// Assert
assertThat(code).contains("enum class ExcludedBlockchainToggles {")
assertThat(code).contains("NEXA,")
assertThat(code).contains("NEXA_TEST,")
assertThat(code).contains("SONIC,")
assertThat(code).contains(""""NEXA" to "undefined"""")
assertThat(code).contains(""""NEXA/test" to "undefined"""")
assertThat(code).contains(""""sonic" to "5.21.0"""")
assertThat(code).contains("enum class ExcludedBlockchainToggles(")
assertThat(code).contains("val rawName: String")
assertThat(code).contains("val version: String")
assertThat(code).contains("NEXA(")
assertThat(code).contains("NEXA_TEST(")
assertThat(code).contains("SONIC(")
assertThat(code).contains(""""NEXA", "undefined"""")
assertThat(code).contains(""""NEXA/test", "undefined"""")
assertThat(code).contains(""""sonic", "5.21.0"""")
}
@Test
@ -230,8 +247,25 @@ class TogglesGeneratorTest {
val code2 = generateAndReadOutput(json, "ExcludedBlockchainToggles")
// Assert
assertThat(code1).contains("enum class FeatureToggles {")
assertThat(code2).contains("enum class ExcludedBlockchainToggles {")
assertThat(code1).contains("enum class FeatureToggles(")
assertThat(code2).contains("enum class ExcludedBlockchainToggles(")
}
@Test
fun `generate does not include semicolon after enum entries`() {
// Arrange
val json = """
[
{ "name": "FEATURE_A", "version": "1.0" },
{ "name": "FEATURE_B", "version": "2.0" }
]
""".trimIndent()
// Act
val code = generateAndReadOutput(json, "TestToggles")
// Assert - ensure no trailing semicolons
assertThat(code).doesNotContain(";\n")
}
private fun generateAndReadOutput(json: String, fileName: String): String {