Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-09 11:08:16 +03:00
parent 2a835ce3c9
commit 0c56df0075
16 changed files with 306 additions and 58 deletions

View file

@ -1,7 +1,8 @@
package com.tangem.common
import android.Manifest
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import androidx.test.rule.GrantPermissionRule
import com.kaspersky.components.alluresupport.interceptors.step.ScreenshotStepInterceptor
@ -10,8 +11,8 @@ import com.kaspersky.components.composesupport.config.addComposeSupport
import com.kaspersky.kaspresso.kaspresso.Kaspresso
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import com.tangem.common.allure.FailedStepScreenshotInterceptor
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.common.rules.ApiEnvironmentRule
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.tap.MainActivity
import dagger.hilt.android.testing.HiltAndroidRule
import org.junit.Rule
@ -35,17 +36,20 @@ abstract class BaseTestCase : TestCase(
) {
@Inject
lateinit var tangemSdkManager: TangemSdkManager
@Inject
lateinit var appPreferencesStore: AppPreferencesStore
lateinit var apiConfigsManager: ApiConfigsManager
private val hiltRule = HiltAndroidRule(this)
private val permissionRule = GrantPermissionRule.grant(
private val apiEnvironmentRule = ApiEnvironmentRule()
private val permissionRule = GrantPermissionRule.grant(
Manifest.permission.POST_NOTIFICATIONS,
Manifest.permission.CAMERA,
)
val composeTestRule = createAndroidComposeRule<MainActivity>()
/**
* It is important to use `ComposeRule` without specifying an activity to ensure that the initialization order of
* all test rules is fully controlled.
*/
val composeTestRule = createEmptyComposeRule()
@Rule
@JvmField
@ -53,18 +57,26 @@ abstract class BaseTestCase : TestCase(
.outerRule(hiltRule)
.around(ApplicationInjectionExecutionRule())
.around(permissionRule)
.around(apiEnvironmentRule)
.around(composeTestRule)
/**
* Initialization order is important:
* DI dependencies must be injected first,
* then the API environment should be set up,
* and only after that the activity should be launched.
*/
protected fun setupHooks(
additionalBeforeSection: () -> Unit = {},
additionalAfterSection: () -> Unit = {},
) = before {
hiltRule.inject()
apiEnvironmentRule.setup(apiConfigsManager)
ActivityScenario.launch(MainActivity::class.java)
Intents.init()
additionalBeforeSection()
}.after {
additionalAfterSection()
Intents.release()
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.common.annotations
import com.tangem.datasource.api.common.config.ApiEnvironment
/**
* Annotation to specify the API environment for a class or function.
*
* @property environment the API environment to be used (defaults to [ApiEnvironment.MOCK])
*/
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class ApiEnv(
val environment: ApiEnvironment = ApiEnvironment.MOCK,
)

View file

@ -0,0 +1,78 @@
package com.tangem.common.rules
import androidx.test.platform.app.InstrumentationRegistry
import com.tangem.common.annotations.ApiEnv
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.config.managers.MutableApiConfigsManager
import com.tangem.wallet.test.BuildConfig
import kotlinx.coroutines.runBlocking
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import timber.log.Timber
/**
* A JUnit rule that sets up the API environment for tests based on annotations or instrumentation arguments.
*
* This rule allows tests to specify which API environment to use either through an annotation on the test method/class
* or via an instrumentation argument.
*
* This rule only finds and stores the required API environment. The actual environment setup is performed
* by calling the [setup] method with an appropriate [ApiConfigsManager] instance.
*/
class ApiEnvironmentRule : TestRule {
private var targetEnvironment: ApiEnvironment? = null
/**
* Sets up the API environment based on the provided [ApiConfigsManager].
* This method is typically called in the test setup phase to ensure the correct API environment is configured.
*
* @param apiConfigsManager the manager responsible for API configurations
*/
fun setup(apiConfigsManager: ApiConfigsManager) {
val mutableManager = requireNotNull(apiConfigsManager as? MutableApiConfigsManager) {
"MutableApiConfigsManager isn't available in build type ${BuildConfig.BUILD_TYPE}."
}
mutableManager.setupEnvironment()
}
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
override fun evaluate() {
targetEnvironment = determineEnvironment(description)
base.evaluate()
}
}
}
private fun determineEnvironment(description: Description): ApiEnvironment {
val instrumentationArgs = InstrumentationRegistry.getArguments()
val envArg = instrumentationArgs.getString(ENV_ARGUMENT)
if (!envArg.isNullOrEmpty()) return ApiEnvironment.valueOf(envArg.uppercase())
val methodAnnotation = description.getAnnotation(ApiEnv::class.java)
if (methodAnnotation != null) return methodAnnotation.environment
val classAnnotation = description.testClass.getAnnotation(ApiEnv::class.java)
if (classAnnotation != null) return classAnnotation.environment
return ApiEnvironment.MOCK
}
private fun MutableApiConfigsManager.setupEnvironment() {
val environment = requireNotNull(targetEnvironment) { "Target environment is null" }
runBlocking { changeEnvironment(environment) }
Timber.i("API environment set to: ${environment.name}")
}
private companion object {
const val ENV_ARGUMENT = "testEnvironment"
}
}

View file

@ -76,6 +76,7 @@ internal class DefaultCardSdkProvider @Inject constructor(
when (it[ApiConfig.ID.TangemTech.name]) {
ApiEnvironment.DEV,
ApiEnvironment.STAGE,
ApiEnvironment.MOCK,
-> false
ApiEnvironment.PROD,
null,

View file

@ -16,6 +16,9 @@ enum class ApiEnvironment {
@Json(name = "STAGE")
STAGE,
@Json(name = "MOCK")
MOCK,
@Json(name = "PROD")
PROD,
}

View file

@ -14,6 +14,7 @@ import com.tangem.utils.version.AppVersionProvider
* @property environmentConfigStorage environment config storage
* @property expressAuthProvider express auth provider
* @property appVersionProvider app version provider
* @property appInfoProvider app info provider
*/
internal class Express(
private val environmentConfigStorage: EnvironmentConfigStorage,
@ -27,9 +28,25 @@ internal class Express(
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createDevEnvironment(),
createStageEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.STAGE
MOCKED_BUILD_TYPE,
-> ApiEnvironment.MOCK
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "[REDACTED_ENV_URL]",
@ -42,6 +59,12 @@ internal class Express(
headers = createHeaders(isProd = false),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(isProd = false),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://express.tangem.com/v1/",
@ -63,21 +86,4 @@ internal class Express(
?.apiKey
?: error("No express config provided")
}
private companion object {
fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV
INTERNAL_BUILD_TYPE,
MOCKED_BUILD_TYPE,
-> ApiEnvironment.STAGE
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.version.AppVersionProvider
@ -7,11 +8,37 @@ internal class TangemPay(
private val appVersionProvider: AppVersionProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.DEV
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs = listOf(
createProdEnvironment(),
createDevEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.DEV
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
@ -20,12 +47,6 @@ internal class TangemPay(
headers = createHeaders(),
)
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
private fun createHeaders() = mapOf(
"version" to ProviderSuspend { appVersionProvider.versionName },
"platform" to ProviderSuspend { "Android" },

View file

@ -1,5 +1,6 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.utils.RequestHeader
import com.tangem.utils.info.AppInfoProvider
@ -12,14 +13,28 @@ internal class TangemTech(
private val appInfoProvider: AppInfoProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs = listOf(
createDevEnvironment(),
createStageEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
-> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "[REDACTED_ENV_URL]",
@ -32,6 +47,12 @@ internal class TangemTech(
headers = createHeaders(),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem.org/v1/",

View file

@ -20,28 +20,26 @@ import kotlinx.coroutines.flow.*
* @property dispatchers coroutine dispatcher provider
*/
internal class DevApiConfigsManager(
apiConfigs: ApiConfigs,
private val apiConfigs: ApiConfigs,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : MutableApiConfigsManager {
override val configs: Flow<Map<ApiConfig, ApiEnvironment>> get() = _apiConfigs
override val configs: StateFlow<Map<ApiConfig, ApiEnvironment>>
field = MutableStateFlow(value = getInitialConfigs())
private val _apiConfigs = MutableStateFlow(value = apiConfigs.associateWith { it.defaultEnvironment })
override val isInitialized: StateFlow<Boolean> get() = _isInitialized.asStateFlow()
private val _isInitialized = MutableStateFlow(value = false)
override val isInitialized: StateFlow<Boolean>
field = MutableStateFlow(value = false)
override fun initialize() {
_isInitialized.value = false
isInitialized.value = false
// We can't use appPreferencesStore.getObjectMap as base flow,
// because we should keep possibility to work with configs synchronous.
// See [getBaseUrl]
appPreferencesStore.getObjectMap<ApiEnvironment>(PreferencesKeys.apiConfigsEnvironmentKey)
.onEach { savedEnvironments ->
_apiConfigs.update { apiConfigs ->
configs.update { apiConfigs ->
apiConfigs.mapValues {
val (config, currentEnvironment) = it
@ -49,15 +47,15 @@ internal class DevApiConfigsManager(
}
}
if (!_isInitialized.value) {
_isInitialized.value = true
if (!isInitialized.value) {
isInitialized.value = true
}
}
.launchIn(CoroutineScope(SupervisorJob() + dispatchers.default))
}
override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig {
val apiConfigs = _apiConfigs.value
val apiConfigs = configs.value
val config = apiConfigs.map { it }.firstOrNull { it.key.id == id }?.key
?: error("Api config with id [$id] not found")
@ -78,4 +76,27 @@ internal class DevApiConfigsManager(
mutablePreferences.setObjectMap(PreferencesKeys.apiConfigsEnvironmentKey, updatedMap)
}
}
override suspend fun changeEnvironment(environment: ApiEnvironment) {
val supportedConfigs = apiConfigs
.filter { config ->
config.environmentConfigs.any { it.environment == environment }
}
.map { it.id.name }
appPreferencesStore.editData { mutablePreferences ->
val updatedMap = mutablePreferences.getObjectMap<ApiEnvironment>(PreferencesKeys.apiConfigsEnvironmentKey)
.mapValues { (configName, currentEnvironment) ->
val isEnvSupported = supportedConfigs.contains(configName)
if (isEnvSupported) environment else currentEnvironment
}
mutablePreferences.setObjectMap(key = PreferencesKeys.apiConfigsEnvironmentKey, value = updatedMap)
}
}
private fun getInitialConfigs(): Map<ApiConfig, ApiEnvironment> {
return apiConfigs.associateWith(ApiConfig::defaultEnvironment)
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.datasource.api.common.config.managers
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfigs
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.ApiEnvironmentConfig
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
/**
* Implementation of [ApiConfigsManager] in MOCK environment
*
* @property apiConfigs api configs
*
[REDACTED_AUTHOR]
*/
internal class MockApiConfigsManager(
private val apiConfigs: ApiConfigs,
) : MutableApiConfigsManager {
override val configs: StateFlow<Map<ApiConfig, ApiEnvironment>>
field = MutableStateFlow(value = getInitialConfigs())
override val isInitialized: StateFlow<Boolean> = MutableStateFlow(value = true)
override fun initialize() = Unit
override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig {
val apiConfigs = configs.value
val (config, currentEnvironment) = apiConfigs.entries.firstOrNull { it.key.id == id }
?: error("Api config with id [$id] not found")
return config.environmentConfigs.firstOrNull { it.environment == currentEnvironment }
?: error("Api config with id [$id] doesn't contain environment [$currentEnvironment]")
}
override suspend fun changeEnvironment(id: String, environment: ApiEnvironment) {
configs.update { apiConfigs ->
val apiConfig = apiConfigs.keys.firstOrNull { it.id.name == id }
?: error("Api config with id [$id] not found. Check that ApiConfig with id [$id] was provided into DI")
apiConfigs + (apiConfig to environment)
}
}
override suspend fun changeEnvironment(environment: ApiEnvironment) {
configs.update { apiConfigs ->
apiConfigs.mapValues { (config, currentEnvironment) ->
val isEnvSupported = config.environmentConfigs.any { it.environment == environment }
if (isEnvSupported) environment else currentEnvironment
}
}
}
private fun getInitialConfigs(): Map<ApiConfig, ApiEnvironment> {
return apiConfigs.associateWith { it.defaultEnvironment }
}
}

View file

@ -16,4 +16,7 @@ interface MutableApiConfigsManager : ApiConfigsManager {
/** Change api environment [environment] by [id] */
suspend fun changeEnvironment(id: String, environment: ApiEnvironment)
/** Change api environment [environment] for all configs */
suspend fun changeEnvironment(environment: ApiEnvironment)
}

View file

@ -6,18 +6,20 @@ import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfigs
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.config.managers.DevApiConfigsManager
import com.tangem.datasource.api.common.config.managers.MockApiConfigsManager
import com.tangem.datasource.api.common.config.managers.ProdApiConfigsManager
import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.markets.TangemTechMarketsApi
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.TangemTechApiV2
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.utils.*
@ -45,7 +47,7 @@ internal object NetworkModule {
private const val STAKE_KIT_API_TIMEOUT_SECONDS = 60L
private val excludedApiForLogging: Set<ApiConfig.ID> = setOf(
// ApiConfig.ID.StakeKit,
ApiConfig.ID.StakeKit,
)
@Provides
@ -55,10 +57,10 @@ internal object NetworkModule {
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
): ApiConfigsManager {
return if (BuildConfig.TESTER_MENU_ENABLED) {
DevApiConfigsManager(apiConfigs, appPreferencesStore, dispatchers)
} else {
ProdApiConfigsManager(apiConfigs)
return when {
BuildConfig.BUILD_TYPE == MOCKED_BUILD_TYPE -> MockApiConfigsManager(apiConfigs)
BuildConfig.TESTER_MENU_ENABLED -> DevApiConfigsManager(apiConfigs, appPreferencesStore, dispatchers)
else -> ProdApiConfigsManager(apiConfigs)
}
}

View file

@ -5,6 +5,7 @@ import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.SwitchEnvironmentInterceptor
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.createNetworkLoggingInterceptor
import com.tangem.datasource.api.utils.ConnectTimeout
@ -93,7 +94,7 @@ internal fun OkHttpClient.Builder.applyApiConfig(
id: ApiConfig.ID,
apiConfigsManager: ApiConfigsManager,
): OkHttpClient.Builder {
return if (BuildConfig.TESTER_MENU_ENABLED) {
return if (BuildConfig.TESTER_MENU_ENABLED || BuildConfig.BUILD_TYPE == MOCKED_BUILD_TYPE) {
addInterceptor(
interceptor = SwitchEnvironmentInterceptor(
id = id,

View file

@ -167,6 +167,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
return when (env) {
ApiEnvironment.DEV,
ApiEnvironment.STAGE,
ApiEnvironment.MOCK,
-> rsaPublicKey.dev
ApiEnvironment.PROD -> rsaPublicKey.prod
}

View file

@ -14,7 +14,8 @@ internal object ApiEnvironmentComparator : Comparator<ApiEnvironmentConfig> {
when (it) {
ApiEnvironment.DEV -> 0
ApiEnvironment.STAGE -> 1
ApiEnvironment.PROD -> 2
ApiEnvironment.MOCK -> 2
ApiEnvironment.PROD -> 3
}
}

View file

@ -111,8 +111,10 @@ internal class EnvironmentsTogglesViewModel @Inject constructor(
cardSdkConfigRepository.setTangemApiProdEnvFlag(
flag = when (environment) {
ApiEnvironment.PROD -> true
ApiEnvironment.DEV -> false
ApiEnvironment.STAGE -> false
ApiEnvironment.DEV,
ApiEnvironment.STAGE,
ApiEnvironment.MOCK,
-> false
},
)
}