Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-06 10:59:48 +04:00
parent e2721b21e1
commit a67ca6279f
14 changed files with 24 additions and 614 deletions

View file

@ -1,71 +0,0 @@
package com.tangem.data.account.producer
import arrow.core.Option
import arrow.core.some
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
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 flowProducerTools tools for producing flows
* @property userWalletsListRepository repository for getting user wallets
* @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,
override val flowProducerTools: FlowProducerTools,
private val userWalletsListRepository: UserWalletsListRepository,
private val userTokensResponseStore: UserTokensResponseStore,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiWalletCryptoCurrenciesProducer {
override val fallback: Option<Set<CryptoCurrency>> = emptySet<CryptoCurrency>().some()
override fun produce(): Flow<Set<CryptoCurrency>> {
val userWallet = userWalletsListRepository.getSyncStrict(id = params.userWalletId)
if (!userWallet.isMultiCurrency) {
error("${this::class.simpleName ?: this::class.toString()} supports only multi-currency wallet")
}
return userTokensResponseStore.get(userWalletId = params.userWalletId)
.distinctUntilChanged()
.map { response ->
if (response == null) return@map emptySet()
responseCryptoCurrenciesFactory.createCurrencies(
response = response,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
).toSet()
}
.onEmpty { emit(emptySet()) }
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : MultiWalletCryptoCurrenciesProducer.Factory {
override fun create(
params: MultiWalletCryptoCurrenciesProducer.Params,
): DefaultMultiWalletCryptoCurrenciesProducer
}
}

View file

@ -1,378 +0,0 @@
package com.tangem.data.account.producer
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.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.test.core.getEmittedValues
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.Disabled
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 userWalletsListRepository: UserWalletsListRepository = mockk(relaxUnitFun = true)
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk()
private val flowProducerTools: FlowProducerTools = mockk()
private val producer = DefaultMultiWalletCryptoCurrenciesProducer(
params = params,
flowProducerTools = flowProducerTools,
userWalletsListRepository = userWalletsListRepository,
userTokensResponseStore = userTokensResponseStore,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun resetMocks() {
clearMocks(userWalletsListRepository, userTokensResponseStore, responseCryptoCurrenciesFactory)
}
@Test
fun `flow is mapped for user wallet id from params`() = runTest {
// Arrange
val userTokensResponseFlow = flowOf<UserTokensResponse?>(null)
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
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 {
userWalletsListRepository.userWallets
userTokensResponseStore.get(params.userWalletId)
}
verify(inverse = true) {
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = 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),
)
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
} returns cryptoCurrencies.toList()
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = updatedUserTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
} 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 {
userWalletsListRepository.userWallets
userTokensResponseStore.get(params.userWalletId)
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
// 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,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
}
@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>()
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
} 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 {
userWalletsListRepository.userWallets
userTokensResponseStore.get(params.userWalletId)
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
// 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)
}
@Disabled
@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)
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
} returns cryptoCurrencies.toList()
val producerFlow = producer.produceWithFallback()
// Act 1 (fallback)
val actual1 = getEmittedValues(flow = producerFlow)
// Assert
val expected1 = producer.fallback.getOrNull()
Truth.assertThat(actual1.size).isEqualTo(1)
Truth.assertThat(actual1.first()).isEqualTo(expected1)
verifyOrder {
userWalletsListRepository.userWallets
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,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
}
@Test
fun `flow is empty if store returns empty flow`() = runTest {
// Arrange
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
every { userTokensResponseStore.get(params.userWalletId) } returns emptyFlow()
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
val expected = producer.fallback.getOrNull()
Truth.assertThat(actual.size).isEqualTo(1)
Truth.assertThat(actual.first()).isEqualTo(expected)
verifyOrder {
userWalletsListRepository.userWallets
userTokensResponseStore.get(params.userWalletId)
}
verify(inverse = true) {
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = any())
}
}
@Test
fun `produce throws exception if UserWallet isn't multi-currency wallet`() = runTest {
// Arrange
val mockUserWallet = mockk<UserWallet> {
every { walletId } returns userWallet.walletId
every { isMultiCurrency } returns false
}
val userWalletsFlow = MutableStateFlow(listOf(mockUserWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
// 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 { userWalletsListRepository.userWallets }
verify(inverse = true) {
userTokensResponseStore.get(any())
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = any())
}
}
private companion object {
val scanResponse = MockScanResponseFactory.create(
cardConfig = GenericCardConfig(2),
derivedKeys = emptyMap(),
)
val userWallet = MockUserWalletFactory.create(scanResponse = scanResponse)
}
}

View file

@ -1,14 +1,12 @@
package com.tangem.data.common.currency
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.tokens.UserTokensBackwardCompatibility
import com.tangem.data.common.wallet.WalletServerBinder
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.isNetworkError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncOrNull
import com.tangem.domain.models.wallet.UserWallet
@ -23,31 +21,11 @@ import timber.log.Timber
class UserTokensSaver(
private val tangemTechApi: TangemTechApi,
private val userWalletsListRepository: UserWalletsListRepository,
private val userTokensResponseStore: UserTokensResponseStore,
private val dispatchers: CoroutineDispatcherProvider,
private val addressesEnricher: UserTokensResponseAddressesEnricher,
private val walletServerBinder: WalletServerBinder,
private val pushTokensRetryerPool: RetryerPool,
) {
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
suspend fun storeAndPush(userWalletId: UserWalletId, response: UserTokensResponse) {
withContext(dispatchers.default) {
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = true)
store(userWalletId = userWalletId, response = enrichedResponse, useEnricher = false)
push(userWalletId = userWalletId, response = enrichedResponse, useEnricher = false)
}
}
suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse, useEnricher: Boolean = true) =
withContext(dispatchers.default) {
val updatedResponse = response
.applyCompatibility()
.enrichIf(userWalletId = userWalletId, condition = useEnricher)
userTokensResponseStore.store(userWalletId = userWalletId, response = updatedResponse)
}
suspend fun push(
userWalletId: UserWalletId,
@ -111,10 +89,6 @@ class UserTokensSaver(
)
}
private fun UserTokensResponse.applyCompatibility(): UserTokensResponse {
return userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(userTokensResponse = this)
}
private suspend fun UserTokensResponse.enrichIf(
userWalletId: UserWalletId,
condition: Boolean,

View file

@ -12,7 +12,6 @@ import com.tangem.data.common.wallet.WalletServerBinder
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -67,7 +66,6 @@ internal object DataCommonModule {
fun provideUserTokensSaver(
tangemTechApi: TangemTechApi,
userWalletsListRepository: UserWalletsListRepository,
userTokensResponseStore: UserTokensResponseStore,
dispatchers: CoroutineDispatcherProvider,
addressesEnricher: UserTokensResponseAddressesEnricher,
walletServerBinder: WalletServerBinder,
@ -75,7 +73,6 @@ internal object DataCommonModule {
return UserTokensSaver(
tangemTechApi = tangemTechApi,
userWalletsListRepository = userWalletsListRepository,
userTokensResponseStore = userTokensResponseStore,
dispatchers = dispatchers,
addressesEnricher = addressesEnricher,
pushTokensRetryerPool = RetryerPool(

View file

@ -6,7 +6,6 @@ import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.WalletType
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -23,14 +22,12 @@ class UserTokensSaverTest {
private val tangemTechApi: TangemTechApi = mockk()
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxUnitFun = true)
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxed = true)
private val enricher: UserTokensResponseAddressesEnricher = mockk()
private val walletServerBinder: WalletServerBinder = mockk()
private val userTokensSaver: UserTokensSaver = UserTokensSaver(
tangemTechApi = tangemTechApi,
userWalletsListRepository = userWalletsListRepository,
userTokensResponseStore = userTokensResponseStore,
dispatchers = TestingCoroutineDispatcherProvider(),
addressesEnricher = enricher,
walletServerBinder = walletServerBinder,
@ -42,46 +39,11 @@ class UserTokensSaverTest {
clearMocks(
tangemTechApi,
userWalletsListRepository,
userTokensResponseStore,
enricher,
walletServerBinder,
)
}
@Test
fun `GIVEN user wallet id and response WHEN store THEN should store enriched response`() = runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val response = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
val enrichedResponse = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.MANUAL,
tokens = emptyList(),
)
coEvery { enricher(userWalletId, response) } returns enrichedResponse
// WHEN
userTokensSaver.store(userWalletId, response)
// THEN
coVerifyOrder {
enricher(userWalletId, response)
userTokensResponseStore.store(userWalletId, enrichedResponse)
}
coVerify(inverse = true) {
tangemTechApi.saveTokens(any(), any())
}
}
@Test
fun `GIVEN user wallet id and response WHEN push AND api call fails THEN should log error and call onFailSend`() =
runTest {
@ -133,49 +95,4 @@ class UserTokensSaverTest {
assert(onFailSendCalled) { "onFailSend callback should be called when API call fails" }
}
@Test
fun `GIVEN user wallet id and response WHEN storeAndPush THEN should store and push enriched response`() = runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val userWallet = mockk<UserWallet.Cold> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.name } returns "Wallet"
}
val response = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
walletName = userWallet.name,
walletType = WalletType.COLD,
)
val enrichedResponse = UserTokensResponse(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
walletName = userWallet.name,
walletType = WalletType.COLD,
)
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
coEvery { enricher(userWalletId, response) } returns enrichedResponse
coEvery {
tangemTechApi.saveTokens(userWalletId.stringValue, enrichedResponse)
} returns ApiResponse.Success(Unit)
// WHEN
userTokensSaver.storeAndPush(userWalletId, response)
// THEN
coVerifyOrder {
enricher(userWalletId, response)
userWalletsListRepository.userWallets
tangemTechApi.saveTokens(userWalletId.stringValue, enrichedResponse)
}
}
}