diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index efb58c1d8b..bb4a57959a 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -16,6 +16,7 @@ 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.common.CardTypesResolver import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.core.error.DataError import com.tangem.domain.demo.DemoConfig @@ -573,6 +574,10 @@ internal class DefaultCurrenciesRepository( ) } + override fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver { + return userWalletsStore.getSyncStrict(userWalletId).requireColdWallet().cardTypesResolver + } + private fun getMultiCurrencyWalletCurrencies(userWallet: UserWallet): Flow> { return getSavedUserTokensResponse(userWallet.walletId).map { storedTokens -> responseCryptoCurrenciesFactory.createCurrencies( diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/CustomTokensMerger.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/CustomTokensMerger.kt index 18895b5065..67d189f978 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/CustomTokensMerger.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/CustomTokensMerger.kt @@ -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 { + 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 }, ) } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/FoundTokenConverter.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/FoundTokenConverter.kt deleted file mode 100644 index 78ce164eb6..0000000000 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/FoundTokenConverter.kt +++ /dev/null @@ -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 { - - 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(), - ) - } -} \ No newline at end of file diff --git a/data/tokens/src/test/kotlin/com/tangem/data/tokens/utils/CustomTokensMergerTest.kt b/data/tokens/src/test/kotlin/com/tangem/data/tokens/utils/CustomTokensMergerTest.kt new file mode 100644 index 0000000000..bb4882036a --- /dev/null +++ b/data/tokens/src/test/kotlin/com/tangem/data/tokens/utils/CustomTokensMergerTest.kt @@ -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 + + 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, + ) + } + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt index bb43280673..63bb8891bd 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt @@ -1,5 +1,6 @@ package com.tangem.domain.tokens.repository +import com.tangem.domain.common.CardTypesResolver import com.tangem.domain.core.error.DataError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -8,7 +9,6 @@ import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow -import kotlin.jvm.Throws /** * Repository for everything related to the tokens of user wallet @@ -263,4 +263,7 @@ interface CurrenciesRepository { */ @Throws suspend fun syncTokens(userWalletId: UserWalletId) + + @Throws + fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt index cbf52f0709..475ea789ec 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt @@ -2,6 +2,7 @@ package com.tangem.domain.tokens.repository import arrow.core.Either import arrow.core.getOrElse +import com.tangem.domain.common.CardTypesResolver import com.tangem.domain.core.error.DataError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -166,4 +167,8 @@ internal class MockCurrenciesRepository( override suspend fun syncTokens(userWalletId: UserWalletId) { return Unit } + + override fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver { + error("No-op") + } } \ No newline at end of file