diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt index 41a9f6bc8f..a6b364259c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt @@ -21,12 +21,7 @@ inline fun catchApiResponseError(onError: (ApiResponseError) -> Unit, block: } } -/** - * Checks if the [ApiResponseError] is a network-related error. - * You can provide a custom predicate [codePredicate] to check for specific HTTP status codes. - */ -inline fun ApiResponseError.isNetworkError( - codePredicate: (ApiResponseError.HttpException.Code) -> Boolean = { true }, -): Boolean { - return this is ApiResponseError.HttpException && codePredicate(this.code) +/** Checks if the ApiResponseError is a network error with the specified HTTP status [code] */ +fun ApiResponseError.isNetworkError(code: ApiResponseError.HttpException.Code): Boolean { + return this is ApiResponseError.HttpException && this.code == code } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt index 6c3afd812f..ac0ce437eb 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/account/GetWalletAccountsResponse.kt @@ -15,7 +15,7 @@ data class GetWalletAccountsResponse( @JsonClass(generateAdapter = true) data class Wallet( - @Json(name = "version") val version: Int, + @Json(name = "version") val version: Int = 0, @Json(name = "group") val group: GroupType, @Json(name = "sort") val sort: SortType, @Json(name = "totalAccounts") val totalAccounts: Int, diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt new file mode 100644 index 0000000000..7142520d72 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -0,0 +1,141 @@ +package com.tangem.data.account.fetcher + +import com.tangem.data.account.converter.CryptoPortfolioConverter +import com.tangem.data.account.utils.assignTokens +import com.tangem.data.account.utils.toUserTokensResponse +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.common.response.isNetworkError +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.local.token.UserTokensResponseStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.Provider +import timber.log.Timber +import javax.inject.Inject + +/** + * Handles errors that occur during the fetching of wallet accounts + * + * @property userTokensSaver saves user tokens to the storage + * @property userWalletsStore provides access to user wallet data + * @property userTokensResponseStore provides access to user token responses. + * @property cryptoPortfolioCF factory for converting crypto portfolios + * @property userTokensResponseFactory factory for creating user token responses + * @property cardCryptoCurrencyFactory factory for creating default cryptocurrencies for multi-currency wallets + * + * @see DefaultWalletAccountsFetcher + * +[REDACTED_AUTHOR] + */ +internal class FetchWalletAccountsErrorHandler @Inject constructor( + private val userTokensSaver: UserTokensSaver, + private val userWalletsStore: UserWalletsStore, + private val userTokensResponseStore: UserTokensResponseStore, + private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory, + private val userTokensResponseFactory: UserTokensResponseFactory, + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, +) { + + /** + * Handles the error that occurred during the fetching of wallet accounts. + * [pushWalletAccounts] and [storeWalletAccounts] are functions that passed as parameters to avoid + * cyclic dependencies. + * + * @param error the error that occurred + * @param userWalletId the ID of the user wallet + * @param savedAccountsResponse the previously saved wallet accounts response, if available + * @param pushWalletAccounts function to push wallet accounts to the server + * @param storeWalletAccounts function to store wallet accounts locally + */ + suspend fun handle( + error: ApiResponseError, + userWalletId: UserWalletId, + savedAccountsResponse: GetWalletAccountsResponse?, + pushWalletAccounts: suspend (userWalletId: UserWalletId, accounts: List) -> Unit, + storeWalletAccounts: suspend (userWalletId: UserWalletId, response: GetWalletAccountsResponse) -> Unit, + ) { + val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED) + if (isResponseUpToDate) { + Timber.e("ETag is up to date, no need to update accounts for wallet: $userWalletId") + return + } + + val userWalletProvider = Provider { userWalletsStore.getSyncStrict(key = userWalletId) } + val accountDTOs = savedAccountsResponse?.accounts.orDefault(userWalletProvider = userWalletProvider) + + val userTokensResponse = savedAccountsResponse?.toUserTokensResponse() + .orFromLegacyStore(userWalletProvider = userWalletProvider) + .orDefault(userWalletProvider = userWalletProvider) + + val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND) + if (isNotFoundError) { + pushWalletAccounts(userWalletId, accountDTOs) + userTokensSaver.push(userWalletId = userWalletId, response = userTokensResponse) + } + + val response = savedAccountsResponse.orDefault(userWalletId, accountDTOs, userTokensResponse) + storeWalletAccounts(userWalletId, response) + } + + private fun List?.orDefault(userWalletProvider: Provider): List { + if (this != null) return this + + val userWallet = userWalletProvider() + + val accounts = AccountList.empty(userWallet).accounts + .filterIsInstance() + + val converter = cryptoPortfolioCF.create(userWallet = userWallet) + + return converter.convertListBack(input = accounts) + } + + private suspend fun UserTokensResponse?.orFromLegacyStore( + userWalletProvider: Provider, + ): UserTokensResponse? { + if (this != null) return this + + return userTokensResponseStore.getSyncOrNull( + userWalletId = userWalletProvider().walletId, + ) + } + + private fun UserTokensResponse?.orDefault(userWalletProvider: Provider): UserTokensResponse { + if (this != null) return this + + return userTokensResponseFactory.createUserTokensResponse( + currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet( + userWallet = userWalletProvider(), + ), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } + + private fun GetWalletAccountsResponse?.orDefault( + userWalletId: UserWalletId, + accountDTOs: List, + userTokensResponse: UserTokensResponse, + ): GetWalletAccountsResponse { + if (this != null) return this + + return GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = userTokensResponse.group, + sort = userTokensResponse.sort, + totalAccounts = accountDTOs.size, + ), + accounts = accountDTOs.assignTokens(userWalletId = userWalletId, tokens = userTokensResponse.tokens), + unassignedTokens = emptyList(), + ) + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt b/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt new file mode 100644 index 0000000000..caace442d6 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/utils/GetWalletAccountsResponseExt.kt @@ -0,0 +1,57 @@ +package com.tangem.data.account.utils + +import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.wallet.UserWalletId + +/** Flattens the tokens from all wallet accounts into a single list */ +internal fun GetWalletAccountsResponse.flattenTokens(): List { + return accounts.flatMap { it.tokens.orEmpty() } +} + +/** Converts the [GetWalletAccountsResponse] into a [UserTokensResponse] */ +internal fun GetWalletAccountsResponse.toUserTokensResponse(): UserTokensResponse { + return UserTokensResponse( + group = wallet.group, + sort = wallet.sort, + tokens = flattenTokens(), + ) +} + +/** + * Assigns tokens from a [UserTokensResponse] to the wallet accounts in the [GetWalletAccountsResponse] + * + * @param userWalletId the ID of the user wallet + * + * @return a new [GetWalletAccountsResponse]` with tokens assigned to the wallet accounts + */ +internal fun GetWalletAccountsResponse.assignTokens(userWalletId: UserWalletId): GetWalletAccountsResponse { + return copy( + accounts = accounts.assignTokens(userWalletId = userWalletId, tokens = unassignedTokens), + unassignedTokens = emptyList(), + ) +} + +/** + * Assigns tokens from a [UserTokensResponse] to a list of wallet accounts + * + * @param userWalletId the ID of the user wallet + * @param tokens tokens to be assigned + * + * @return a new list of [WalletAccountDTO] with tokens assigned to each account + */ +internal fun List.assignTokens( + userWalletId: UserWalletId, + tokens: List, +): List { + val enrichedTokens = UserTokensResponseAccountIdEnricher(userWalletId, tokens) + .groupBy { it.accountId } + + return map { accountDTO -> + accountDTO.copy( + tokens = enrichedTokens[accountDTO.id].orEmpty(), + ) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt new file mode 100644 index 0000000000..5d41955371 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt @@ -0,0 +1,239 @@ +package com.tangem.data.account.fetcher + +import com.tangem.data.account.converter.CryptoPortfolioConverter +import com.tangem.data.account.utils.toUserTokensResponse +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.data.common.currency.UserTokensResponseFactory +import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.datasource.local.token.UserTokensResponseStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +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) +class FetchWalletAccountsErrorHandlerTest { + + private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) + private val userWalletsStore: UserWalletsStore = mockk() + private val userTokensResponseStore: UserTokensResponseStore = mockk() + private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory = mockk() + private val cryptoPortfolioConverter = mockk() + private val userTokensResponseFactory: UserTokensResponseFactory = mockk() + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() + + private val handler = FetchWalletAccountsErrorHandler( + userTokensSaver = userTokensSaver, + userWalletsStore = userWalletsStore, + userTokensResponseStore = userTokensResponseStore, + cryptoPortfolioCF = cryptoPortfolioCF, + userTokensResponseFactory = userTokensResponseFactory, + cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + ) + + private val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + @BeforeEach + fun setupEach() { + clearMocks( + userTokensSaver, + userWalletsStore, + userTokensResponseStore, + cryptoPortfolioCF, + cryptoPortfolioConverter, + cardCryptoCurrencyFactory, + ) + } + + @Test + fun `does not update accounts when response is up to date`() = runTest { + // Arrange + val error = ApiResponseError.HttpException( + code = Code.NOT_MODIFIED, + message = "Not Modified", + errorBody = null, + ) + + val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk() + val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk() + + // Act + handler.handle( + error = error, + userWalletId = userWalletId, + savedAccountsResponse = null, + pushWalletAccounts = pushWalletAccounts, + storeWalletAccounts = storeWalletAccounts, + ) + + // Assert + coVerify(inverse = true) { + userWalletsStore.getSyncStrict(key = any()) + userTokensResponseStore.getSyncOrNull(userWalletId = any()) + userTokensResponseFactory.createUserTokensResponse(any(), any(), any()) + cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any()) + cryptoPortfolioCF.create(any()) + cryptoPortfolioConverter.convertListBack(any()) + pushWalletAccounts(any(), any()) + userTokensSaver.push(userWalletId = any(), response = any()) + storeWalletAccounts(any(), any()) + } + } + + @Test + fun `pushes and stores accounts when NOT_FOUND error occurs`() = runTest { + // Arrange + val error = ApiResponseError.HttpException( + code = Code.NOT_FOUND, + message = "Not Found", + errorBody = null, + ) + + val accountDTO = WalletAccountDTO( + id = "nibh", + name = "Michael Dotson", + derivationIndex = 7135, + icon = "consectetuer", + iconColor = "ferri", + tokens = listOf(), + totalTokens = 7738, + totalNetworks = 3348, + ) + + val savedAccountsResponse = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 1, + ), + accounts = listOf(accountDTO), + unassignedTokens = emptyList(), + ) + + val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk(relaxed = true) + val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true) + + // Act + handler.handle( + error = error, + userWalletId = userWalletId, + savedAccountsResponse = savedAccountsResponse, + pushWalletAccounts = pushWalletAccounts, + storeWalletAccounts = storeWalletAccounts, + ) + + // Assert + coVerify { + pushWalletAccounts(userWalletId, listOf(accountDTO)) + userTokensSaver.push(userWalletId, response = savedAccountsResponse.toUserTokensResponse()) + storeWalletAccounts(userWalletId, savedAccountsResponse) + } + + coVerify(inverse = true) { + userWalletsStore.getSyncStrict(key = any()) + userTokensResponseStore.getSyncOrNull(userWalletId = any()) + userTokensResponseFactory.createUserTokensResponse(any(), any(), any()) + cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any()) + cryptoPortfolioCF.create(any()) + cryptoPortfolioConverter.convertListBack(any()) + } + } + + @Test + fun `uses default accounts when savedAccountsResponse is null`() = runTest { + // Arrange + val error = ApiResponseError.TimeoutException + + val accounts = AccountList.empty(userWallet).accounts + .filterIsInstance() + + val accountDTO = WalletAccountDTO( + id = "nibh", + name = "Michael Dotson", + derivationIndex = 7135, + icon = "consectetuer", + iconColor = "ferri", + tokens = listOf(), + totalTokens = 7738, + totalNetworks = 3348, + ) + + val savedAccountsResponse = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 1, + ), + accounts = listOf(accountDTO), + unassignedTokens = emptyList(), + ) + + val userTokensResponse = savedAccountsResponse.toUserTokensResponse() + + every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet + every { cryptoPortfolioCF.create(userWallet) } returns cryptoPortfolioConverter + every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountDTO) + coEvery { userTokensResponseStore.getSyncOrNull(userWalletId) } returns null + every { + userTokensResponseFactory.createUserTokensResponse( + currencies = emptyList(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } returns userTokensResponse + every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList() + + val pushWalletAccounts: suspend (UserWalletId, List) -> Unit = mockk(relaxed = true) + val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true) + + // Act + handler.handle( + error = error, + userWalletId = userWalletId, + savedAccountsResponse = null, + pushWalletAccounts = pushWalletAccounts, + storeWalletAccounts = storeWalletAccounts, + ) + + // Assert + coVerify { + userWalletsStore.getSyncStrict(userWalletId) + cryptoPortfolioCF.create(userWallet) + cryptoPortfolioConverter.convertListBack(accounts) + userTokensResponseStore.getSyncOrNull(userWalletId) + userTokensResponseFactory.createUserTokensResponse( + currencies = emptyList(), + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) + storeWalletAccounts(userWalletId, any()) + } + + coVerify(inverse = true) { + pushWalletAccounts(any(), any()) + userTokensSaver.push(userWalletId = any(), response = any()) + } + } + + private companion object { + + val userWalletId = UserWalletId("011") + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt b/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt new file mode 100644 index 0000000000..8f9089305d --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/utils/GetWalletAccountsResponseExtTest.kt @@ -0,0 +1,324 @@ +package com.tangem.data.account.utils + +import com.google.common.truth.Truth +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GetWalletAccountsResponseExtTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class FlattenTokens { + + @Test + fun `flattenTokens returns empty list when accounts are empty`() { + // Arrange + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 0, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.flattenTokens() + + // Assert + Truth.assertThat(actual).isEmpty() + } + + @Test + fun `flattenTokens returns empty list when single account has empty tokens`() { + // Arrange + val account = createWalletAccountDTO(derivationIndex = 0) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 1, + ), + accounts = listOf(account), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.flattenTokens() + + // Assert + Truth.assertThat(actual).isEmpty() + } + + @Test + fun `flattenTokens returns all tokens from multiple accounts`() { + // Arrange + val token1 = createUserToken(id = "0") + val token2 = createUserToken(id = "1") + val token3 = createUserToken(id = "2") + + val account1 = createWalletAccountDTO(derivationIndex = 0, tokens = listOf(token1, token2)) + val account2 = createWalletAccountDTO(derivationIndex = 1, tokens = listOf(token3)) + val account3 = createWalletAccountDTO(derivationIndex = 2) + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 2, + ), + accounts = listOf(account1, account2, account3), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.flattenTokens() + + // Assert + Truth.assertThat(actual).containsExactly(token1, token2, token3) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ToUserTokensResponse { + + @Test + fun `toUserTokensResponse returns correct UserTokensResponse for empty accounts and unassignedTokens`() { + // Arrange + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 0, + ), + accounts = emptyList(), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.toUserTokensResponse() + + // Assert + val expected = UserTokensResponse( + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + tokens = emptyList(), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `toUserTokensResponse includes tokens from accounts and unassignedTokens`() { + // Arrange + val token1 = createUserToken(id = "0") + val token2 = createUserToken(id = "1") + val account = createWalletAccountDTO(derivationIndex = 0, tokens = listOf(token1)) + val unassignedToken = token2 + + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + totalAccounts = 1, + ), + accounts = listOf(account), + unassignedTokens = listOf(unassignedToken), + ) + + // Act + val actual = response.toUserTokensResponse() + + // Assert + val expected = UserTokensResponse( + group = UserTokensResponse.GroupType.NETWORK, + sort = UserTokensResponse.SortType.BALANCE, + tokens = listOf(token1), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetWalletAccountsResponseAssignTokens { + + @Test + fun `assignTokens correctly assigns tokens to accounts`() { + // Arrange + val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" + val token1 = createUserToken(id = "0", accountId = null) + val token2 = createUserToken(id = "1", accountId = null) + val account1 = createWalletAccountDTO(derivationIndex = 0) + val account2 = createWalletAccountDTO(derivationIndex = 1) + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 2, + ), + accounts = listOf(account1, account2), + unassignedTokens = listOf(token1, token2), + ) + + // Act + val actual = response.assignTokens(userWalletId) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = response.wallet, + accounts = listOf( + account1.copy( + tokens = listOf( + token1.copy(accountId = accountId), + token2.copy(accountId = accountId), + ), + ), + account2, + ), + unassignedTokens = emptyList(), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `assignTokens does not change accounts if there are no unassignedTokens`() { + // Arrange + val account = createWalletAccountDTO(derivationIndex = 0) + val response = GetWalletAccountsResponse( + wallet = GetWalletAccountsResponse.Wallet( + version = 1, + group = UserTokensResponse.GroupType.NONE, + sort = UserTokensResponse.SortType.MANUAL, + totalAccounts = 1, + ), + accounts = listOf(account), + unassignedTokens = emptyList(), + ) + + // Act + val actual = response.assignTokens(userWalletId) + + // Assert + val expected = GetWalletAccountsResponse( + wallet = response.wallet, + accounts = listOf(account), + unassignedTokens = emptyList(), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class WalletAccountDTOListAssignTokens { + + @Test + fun `assignTokens correctly assigns tokens to accounts`() { + // Arrange + val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" + val token1 = createUserToken(id = "0", accountId = null) + val token2 = createUserToken(id = "1", accountId = null) + val account1 = createWalletAccountDTO(derivationIndex = 0) + val account2 = createWalletAccountDTO(derivationIndex = 1) + + // Act + val actual = listOf(account1, account2).assignTokens( + userWalletId = userWalletId, + tokens = listOf(token1, token2), + ) + + // Assert + val expected = listOf( + account1.copy( + tokens = listOf( + token1.copy(accountId = accountId), + token2.copy(accountId = accountId), + ), + ), + account2, + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `assignTokens does not change accounts if there are no unassignedTokens`() { + // Arrange + val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027" + val token1 = createUserToken(id = "0", accountId = accountId) + val account1 = createWalletAccountDTO(derivationIndex = 0) + + // Act + val actual = listOf(account1).assignTokens( + userWalletId = userWalletId, + tokens = listOf(token1), + ) + + // Assert + val expected = listOf( + account1.copy( + tokens = listOf( + token1.copy(accountId = accountId), + ), + ), + ) + + Truth.assertThat(actual).isEqualTo(expected) + } + } + + private fun createWalletAccountDTO(derivationIndex: Int, tokens: List = emptyList()) = + WalletAccountDTO( + id = AccountId.forCryptoPortfolio( + userWalletId = userWalletId, + derivationIndex = DerivationIndex(derivationIndex).getOrNull()!!, + ).value, + name = "Name #$derivationIndex", + derivationIndex = derivationIndex, + icon = "icon", + iconColor = "color", + tokens = tokens, + totalTokens = tokens.size, + totalNetworks = 1, + ) + + private fun createUserToken(id: String, accountId: String? = "account_id") = UserTokensResponse.Token( + id = id, + accountId = accountId, + networkId = "ethereum", + derivationPath = "m/44'/60'/0'/0/0", + name = "Token", + symbol = "T", + contractAddress = "0x$id", + decimals = 18, + ) + + private companion object { + + val userWalletId = UserWalletId("011") + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt index b52d3c3f86..f91e9f40f3 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseAccountIdEnricher.kt @@ -24,23 +24,36 @@ object UserTokensResponseAccountIdEnricher { * @param response the [UserTokensResponse] containing tokens to be enriched */ operator fun invoke(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse { - val hasUnassignedTokens = response.tokens.any { it.accountId == null } - if (!hasUnassignedTokens) return response + val enrichedTokens = invoke(userWalletId = userWalletId, tokens = response.tokens) - val enrichedTokens = response.tokens + return response.copy(tokens = enrichedTokens) + } + + /** + * Enriches the given list of tokens with accountId values + * + * @param userWalletId the ID of the user wallet + * @param tokens the list of tokens to be enriched + */ + operator fun invoke( + userWalletId: UserWalletId, + tokens: List, + ): List { + val hasUnassignedTokens = tokens.any { it.accountId == null } + if (!hasUnassignedTokens) return tokens + + val enrichedTokens = tokens .filter { it.accountId == null } .groupByAccountIndex() .mapKeysToAccountId(userWalletId) .mapToEnrichedTokens() - if (enrichedTokens.isEmpty()) return response + if (enrichedTokens.isEmpty()) return tokens - return response.copy( - tokens = response.tokens.map { token -> - val enrichedToken = enrichedTokens.find { it == token } - enrichedToken ?: token - }, - ) + return tokens.map { token -> + val enrichedToken = enrichedTokens.firstOrNull { it == token } + enrichedToken ?: token + } } private fun List.groupByAccountIndex(): Map> {