Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-02 13:40:00 +03:00
commit 86d72b171f
657 changed files with 19875 additions and 4004 deletions

View file

@ -12,6 +12,10 @@ android {
namespace = "com.tangem.data.tokens"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
// region Project - Data
@ -62,4 +66,13 @@ dependencies {
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
// endregion
}

View file

@ -0,0 +1,131 @@
package com.tangem.data.tokens
import arrow.core.Either
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.data.tokens.utils.CustomTokensMerger
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher.Params
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.isMultiCurrency
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
/**
* Default implementation of [MultiWalletCryptoCurrenciesFetcher]
*
* @property userWalletsStore [UserWallet]'s store
* @property tangemTechApi Tangem Tech API
* @property userTokensResponseStore store of [UserTokensResponse]
* @property userTokensSaver user tokens saver
* @property cardCryptoCurrencyFactory factory for creating crypto currencies for specified card
* @property expressServiceLoader express service loader
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
internal class DefaultMultiWalletCryptoCurrenciesFetcher(
private val demoConfig: DemoConfig,
private val userWalletsStore: UserWalletsStore,
private val tangemTechApi: TangemTechApi,
private val customTokensMerger: CustomTokensMerger,
private val userTokensResponseStore: UserTokensResponseStore,
private val userTokensSaver: UserTokensSaver,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
private val expressServiceLoader: ExpressServiceLoader,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiWalletCryptoCurrenciesFetcher {
private val userTokensResponseFactory = UserTokensResponseFactory()
override suspend fun invoke(params: Params) = Either.catchOn(dispatchers.default) {
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId)
if (!userWallet.isMultiCurrency) error("${this::class.simpleName} supports only multi-currency wallet")
val response = if (userWallet is UserWallet.Cold && userWallet.isDemoWalletWithoutSavedTokens()) {
createDefaultUserTokensResponse(scanResponse = userWallet.scanResponse)
} else {
safeApiCall(
call = {
withContext(dispatchers.io) {
tangemTechApi.getUserTokens(userId = userWallet.walletId.stringValue).bind()
}
},
onError = {
handleFetchTokensError(error = it, userWallet = userWallet.requireColdWallet()) // TODO 11142
},
)
}
val compatibleUserTokensResponse = response
.let { it.copy(tokens = it.tokens.distinct()) }
.let { customTokensMerger.mergeIfPresented(userWalletId = userWallet.walletId, response = it) }
userTokensSaver.store(userWalletId = userWallet.walletId, response = compatibleUserTokensResponse)
fetchExpressAssetsByNetworkIds(userWallet = userWallet, userTokens = compatibleUserTokensResponse)
}
private suspend fun UserWallet.Cold.isDemoWalletWithoutSavedTokens(): Boolean {
val isDemoCard = demoConfig.isDemoCardId(cardId = cardId)
return if (isDemoCard) {
val response = userTokensResponseStore.getSyncOrNull(userWalletId = walletId)
response == null
} else {
false
}
}
private suspend fun handleFetchTokensError(error: ApiResponseError, userWallet: UserWallet): UserTokensResponse {
val userWalletId = userWallet.walletId
val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)
?: createDefaultUserTokensResponse(scanResponse = userWallet.requireColdWallet().scanResponse)
if (error is ApiResponseError.HttpException && error.code == ApiResponseError.HttpException.Code.NOT_FOUND) {
Timber.w(error, "Requested currencies could not be found in the remote store for: $userWalletId")
userTokensSaver.push(userWalletId, response)
}
return response
}
private suspend fun fetchExpressAssetsByNetworkIds(userWallet: UserWallet, userTokens: UserTokensResponse) {
val tokens = userTokens.tokens.map { token ->
LeastTokenInfo(
contractAddress = token.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE,
network = token.networkId,
)
}
expressServiceLoader.update(userWallet = userWallet, userTokens = tokens)
}
private fun createDefaultUserTokensResponse(scanResponse: ScanResponse): UserTokensResponse {
return userTokensResponseFactory.createUserTokensResponse(
currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(scanResponse),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
}
}

View file

@ -0,0 +1,64 @@
package com.tangem.data.tokens
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.*
/**
* Default implementation of [MultiWalletCryptoCurrenciesProducer]
*
* @property params params
* @property userWalletsStore UserWallet's store
* @property userTokensResponseStore store of `UserTokensResponse`
* @property responseCryptoCurrenciesFactory factory for creating [CryptoCurrency] from `UserTokensResponse`
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultMultiWalletCryptoCurrenciesProducer @AssistedInject constructor(
@Assisted val params: MultiWalletCryptoCurrenciesProducer.Params,
private val userWalletsStore: UserWalletsStore,
private val userTokensResponseStore: UserTokensResponseStore,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiWalletCryptoCurrenciesProducer {
override val fallback: Set<CryptoCurrency>
get() = emptySet()
override fun produce(): Flow<Set<CryptoCurrency>> {
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId).requireColdWallet() // TODO [REDACTED_TASK_KEY]
if (!userWallet.isMultiCurrency) {
error("${this::class.simpleName} supports only multi-currency wallet")
}
return userTokensResponseStore.get(userWalletId = params.userWalletId)
.distinctUntilChanged()
.map { response ->
if (response == null) return@map emptySet()
responseCryptoCurrenciesFactory.createCurrencies(
response = response,
scanResponse = userWallet.scanResponse,
).toSet()
}
.onEmpty { emit(fallback) }
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : MultiWalletCryptoCurrenciesProducer.Factory {
override fun create(
params: MultiWalletCryptoCurrenciesProducer.Params,
): DefaultMultiWalletCryptoCurrenciesProducer
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.data.tokens.di
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.data.tokens.DefaultMultiWalletCryptoCurrenciesFetcher
import com.tangem.data.tokens.utils.CustomTokensMerger
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal class MultiWalletCryptoCurrenciesFetcherModule {
@Singleton
@Provides
fun provideMultiWalletCryptoCurrenciesFetcher(
tangemTechApi: TangemTechApi,
userWalletsStore: UserWalletsStore,
userTokensResponseStore: UserTokensResponseStore,
userTokensSaver: UserTokensSaver,
cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
expressServiceLoader: ExpressServiceLoader,
dispatchers: CoroutineDispatcherProvider,
): MultiWalletCryptoCurrenciesFetcher {
return DefaultMultiWalletCryptoCurrenciesFetcher(
demoConfig = DemoConfig(),
userWalletsStore = userWalletsStore,
tangemTechApi = tangemTechApi,
customTokensMerger = CustomTokensMerger(
tangemTechApi = tangemTechApi,
userTokensSaver = userTokensSaver,
dispatchers = dispatchers,
),
userTokensResponseStore = userTokensResponseStore,
userTokensSaver = userTokensSaver,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
expressServiceLoader = expressServiceLoader,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.tokens.di
import com.tangem.data.tokens.DefaultMultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface MultiWalletCryptoCurrenciesProducerModule {
@Singleton
@Binds
fun bindMultiWalletCryptoCurrenciesProducerFactory(
impl: DefaultMultiWalletCryptoCurrenciesProducer.Factory,
): MultiWalletCryptoCurrenciesProducer.Factory
}

View file

@ -0,0 +1,25 @@
package com.tangem.data.tokens.di
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal class MultiWalletCryptoCurrenciesSupplierModule {
@Singleton
@Provides
fun provideMultiWalletCryptoCurrenciesSupplier(
factory: MultiWalletCryptoCurrenciesProducer.Factory,
): MultiWalletCryptoCurrenciesSupplier {
return object : MultiWalletCryptoCurrenciesSupplier(
factory = factory,
keyCreator = { "multi_crypto_currency_${it.userWalletId}" },
) {}
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.data.tokens.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.data.tokens.repository.DefaultCurrenciesRepository
import com.tangem.data.tokens.repository.DefaultCurrencyChecksRepository
@ -10,6 +11,7 @@ import com.tangem.data.tokens.repository.DefaultPolkadotAccountHealthCheckReposi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
@ -30,7 +32,7 @@ internal object TokensDataModule {
@Singleton
fun provideCurrenciesRepository(
tangemTechApi: TangemTechApi,
appPreferencesStore: AppPreferencesStore,
userTokensResponseStore: UserTokensResponseStore,
userWalletsStore: UserWalletsStore,
walletManagersFacade: WalletManagersFacade,
cacheRegistry: CacheRegistry,
@ -39,18 +41,20 @@ internal object TokensDataModule {
excludedBlockchains: ExcludedBlockchains,
cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
tokensSaver: UserTokensSaver,
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
): CurrenciesRepository {
return DefaultCurrenciesRepository(
tangemTechApi = tangemTechApi,
userWalletsStore = userWalletsStore,
walletManagersFacade = walletManagersFacade,
cacheRegistry = cacheRegistry,
appPreferencesStore = appPreferencesStore,
userTokensResponseStore = userTokensResponseStore,
expressServiceLoader = expressServiceLoader,
dispatchers = dispatchers,
excludedBlockchains = excludedBlockchains,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
userTokensSaver = tokensSaver,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
)
}

View file

@ -14,11 +14,9 @@ import com.tangem.datasource.api.express.models.request.LeastTokenInfo
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.core.error.DataError
import com.tangem.domain.demo.DemoConfig
@ -41,16 +39,16 @@ internal class DefaultCurrenciesRepository(
private val userWalletsStore: UserWalletsStore,
private val walletManagersFacade: WalletManagersFacade,
private val cacheRegistry: CacheRegistry,
private val appPreferencesStore: AppPreferencesStore,
private val expressServiceLoader: ExpressServiceLoader,
private val dispatchers: CoroutineDispatcherProvider,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
private val userTokensSaver: UserTokensSaver,
private val userTokensResponseStore: UserTokensResponseStore,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
excludedBlockchains: ExcludedBlockchains,
) : CurrenciesRepository {
private val demoConfig = DemoConfig()
private val responseCurrenciesFactory = ResponseCryptoCurrenciesFactory(excludedBlockchains)
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
private val userTokensResponseFactory = UserTokensResponseFactory()
private val customTokensMerger = CustomTokensMerger(
@ -92,7 +90,10 @@ internal class DefaultCurrenciesRepository(
response = updatedResponse,
)
fetchExpressAssetsByNetworkIds(userWalletId, updatedResponse)
fetchExpressAssetsByNetworkIds(
userWallet = userWalletsStore.getSyncStrict(key = userWalletId),
userTokens = updatedResponse,
)
}
}
@ -115,7 +116,11 @@ internal class DefaultCurrenciesRepository(
userWalletId = userWalletId,
response = updatedResponse,
)
fetchExpressAssetsByNetworkIds(userWalletId, updatedResponse)
fetchExpressAssetsByNetworkIds(
userWallet = userWalletsStore.getSyncStrict(key = userWalletId),
userTokens = updatedResponse,
)
}
}
@ -201,7 +206,7 @@ internal class DefaultCurrenciesRepository(
override fun getWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow<List<CryptoCurrency>> {
return channelFlow {
val userWallet = getUserWallet(userWalletId)
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
if (userWallet.isMultiCurrency) {
getMultiCurrencyWalletCurrenciesUpdates(userWalletId).collect(::send)
@ -218,13 +223,19 @@ internal class DefaultCurrenciesRepository(
refresh: Boolean,
): CryptoCurrency {
return withContext(dispatchers.io) {
val userWallet = getUserWallet(userWalletId)
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = false)
val currency = cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(
userWallet.requireColdWallet().scanResponse,
)
fetchExpressAssetsByNetworkIds(userWalletId, listOf(currency), refresh)
fetchExpressAssetsByNetworkIds(
userWallet = userWallet,
cryptoCurrencies = listOf(currency),
refresh = refresh,
)
currency
}
}
@ -234,7 +245,8 @@ internal class DefaultCurrenciesRepository(
refresh: Boolean,
): List<CryptoCurrency> {
return withContext(dispatchers.io) {
val scanResponse = getUserWallet(userWalletId).requireColdWallet().scanResponse
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
val scanResponse = userWallet.requireColdWallet().scanResponse
val currencies = if (scanResponse.cardTypesResolver.isSingleWalletWithToken()) {
cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(scanResponse = scanResponse)
@ -244,7 +256,12 @@ internal class DefaultCurrenciesRepository(
}
}
fetchExpressAssetsByNetworkIds(userWalletId, currencies, refresh)
fetchExpressAssetsByNetworkIds(
userWallet = userWallet,
cryptoCurrencies = currencies,
refresh = refresh,
)
currencies
}
}
@ -254,7 +271,7 @@ internal class DefaultCurrenciesRepository(
id: CryptoCurrency.ID,
): CryptoCurrency {
return withContext(dispatchers.io) {
val userWallet = getUserWallet(userWalletId)
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = false)
val currency = cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(
@ -262,14 +279,14 @@ internal class DefaultCurrenciesRepository(
)
.find { it.id == id }
requireNotNull(currency) { "Unable to find currency with provided ID: $id" }
fetchExpressAssetsByNetworkIds(userWalletId, listOf(currency))
fetchExpressAssetsByNetworkIds(userWallet, listOf(currency))
currency
}
}
override fun getMultiCurrencyWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow<List<CryptoCurrency>> {
return channelFlow {
val userWallet = getUserWallet(userWalletId)
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
getMultiCurrencyWalletCurrencies(userWallet)
@ -286,7 +303,7 @@ internal class DefaultCurrenciesRepository(
userWalletId: UserWalletId,
refresh: Boolean,
): List<CryptoCurrency> = withContext(dispatchers.io) {
val userWallet = getUserWallet(userWalletId)
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
fetchTokensIfCacheExpired(userWallet, refresh)
@ -298,12 +315,12 @@ internal class DefaultCurrenciesRepository(
},
)
responseCurrenciesFactory.createCurrencies(storedTokens, userWallet.requireColdWallet().scanResponse)
responseCryptoCurrenciesFactory.createCurrencies(storedTokens, userWallet.requireColdWallet().scanResponse)
}
override suspend fun getMultiCurrencyWalletCachedCurrenciesSync(userWalletId: UserWalletId) =
withContext(dispatchers.io) {
val userWallet = getUserWallet(userWalletId)
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
val storedTokens = requireNotNull(
@ -313,7 +330,7 @@ internal class DefaultCurrenciesRepository(
},
)
responseCurrenciesFactory.createCurrencies(storedTokens, userWallet.requireColdWallet().scanResponse)
responseCryptoCurrenciesFactory.createCurrencies(storedTokens, userWallet.requireColdWallet().scanResponse)
}
override suspend fun getMultiCurrencyWalletCurrency(
@ -325,7 +342,7 @@ internal class DefaultCurrenciesRepository(
override suspend fun getMultiCurrencyWalletCurrency(userWalletId: UserWalletId, id: String): CryptoCurrency =
withContext(dispatchers.io) {
val userWallet = getUserWallet(userWalletId)
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
val response = requireNotNull(
@ -335,7 +352,7 @@ internal class DefaultCurrenciesRepository(
},
)
responseCurrenciesFactory.createCurrency(
responseCryptoCurrenciesFactory.createCurrency(
currencyId = id,
response = response,
scanResponse = userWallet.requireColdWallet().scanResponse,
@ -348,7 +365,7 @@ internal class DefaultCurrenciesRepository(
derivationPath: Network.DerivationPath,
): CryptoCurrency.Coin {
return withContext(dispatchers.io) {
val userWallet = getUserWallet(userWalletId)
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
ensureIsCorrectUserWallet(userWallet = userWallet, isMultiCurrencyWalletExpected = true)
fetchTokensIfCacheExpired(userWallet = userWallet, refresh = false)
@ -370,7 +387,10 @@ internal class DefaultCurrenciesRepository(
it.derivationPath == derivationPath.value
} ?: error("Coin in this network $networkId not found")
val coin = responseCurrenciesFactory.createCurrency(storedCoin, userWallet.requireColdWallet().scanResponse)
val coin = responseCryptoCurrenciesFactory.createCurrency(
responseToken = storedCoin,
scanResponse = userWallet.requireColdWallet().scanResponse,
)
coin as? CryptoCurrency.Coin ?: error("Unable to create currency")
}
@ -378,7 +398,7 @@ internal class DefaultCurrenciesRepository(
override fun isTokensGrouped(userWalletId: UserWalletId): Flow<Boolean> {
return channelFlow {
val userWallet = getUserWallet(userWalletId)
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
if (userWallet.isMultiCurrency) {
getSavedUserTokensResponse(userWalletId)
@ -394,7 +414,7 @@ internal class DefaultCurrenciesRepository(
override fun isTokensSortedByBalance(userWalletId: UserWalletId): Flow<Boolean> {
return channelFlow {
val userWallet = getUserWallet(userWalletId)
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
if (userWallet.isMultiCurrency) {
getSavedUserTokensResponse(userWalletId)
@ -466,7 +486,7 @@ internal class DefaultCurrenciesRepository(
contractAddress: String,
networkId: String,
): CryptoCurrency.Token {
val userWallet = getUserWallet(userWalletId)
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
val token = withContext(dispatchers.io) {
val foundToken = tangemTechApi.getCoins(
contractAddress = contractAddress,
@ -525,7 +545,7 @@ internal class DefaultCurrenciesRepository(
getL2CompatibilityTokenComparison(it, currencyRawId.value)
}
responseCurrenciesFactory.createCurrencies(
responseCryptoCurrenciesFactory.createCurrencies(
response = storedTokens.copy(tokens = filterResponse),
scanResponse = userWallet.requireColdWallet().scanResponse,
)
@ -570,9 +590,13 @@ internal class DefaultCurrenciesRepository(
)
}
override fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver {
return userWalletsStore.getSyncStrict(userWalletId).requireColdWallet().cardTypesResolver // TODO [REDACTED_TASK_KEY]
}
private fun getMultiCurrencyWalletCurrencies(userWallet: UserWallet): Flow<List<CryptoCurrency>> {
return getSavedUserTokensResponse(userWallet.walletId).map { storedTokens ->
responseCurrenciesFactory.createCurrencies(
responseCryptoCurrenciesFactory.createCurrencies(
response = storedTokens,
scanResponse = userWallet.requireColdWallet().scanResponse,
)
@ -611,7 +635,7 @@ internal class DefaultCurrenciesRepository(
userTokensSaver.store(userWalletId, compatibleUserTokensResponse)
fetchExpressAssetsByNetworkIds(userWalletId, compatibleUserTokensResponse)
fetchExpressAssetsByNetworkIds(userWallet, compatibleUserTokensResponse)
}
private suspend fun checkIsEmptyDemoWallet(userWallet: UserWallet.Cold): Boolean {
@ -620,7 +644,7 @@ internal class DefaultCurrenciesRepository(
return demoConfig.isDemoCardId(userWallet.cardId) && response == null
}
private suspend fun fetchExpressAssetsByNetworkIds(userWalletId: UserWalletId, userTokens: UserTokensResponse) {
private suspend fun fetchExpressAssetsByNetworkIds(userWallet: UserWallet, userTokens: UserTokensResponse) {
val tokens = userTokens.tokens.map { token ->
LeastTokenInfo(
contractAddress = token.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE,
@ -629,12 +653,12 @@ internal class DefaultCurrenciesRepository(
}
coroutineScope {
launch { expressServiceLoader.update(getUserWallet(userWalletId), tokens) }
launch { expressServiceLoader.update(userWallet, tokens) }
}
}
private suspend fun fetchExpressAssetsByNetworkIds(
userWalletId: UserWalletId,
userWallet: UserWallet,
cryptoCurrencies: List<CryptoCurrency>,
refresh: Boolean = false,
) {
@ -646,11 +670,11 @@ internal class DefaultCurrenciesRepository(
)
}
cacheRegistry.invokeOnExpire(
key = getAssetsCacheKey(userWalletId),
key = getAssetsCacheKey(userWallet.walletId),
skipCache = refresh,
block = {
coroutineScope {
launch { expressServiceLoader.update(getUserWallet(userWalletId), tokens) }
launch { expressServiceLoader.update(userWallet, tokens) }
}
},
)
@ -660,9 +684,8 @@ internal class DefaultCurrenciesRepository(
private suspend fun handleFetchTokensError(userWallet: UserWallet, e: ApiResponseError): UserTokensResponse {
val userWalletId = userWallet.walletId
val response = appPreferencesStore.getObjectSyncOrNull(
key = PreferencesKeys.getUserTokensKey(userWalletId.stringValue),
) ?: createDefaultUserTokensResponse(userWallet)
val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)
?: createDefaultUserTokensResponse(userWallet = userWallet)
if (e is ApiResponseError.HttpException && e.code == ApiResponseError.HttpException.Code.NOT_FOUND) {
Timber.w(e, "Requested currencies could not be found in the remote store for: $userWalletId")
@ -678,20 +701,14 @@ internal class DefaultCurrenciesRepository(
private fun createDefaultUserTokensResponse(userWallet: UserWallet) =
userTokensResponseFactory.createUserTokensResponse(
currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(
userWallet.requireColdWallet().scanResponse,
userWallet.requireColdWallet().scanResponse, // TODO [REDACTED_TASK_KEY]
),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
private fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"Unable to find a user wallet with provided ID: $userWalletId"
}
}
private fun ensureIsCorrectUserWallet(userWalletId: UserWalletId, isMultiCurrencyWalletExpected: Boolean) {
val userWallet = getUserWallet(userWalletId)
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected)
}
@ -720,14 +737,10 @@ internal class DefaultCurrenciesRepository(
private fun getTokensCacheKey(userWalletId: UserWalletId): String = "tokens_cache_key_${userWalletId.stringValue}"
private fun getSavedUserTokensResponse(key: UserWalletId): Flow<UserTokensResponse> {
return appPreferencesStore
.getObject<UserTokensResponse>(PreferencesKeys.getUserTokensKey(userWalletId = key.stringValue))
.filterNotNull()
return userTokensResponseStore.get(userWalletId = key).filterNotNull()
}
private suspend fun getSavedUserTokensResponseSync(key: UserWalletId): UserTokensResponse? {
return appPreferencesStore.getObjectSyncOrNull<UserTokensResponse>(
key = PreferencesKeys.getUserTokensKey(key.stringValue),
)
return userTokensResponseStore.getSyncOrNull(userWalletId = key)
}
}

View file

@ -4,6 +4,7 @@ import arrow.atomic.AtomicBoolean
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -15,11 +16,15 @@ import timber.log.Timber
/**
* Responsible for merging custom tokens into a user's token response.
* It handles the logic to update tokens with additional details if necessary.
*
* @property tangemTechApi Tangem Tech API
* @property userTokensSaver user tokens saver
* @property dispatchers dispatchers
*/
internal class CustomTokensMerger(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val userTokensSaver: UserTokensSaver,
private val dispatchers: CoroutineDispatcherProvider,
) {
/**
@ -29,21 +34,17 @@ internal class CustomTokensMerger(
* is needed, and if so, updating the token from [TangemTechApi.getCoins] response. It then pushes to the backend
* and returns an updated UserTokensResponse.
*
* @param userWalletId The identifier for the user's wallet, used when pushing updates.
* @param response The original user tokens response that may need to be updated.
* @return A potentially updated UserTokensResponse, with custom tokens merged if necessary.
* @param userWalletId the identifier for the user's wallet, used when pushing updates
* @param response the original user tokens response that may need to be updated
*
* @return a potentially updated UserTokensResponse, with custom tokens merged if necessary
*/
suspend fun mergeIfPresented(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse {
// use flag to check: we can't compare two token list after merge because Token equals don't include some fields
val wasMerged = AtomicBoolean(false)
val mergedTokens = withContext(dispatchers.default) {
response.tokens
.map { token ->
async { mergeIfPresented(token, wasMerged) }
}
.awaitAll()
}
val mergedTokens = mergeTokens(response = response, wasMerged = wasMerged)
val updatedResponse = response.copy(tokens = mergedTokens)
// previously here was used compare response.tokens, but it's not working correctly
@ -55,33 +56,48 @@ internal class CustomTokensMerger(
return updatedResponse
}
private suspend fun mergeIfPresented(
token: UserTokensResponse.Token,
private suspend fun mergeTokens(
response: UserTokensResponse,
wasMerged: AtomicBoolean,
): UserTokensResponse.Token {
if (isCoinOrNonCustomToken(token)) return token
): List<UserTokensResponse.Token> {
return withContext(dispatchers.default) {
response.tokens
.map { token ->
async {
if (isCoinOrNonCustomToken(token)) return@async token
return merge(token, wasMerged)
}
private suspend fun merge(
customToken: UserTokensResponse.Token,
wasMerged: AtomicBoolean,
): UserTokensResponse.Token {
val foundToken = fetchToken(customToken)
if (foundToken != null) {
wasMerged.set(true)
mergeToken(customToken = token, wasMerged = wasMerged)
}
}
.awaitAll()
}
return foundToken ?: customToken
}
private fun isCoinOrNonCustomToken(token: UserTokensResponse.Token): Boolean {
return token.contractAddress.isNullOrEmpty() || token.id != null
}
private suspend fun fetchToken(token: UserTokensResponse.Token): UserTokensResponse.Token? {
val response = withContext(dispatchers.io) {
safeApiCall(
private suspend fun mergeToken(
customToken: UserTokensResponse.Token,
wasMerged: AtomicBoolean,
): UserTokensResponse.Token {
val foundToken = findToken(token = customToken)
return if (foundToken != null) {
wasMerged.set(true)
customToken.mergeWith(foundToken)
} else {
customToken
}
}
/**
* Find a [token] among the cryptocurrencies available for application.
* If result is not null, then the token is available and should not be custom.
*/
private suspend fun findToken(token: UserTokensResponse.Token): CoinsResponse.Coin? {
return withContext(dispatchers.io) {
val response = safeApiCall(
call = {
tangemTechApi.getCoins(
contractAddress = token.contractAddress,
@ -89,19 +105,20 @@ internal class CustomTokensMerger(
).bind()
},
onError = {
Timber.w(it, "Unable to fetch token")
Timber.e(it, "Unable to fetch token:\n$token")
null
},
)
}
val foundToken = response?.coins?.firstOrNull() ?: return null
return token.copy(
id = foundToken.id,
name = foundToken.name,
symbol = foundToken.symbol.ifEmpty {
token.symbol
},
response?.coins?.firstOrNull()
}
}
private fun UserTokensResponse.Token.mergeWith(coin: CoinsResponse.Coin): UserTokensResponse.Token {
return copy(
id = coin.id,
name = coin.name,
symbol = coin.symbol.ifEmpty { symbol },
)
}
}

View file

@ -1,18 +0,0 @@
package com.tangem.data.tokens.utils
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.tokens.model.FoundToken
import com.tangem.utils.converter.Converter
internal object FoundTokenConverter : Converter<CoinsResponse.Coin, FoundToken> {
override fun convert(value: CoinsResponse.Coin): FoundToken {
return FoundToken(
id = value.id,
name = value.name,
symbol = value.symbol,
contractAddress = requireNotNull(value.networks.first().contractAddress),
decimals = requireNotNull(value.networks.first().decimalCount).intValueExact(),
)
}
}

View file

@ -1,57 +0,0 @@
package com.tangem.data.tokens.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.compatibility.l2BlockchainsCoinIds
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
/**
* Adapter to replace unsupported currencies for quotes request if it necessary
*/
class QuotesUnsupportedCurrenciesIdAdapter {
/**
* Replaces unsupported currencies id to it replacements for request
*/
fun replaceUnsupportedCurrencies(currenciesIds: Set<String>): ReplacementResult {
val idsForRequest = mutableSetOf<String>()
val idsFiltered = mutableSetOf<String>()
currenciesIds.forEach { currencyId ->
val replacementId = UNSUPPORTED_IDS_WITH_REPLACEMENTS[currencyId]
if (replacementId != null) {
idsFiltered.add(currencyId)
idsForRequest.add(replacementId)
} else {
idsForRequest.add(currencyId)
}
}
return ReplacementResult(idsForRequest = idsForRequest, idsFiltered = idsFiltered)
}
/**
* Recover previously replaced currencies to response that uses in application
*/
fun getResponseWithUnsupportedCurrencies(response: QuotesResponse, filteredIds: Set<String>): QuotesResponse {
val updatedQuotes = mutableMapOf<String, QuotesResponse.Quote>()
filteredIds.forEach { filteredId ->
val replacementId = UNSUPPORTED_IDS_WITH_REPLACEMENTS[filteredId]
if (replacementId != null) {
val quoteReplacement = response.quotes[replacementId]
if (quoteReplacement != null) {
updatedQuotes[filteredId] = quoteReplacement
}
}
}
return response.copy(quotes = updatedQuotes + response.quotes)
}
data class ReplacementResult(val idsForRequest: Set<String>, val idsFiltered: Set<String>)
companion object {
/**
* Map that contains unsupported currencies and their replacement for request
*/
private val ethCoinId = Blockchain.Ethereum.toCoinId()
private val UNSUPPORTED_IDS_WITH_REPLACEMENTS = l2BlockchainsCoinIds.associateWith { ethCoinId }
}
}

View file

@ -0,0 +1,481 @@
package com.tangem.data.tokens
import arrow.core.left
import arrow.core.right
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.assertEither
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.data.tokens.utils.CustomTokensMerger
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.isMultiCurrency
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
private val userTokensResponseFactory = UserTokensResponseFactory()
private val userWalletsStore: UserWalletsStore = mockk(relaxUnitFun = true)
private val tangemTechApi: TangemTechApi = mockk()
private val customTokensMerger: CustomTokensMerger = mockk()
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk()
private val expressServiceLoader: ExpressServiceLoader = mockk(relaxUnitFun = true)
private val fetcher = DefaultMultiWalletCryptoCurrenciesFetcher(
demoConfig = DemoConfig(),
userWalletsStore = userWalletsStore,
tangemTechApi = tangemTechApi,
customTokensMerger = customTokensMerger,
userTokensResponseStore = userTokensResponseStore,
userTokensSaver = userTokensSaver,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
expressServiceLoader = expressServiceLoader,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun resetMocks() {
clearMocks(
userWalletsStore,
tangemTechApi,
userTokensResponseStore,
userTokensSaver,
cardCryptoCurrencyFactory,
expressServiceLoader,
)
}
@Test
fun `fetch failure if UserWallet ISN'T MULTI-CURRENCY wallet`() = runTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet> {
every { isMultiCurrency } returns false
}
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
// Act
val actual = fetcher(params)
// Assert
val expected = IllegalStateException("${this::class.simpleName} supports only multi-currency wallet").left()
assertEither(actual, expected)
verifyOrder { userWalletsStore.getSyncStrict(key = params.userWalletId) }
coVerify(inverse = true) {
userTokensResponseStore.getSyncOrNull(any())
}
}
@Test
fun `fetch successfully if CARD IS DEMO and STORED TOKENS ARE EMPTY`() = runTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet.Cold> {
every { walletId } returns userWalletId
every { isMultiCurrency } returns true
every { cardId } returns "AC01000000041225"
}
val defaultCoins = listOf(
cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin),
cryptoCurrencyFactory.createCoin(Blockchain.Ethereum),
)
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
tokens = listOf(
userTokensResponseFactory.createResponseToken(defaultCoins.first()),
userTokensResponseFactory.createResponseToken(defaultCoins.last()),
),
)
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId = params.userWalletId) } returns null
every {
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(mockUserWallet.scanResponse)
} returns defaultCoins
coEvery {
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
} returns userTokensResponse
// Act
val actual = fetcher(params)
// Assert
val expected = Unit.right()
assertEither(actual, expected)
coVerifyOrder {
userWalletsStore.getSyncStrict(key = params.userWalletId)
userTokensResponseStore.getSyncOrNull(userWalletId = params.userWalletId)
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(scanResponse = mockUserWallet.scanResponse)
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
userTokensSaver.store(userWalletId = params.userWalletId, response = userTokensResponse)
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = userTokensResponse.toLeastTokens())
}
}
@Test
fun `fetch successfully if CARD IS DEMO and STORED TOKENS AREN'T EMPTY`() = runTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet.Cold> {
every { walletId } returns userWalletId
every { isMultiCurrency } returns true
every { cardId } returns "AC01000000041225"
}
val apiResponse = ApiResponse.Success(
data = defaultResponse.copy(group = UserTokensResponse.GroupType.TOKEN),
)
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId = params.userWalletId) } returns defaultResponse
coEvery { tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue) } returns apiResponse
coEvery {
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data)
} returns apiResponse.data
// Act
val actual = fetcher(params)
// Assert
val expected = Unit.right()
assertEither(actual, expected)
coVerifyOrder {
userWalletsStore.getSyncStrict(key = params.userWalletId)
userTokensResponseStore.getSyncOrNull(userWalletId = params.userWalletId)
tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue)
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data)
userTokensSaver.store(userWalletId = params.userWalletId, response = apiResponse.data)
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens())
}
coVerify(inverse = true) {
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(scanResponse = any())
}
}
@Test
fun `fetch successfully if CARD ISN'T DEMO`() = runTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet.Cold> {
every { walletId } returns userWalletId
every { isMultiCurrency } returns true
every { cardId } returns "cardID"
}
val apiResponse = ApiResponse.Success(
data = defaultResponse.copy(group = UserTokensResponse.GroupType.TOKEN),
)
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
coEvery { tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue) } returns apiResponse
coEvery {
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data)
} returns apiResponse.data
// Act
val actual = fetcher(params)
// Assert
val expected = Unit.right()
assertEither(actual, expected)
coVerifyOrder {
userWalletsStore.getSyncStrict(key = params.userWalletId)
tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue)
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data)
userTokensSaver.store(userWalletId = params.userWalletId, response = apiResponse.data)
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens())
}
coVerify(inverse = true) {
userTokensResponseStore.getSyncOrNull(userWalletId = any())
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(scanResponse = any())
}
}
@Test
fun `fetch successfully if API request RETURNS TIMEOUT EXCEPTION and STORED TOKENS ARE EMPTY`() = runTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet.Cold> {
every { walletId } returns userWalletId
every { isMultiCurrency } returns true
every { cardId } returns "cardID"
}
@Suppress("UNCHECKED_CAST")
val apiResponse = ApiResponse.Error(
cause = ApiResponseError.TimeoutException,
) as ApiResponse<UserTokensResponse>
val defaultCoins = listOf(
cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin),
cryptoCurrencyFactory.createCoin(Blockchain.Ethereum),
)
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
tokens = listOf(
userTokensResponseFactory.createResponseToken(defaultCoins.first()),
userTokensResponseFactory.createResponseToken(defaultCoins.last()),
),
)
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
coEvery { tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue) } returns apiResponse
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId) } returns null
coEvery {
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(mockUserWallet.scanResponse)
} returns defaultCoins
coEvery {
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
} returns userTokensResponse
// Act
val actual = fetcher(params)
// Assert
val expected = Unit.right()
assertEither(actual, expected)
coVerifyOrder {
userWalletsStore.getSyncStrict(key = params.userWalletId)
tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue)
userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
userTokensSaver.store(userWalletId = params.userWalletId, response = userTokensResponse)
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = userTokensResponse.toLeastTokens())
}
coVerify(inverse = true) {
userTokensSaver.push(userWalletId = any(), response = any())
}
}
@Test
fun `fetch successfully if API request RETURNS TIMEOUT EXCEPTION and STORED TOKENS AREN'T EMPTY`() = runTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet.Cold> {
every { walletId } returns userWalletId
every { isMultiCurrency } returns true
every { cardId } returns "cardID"
}
@Suppress("UNCHECKED_CAST")
val apiResponse = ApiResponse.Error(
cause = ApiResponseError.TimeoutException,
) as ApiResponse<UserTokensResponse>
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
coEvery { tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue) } returns apiResponse
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId) } returns defaultResponse
coEvery {
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse)
} returns defaultResponse
// Act
val actual = fetcher(params)
// Assert
val expected = Unit.right()
assertEither(actual, expected)
coVerifyOrder {
userWalletsStore.getSyncStrict(key = params.userWalletId)
tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue)
userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse)
userTokensSaver.store(userWalletId = params.userWalletId, response = defaultResponse)
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens())
}
coVerify(inverse = true) {
userTokensSaver.push(userWalletId = any(), response = any())
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(scanResponse = any())
}
}
@Test
fun `fetch successfully if API request RETURNS NOT FOUND EXCEPTION and STORED TOKENS ARE EMPTY`() = runTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet.Cold> {
every { walletId } returns userWalletId
every { isMultiCurrency } returns true
every { cardId } returns "cardID"
}
@Suppress("UNCHECKED_CAST")
val apiResponse = ApiResponse.Error(
cause = ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.NOT_FOUND,
message = null,
errorBody = null,
),
) as ApiResponse<UserTokensResponse>
val defaultCoins = listOf(
cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin),
cryptoCurrencyFactory.createCoin(Blockchain.Ethereum),
)
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
tokens = listOf(
userTokensResponseFactory.createResponseToken(defaultCoins.first()),
userTokensResponseFactory.createResponseToken(defaultCoins.last()),
),
)
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
coEvery { tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue) } returns apiResponse
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId) } returns null
coEvery {
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(mockUserWallet.scanResponse)
} returns defaultCoins
coEvery {
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
} returns userTokensResponse
// Act
val actual = fetcher(params)
// Assert
val expected = Unit.right()
assertEither(actual, expected)
coVerifyOrder {
userWalletsStore.getSyncStrict(key = params.userWalletId)
tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue)
userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(mockUserWallet.scanResponse)
userTokensSaver.push(userWalletId = params.userWalletId, response = userTokensResponse)
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
userTokensSaver.store(userWalletId = params.userWalletId, response = userTokensResponse)
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = userTokensResponse.toLeastTokens())
}
}
@Test
fun `fetch successfully if API request RETURNS NOT FOUND EXCEPTION and STORED TOKENS AREN'T EMPTY`() = runTest {
// Arrange
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
val mockUserWallet = mockk<UserWallet.Cold> {
every { walletId } returns userWalletId
every { isMultiCurrency } returns true
every { cardId } returns "cardID"
}
@Suppress("UNCHECKED_CAST")
val apiResponse = ApiResponse.Error(
cause = ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.NOT_FOUND,
message = null,
errorBody = null,
),
) as ApiResponse<UserTokensResponse>
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
coEvery { tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue) } returns apiResponse
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId) } returns defaultResponse
coEvery {
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse)
} returns defaultResponse
// Act
val actual = fetcher(params)
// Assert
val expected = Unit.right()
assertEither(actual, expected)
coVerifyOrder {
userWalletsStore.getSyncStrict(key = params.userWalletId)
tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue)
userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)
userTokensSaver.push(userWalletId = params.userWalletId, response = defaultResponse)
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse)
userTokensSaver.store(userWalletId = params.userWalletId, response = defaultResponse)
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens())
}
coVerify(inverse = true) {
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(scanResponse = any())
}
}
private companion object {
val userWalletId = UserWalletId("011")
val defaultResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
tokens = listOf(
UserTokensResponse.Token(
id = null,
networkId = "bitcoin",
derivationPath = null,
name = "Bitcoin",
symbol = "BTC",
decimals = 8,
contractAddress = null,
addresses = listOf(),
),
),
)
fun UserTokensResponse.toLeastTokens(): List<LeastTokenInfo> {
return tokens.map { token ->
LeastTokenInfo(
contractAddress = token.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE,
network = token.networkId,
)
}
}
}
}

View file

@ -0,0 +1,351 @@
package com.tangem.data.tokens
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.configs.GenericCardConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.isMultiCurrency
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
private val params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWallet.walletId)
private val userWalletsStore: UserWalletsStore = mockk(relaxUnitFun = true)
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk()
private val producer = DefaultMultiWalletCryptoCurrenciesProducer(
params = params,
userWalletsStore = userWalletsStore,
userTokensResponseStore = userTokensResponseStore,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun resetMocks() {
clearMocks(userWalletsStore, userTokensResponseStore, responseCryptoCurrenciesFactory)
}
@Test
fun `flow is mapped for user wallet id from params`() = runTest {
// Arrange
val userTokensResponseFlow = flowOf<UserTokensResponse?>(null)
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
val expected = emptySet<CryptoCurrency>()
Truth.assertThat(actual.size).isEqualTo(1)
Truth.assertThat(actual.first()).isEqualTo(expected)
verifyOrder {
userWalletsStore.getSyncStrict(params.userWalletId)
userTokensResponseStore.get(params.userWalletId)
}
verify(inverse = true) {
responseCryptoCurrenciesFactory.createCurrencies(response = any(), scanResponse = any())
}
}
@Test
fun `flow will updated if UserTokensResponse is updated`() = runTest {
// Arrange
val userTokensResponseFlow = MutableSharedFlow<UserTokensResponse>(replay = 2)
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.TOKEN,
sort = UserTokensResponse.SortType.MARKETCAP,
tokens = emptyList(),
)
val cryptoCurrencies = emptySet<CryptoCurrency>()
val updatedUserTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.TOKEN,
sort = UserTokensResponse.SortType.MARKETCAP,
tokens = listOf(
UserTokensResponse.Token(
id = null,
networkId = "bitcoin",
derivationPath = null,
name = "Bitcoin",
symbol = "BTC",
decimals = 8,
contractAddress = null,
addresses = listOf(),
),
),
)
val updatedCryptoCurrencies = setOf(
cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin),
)
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
scanResponse = userWallet.scanResponse,
)
} returns cryptoCurrencies.toList()
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = updatedUserTokensResponse,
scanResponse = userWallet.scanResponse,
)
} returns updatedCryptoCurrencies.toList()
val producerFlow = producer.produce()
// Act 1 (first emit)
userTokensResponseFlow.emit(userTokensResponse)
val actual1 = getEmittedValues(flow = producerFlow)
// Assert
val expected1 = cryptoCurrencies
Truth.assertThat(actual1.size).isEqualTo(1)
Truth.assertThat(actual1.first()).isEqualTo(expected1)
verifyOrder {
userWalletsStore.getSyncStrict(params.userWalletId)
userTokensResponseStore.get(params.userWalletId)
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
scanResponse = userWallet.scanResponse,
)
}
// Act 2 (second emit)
userTokensResponseFlow.emit(updatedUserTokensResponse)
val actual2 = getEmittedValues(flow = producerFlow)
// Assert
val expected2 = listOf(cryptoCurrencies, updatedCryptoCurrencies)
Truth.assertThat(actual2.size).isEqualTo(2)
Truth.assertThat(actual2).isEqualTo(expected2)
verifyOrder {
responseCryptoCurrenciesFactory.createCurrencies(
response = updatedUserTokensResponse,
scanResponse = userWallet.scanResponse,
)
}
}
@Test
fun `flow is filtered the same status`() = runTest {
// Arrange
val userTokensResponseFlow = MutableSharedFlow<UserTokensResponse>(replay = 2)
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.TOKEN,
sort = UserTokensResponse.SortType.MARKETCAP,
tokens = emptyList(),
)
val cryptoCurrencies = emptySet<CryptoCurrency>()
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
scanResponse = userWallet.scanResponse,
)
} returns cryptoCurrencies.toList()
val producerFlow = producer.produce()
// Act 1 (first emit)
userTokensResponseFlow.emit(userTokensResponse)
val actual1 = getEmittedValues(flow = producerFlow)
// Assert
val expected1 = cryptoCurrencies
Truth.assertThat(actual1.size).isEqualTo(1)
Truth.assertThat(actual1.first()).isEqualTo(expected1)
verifyOrder {
userWalletsStore.getSyncStrict(params.userWalletId)
userTokensResponseStore.get(params.userWalletId)
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
scanResponse = userWallet.scanResponse,
)
}
// Act 2 (second emit)
userTokensResponseFlow.emit(userTokensResponse)
val actual2 = getEmittedValues(flow = producerFlow)
// Assert
val expected2 = expected1
Truth.assertThat(actual2.size).isEqualTo(1)
Truth.assertThat(actual2.first()).isEqualTo(expected2)
}
@Test
fun `flow throws exception`() = runTest {
// Arrange
val exception = IllegalStateException()
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.TOKEN,
sort = UserTokensResponse.SortType.MARKETCAP,
tokens = emptyList(),
)
val cryptoCurrencies = emptySet<CryptoCurrency>()
val innerFlow = MutableStateFlow(value = false)
val userTokensResponseFlow = flow {
if (innerFlow.value) {
emit(userTokensResponse)
} else {
throw exception
}
}
.buffer(capacity = 5)
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
scanResponse = userWallet.scanResponse,
)
} returns cryptoCurrencies.toList()
val producerFlow = producer.produceWithFallback()
// Act 1 (fallback)
val actual1 = getEmittedValues(flow = producerFlow)
// Assert
val expected1 = producer.fallback
Truth.assertThat(actual1.size).isEqualTo(1)
Truth.assertThat(actual1.first()).isEqualTo(expected1)
verifyOrder {
userWalletsStore.getSyncStrict(params.userWalletId)
userTokensResponseStore.get(params.userWalletId)
}
// Act 2 (emit)
innerFlow.emit(value = true)
val actual2 = getEmittedValues(flow = producerFlow)
// Assert
val expected2 = cryptoCurrencies
Truth.assertThat(actual2.size).isEqualTo(1)
Truth.assertThat(actual2.first()).isEqualTo(expected2)
verifyOrder {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
scanResponse = userWallet.scanResponse,
)
}
}
@Test
fun `flow is empty if store returns empty flow`() = runTest {
// Arrange
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet
every { userTokensResponseStore.get(params.userWalletId) } returns emptyFlow()
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
val expected = producer.fallback
Truth.assertThat(actual.size).isEqualTo(1)
Truth.assertThat(actual.first()).isEqualTo(expected)
verifyOrder {
userWalletsStore.getSyncStrict(params.userWalletId)
userTokensResponseStore.get(params.userWalletId)
}
verify(inverse = true) {
responseCryptoCurrenciesFactory.createCurrencies(response = any(), scanResponse = any())
}
}
@Test
fun `produce throws exception if UserWallet isn't multi-currency wallet`() = runTest {
// Arrange
val mockUserWallet = mockk<UserWallet> {
every { isMultiCurrency } returns false
}
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
// Act
val actual = runCatching { producer.produce() }.exceptionOrNull()
// Assert
val expected = IllegalStateException(
"${DefaultMultiWalletCryptoCurrenciesProducer::class.simpleName} supports only multi-currency wallet",
)
Truth.assertThat(actual).isInstanceOf(expected::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message)
verifyOrder { userWalletsStore.getSyncStrict(params.userWalletId) }
verify(inverse = true) {
userTokensResponseStore.get(any())
responseCryptoCurrenciesFactory.createCurrencies(response = any(), scanResponse = any())
}
}
private companion object {
val scanResponse = MockScanResponseFactory.create(
cardConfig = GenericCardConfig(2),
derivedKeys = emptyMap(),
)
val userWallet = MockUserWalletFactory.create(scanResponse = scanResponse)
}
}

View file

@ -0,0 +1,165 @@
package com.tangem.data.tokens.utils
import com.google.common.truth.Truth
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class CustomTokensMergerTest {
private val tangemTechApi: TangemTechApi = mockk()
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
private val merger = CustomTokensMerger(
tangemTechApi = tangemTechApi,
userTokensSaver = userTokensSaver,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun resetMocks() {
clearMocks(tangemTechApi, userTokensSaver)
}
@Test
fun `mergeIfPresented successfully if UserTokensResponse DOESN'T CONTAIN custom tokens`() = runTest {
// Act
val actual = merger.mergeIfPresented(userWalletId = userWalletId, response = defaultUserTokensResponse)
// Assert
val expected = defaultUserTokensResponse
Truth.assertThat(actual).isEqualTo(expected)
coVerify(inverse = true) {
tangemTechApi.getCoins(contractAddress = any(), networkIds = any())
userTokensSaver.push(any(), any())
}
}
@Test
fun `mergeIfPresented successfully if UserTokensResponse CONTAINS custom tokens`() = runTest {
// Arrange
val customToken = UserTokensResponse.Token(
id = null,
networkId = "cum",
name = "Matilda Rich",
symbol = "consectetuer",
decimals = 9582,
contractAddress = "contractAddress",
)
val userTokensResponse = defaultUserTokensResponse.copy(
tokens = defaultUserTokensResponse.tokens + customToken,
)
coEvery {
tangemTechApi.getCoins(contractAddress = customToken.contractAddress, networkIds = customToken.networkId)
} returns ApiResponse.Success(data = createCoinResponse(token = customToken))
// Act
val actual = merger.mergeIfPresented(userWalletId = userWalletId, response = userTokensResponse)
// Assert
val updatedCustomToken = customToken.copy(
id = "NEW_${customToken.id}",
name = "NEW_${customToken.name}",
symbol = "NEW_${customToken.symbol}",
)
val expected = defaultUserTokensResponse.copy(
tokens = defaultUserTokensResponse.tokens + updatedCustomToken,
)
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
tangemTechApi.getCoins(
contractAddress = customToken.contractAddress,
networkIds = customToken.networkId,
)
userTokensSaver.push(userWalletId = userWalletId, response = expected)
}
}
@Test
fun `mergeIfPresented successfully if API returns error`() = runTest {
// Arrange
val customToken = UserTokensResponse.Token(
id = null,
networkId = "cum",
name = "Matilda Rich",
symbol = "consectetuer",
decimals = 9582,
contractAddress = "contractAddress",
)
val userTokensResponse = defaultUserTokensResponse.copy(
tokens = defaultUserTokensResponse.tokens + customToken,
)
@Suppress("UNCHECKED_CAST")
val apiResponse = ApiResponse.Error(ApiResponseError.TimeoutException) as ApiResponse<CoinsResponse>
coEvery {
tangemTechApi.getCoins(contractAddress = customToken.contractAddress, networkIds = customToken.networkId)
} returns apiResponse
// Act
val actual = merger.mergeIfPresented(userWalletId = userWalletId, response = userTokensResponse)
// Assert
val expected = userTokensResponse
Truth.assertThat(actual).isEqualTo(expected)
coVerify {
tangemTechApi.getCoins(
contractAddress = customToken.contractAddress,
networkIds = customToken.networkId,
)
}
coVerify(inverse = true) { userTokensSaver.push(userWalletId = any(), response = any()) }
}
private companion object {
val userWalletId = UserWalletId("011")
val defaultUserTokensResponse = UserTokensResponseFactory().createUserTokensResponse(
currencies = MockCryptoCurrencyFactory().ethereumAndStellar,
isGroupedByNetwork = false,
isSortedByBalance = false,
)
fun createCoinResponse(token: UserTokensResponse.Token): CoinsResponse {
return CoinsResponse(
imageHost = null,
coins = listOf(
CoinsResponse.Coin(
id = "NEW_${token.id}",
name = "NEW_${token.name}",
symbol = "NEW_${token.symbol}",
active = true,
),
),
total = 1,
)
}
}
}