From 50ca72fdef932f1f991132b363e25e2547ee57da Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 10:22:51 +0200 Subject: [PATCH] Updated on 2026-08-14 --- domain/search/build.gradle.kts | 3 + .../search/usecase/GetSearchResultsUseCase.kt | 37 +- .../usecase/GetSearchResultsUseCaseTest.kt | 449 ++++++++++++++++++ 3 files changed, 474 insertions(+), 15 deletions(-) create mode 100644 domain/search/src/test/kotlin/com/tangem/domain/search/usecase/GetSearchResultsUseCaseTest.kt diff --git a/domain/search/build.gradle.kts b/domain/search/build.gradle.kts index e13e688d24..8c9eced03c 100644 --- a/domain/search/build.gradle.kts +++ b/domain/search/build.gradle.kts @@ -17,4 +17,7 @@ dependencies { implementation(projects.domain.appCurrency) implementation(projects.domain.account) implementation(projects.domain.account.status) + + testImplementation(projects.common.test) + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt b/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt index ba5da04383..c84ebc3d44 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt +++ b/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt @@ -4,11 +4,11 @@ import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.models.portfolio.UserAssetEntry import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.search.model.SearchResult -import com.tangem.domain.models.portfolio.UserAssetEntry import com.tangem.domain.search.model.UserAssetSearchItem import com.tangem.domain.search.repository.SearchRepository import kotlinx.coroutines.flow.Flow @@ -101,7 +101,7 @@ class GetSearchResultsUseCase( if (!shouldGroup) { return entries .map { UserAssetSearchItem.Single(it) } - .sortedByDescending { it.entry.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + .sortedByDescending { it.fiatBalance() } } val grouped = entries.groupBy { entry -> @@ -109,20 +109,27 @@ class GetSearchResultsUseCase( rawId?.value ?: "${entry.currencyStatus.currency.name}|${entry.currencyStatus.currency.symbol}" } - return grouped.map { (_, groupEntries) -> - val assetInfo = groupEntries.first() - UserAssetSearchItem.Grouped( - tokenName = assetInfo.currencyStatus.currency.name, - tokenSymbol = assetInfo.currencyStatus.currency.symbol, - tokenIconUrl = assetInfo.currencyStatus.currency.iconUrl, - entries = groupEntries, - ) - }.sortedByDescending { item -> - when (item) { - is UserAssetSearchItem.Grouped -> - item.entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + return grouped + .map { (_, groupEntries) -> + // Only assets with several matching instances are merged; a single instance stays Single. + if (groupEntries.size > 1) { + val assetInfo = groupEntries.first() + UserAssetSearchItem.Grouped( + tokenName = assetInfo.currencyStatus.currency.name, + tokenSymbol = assetInfo.currencyStatus.currency.symbol, + tokenIconUrl = assetInfo.currencyStatus.currency.iconUrl, + entries = groupEntries, + ) + } else { + UserAssetSearchItem.Single(groupEntries.first()) + } } - } + .sortedByDescending { it.fiatBalance() } + } + + private fun UserAssetSearchItem.fiatBalance(): BigDecimal = when (this) { + is UserAssetSearchItem.Single -> entry.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO + is UserAssetSearchItem.Grouped -> entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } } private fun extractMatchingAssets( diff --git a/domain/search/src/test/kotlin/com/tangem/domain/search/usecase/GetSearchResultsUseCaseTest.kt b/domain/search/src/test/kotlin/com/tangem/domain/search/usecase/GetSearchResultsUseCaseTest.kt new file mode 100644 index 0000000000..79f70a2314 --- /dev/null +++ b/domain/search/src/test/kotlin/com/tangem/domain/search/usecase/GetSearchResultsUseCaseTest.kt @@ -0,0 +1,449 @@ +package com.tangem.domain.search.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.* +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.portfolio.UserAssetEntry +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.search.model.RecentSearchToken +import com.tangem.domain.search.model.SearchResult +import com.tangem.domain.search.model.SearchTextHint +import com.tangem.domain.search.model.UserAssetSearchItem +import com.tangem.domain.search.repository.SearchRepository +import com.tangem.test.core.getEmittedValues +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class GetSearchResultsUseCaseTest { + + private val searchRepository = mockk() + private val multiAccountStatusListSupplier = mockk() + private val userWalletsListRepository = mockk() + + private val useCase = GetSearchResultsUseCase( + searchRepository = searchRepository, + multiAccountStatusListSupplier = multiAccountStatusListSupplier, + userWalletsListRepository = userWalletsListRepository, + ) + + private val factory = MockCryptoCurrencyFactory() + private val cardano = factory.cardano + private val chia = factory.chia + private val ethereum = factory.ethereum + + private val walletId1 = UserWalletId("011") + private val walletId2 = UserWalletId("022") + + @BeforeEach + fun setUp() { + clearMocks(searchRepository, multiAccountStatusListSupplier, userWalletsListRepository) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class History { + + @Test + fun `GIVEN blank query WHEN invoke THEN returns history with empty user assets`() = runTest { + // Arrange + val hints = listOf(SearchTextHint(text = "eth", timestamp = 1L)) + val tokens = listOf() + every { searchRepository.getTextHints() } returns flowOf(hints) + every { searchRepository.getRecentTokens() } returns flowOf(tokens) + + // Act + val actual = getEmittedValues(useCase(query = " ")) + + // Assert + val expected = SearchResult(textHints = hints, recentTokens = tokens, userAssets = emptyList()) + assertThat(actual).containsExactly(expected) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class NoGrouping { + + @Test + fun `GIVEN single wallet single account WHEN search THEN items are Single sorted by fiat desc`() = runTest { + // Arrange + val cardanoStatus = statusOf(cardano, fiatAmount = BigDecimal(10)) + val chiaStatus = statusOf(chia, fiatAmount = BigDecimal(20)) + val mainAccount = mainAccount(walletId1) + val wallet = wallet(walletId1) + + stub( + wallets = listOf(wallet), + statusLists = listOf( + statusList(walletId1, cryptoPortfolio(mainAccount, listOf(cardanoStatus, chiaStatus))), + ), + ) + + // Act + val actual = search(query = "c") + + // Assert — single wallet + single account: nothing is grouped, sorted by fiat desc + assertThat(actual).containsExactly( + UserAssetSearchItem.Single(entry(wallet, mainAccount, chiaStatus)), + UserAssetSearchItem.Single(entry(wallet, mainAccount, cardanoStatus)), + ).inOrder() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Grouping { + + @Test + fun `GIVEN several wallets but each asset appears once WHEN search THEN every asset stays Single`() = runTest { + // Arrange — the regression: a unique asset must not become a Grouped item just because several wallets exist + val cardanoStatus = statusOf(cardano, fiatAmount = BigDecimal(10)) + val chiaStatus = statusOf(chia, fiatAmount = BigDecimal(20)) + val mainAccount1 = mainAccount(walletId1) + val mainAccount2 = mainAccount(walletId2) + val firstWallet = wallet(walletId1) + val secondWallet = wallet(walletId2) + + stub( + wallets = listOf(firstWallet, secondWallet), + statusLists = listOf( + statusList(walletId1, cryptoPortfolio(mainAccount1, listOf(cardanoStatus))), + statusList(walletId2, cryptoPortfolio(mainAccount2, listOf(chiaStatus))), + ), + ) + + // Act + val actual = search(query = "c") + + // Assert + assertThat(actual).containsExactly( + UserAssetSearchItem.Single(entry(secondWallet, mainAccount2, chiaStatus)), + UserAssetSearchItem.Single(entry(firstWallet, mainAccount1, cardanoStatus)), + ).inOrder() + } + + @Test + fun `GIVEN same currency in two wallets WHEN search THEN it is Grouped and unique asset stays Single`() = + runTest { + // Arrange + val chiaStatusW1 = statusOf(chia, fiatAmount = BigDecimal(15)) + val chiaStatusW2 = statusOf(chia, fiatAmount = BigDecimal(5)) + val cardanoStatus = statusOf(cardano, fiatAmount = BigDecimal(30)) + val mainAccount1 = mainAccount(walletId1) + val mainAccount2 = mainAccount(walletId2) + val firstWallet = wallet(walletId1) + val secondWallet = wallet(walletId2) + + stub( + wallets = listOf(firstWallet, secondWallet), + statusLists = listOf( + statusList(walletId1, cryptoPortfolio(mainAccount1, listOf(chiaStatusW1, cardanoStatus))), + statusList(walletId2, cryptoPortfolio(mainAccount2, listOf(chiaStatusW2))), + ), + ) + + // Act + val actual = search(query = "c") + + // Assert — cardano (30) outranks the chia group (15 + 5 = 20) + assertThat(actual).containsExactly( + UserAssetSearchItem.Single(entry(firstWallet, mainAccount1, cardanoStatus)), + UserAssetSearchItem.Grouped( + tokenName = chia.name, + tokenSymbol = chia.symbol, + tokenIconUrl = chia.iconUrl, + entries = listOf( + entry(firstWallet, mainAccount1, chiaStatusW1), + entry(secondWallet, mainAccount2, chiaStatusW2), + ), + ), + ).inOrder() + } + + @Test + fun `GIVEN single wallet with several accounts WHEN same currency in two accounts THEN it is Grouped`() = + runTest { + // Arrange + val chiaStatusMain = statusOf(chia, fiatAmount = BigDecimal(15)) + val chiaStatusSecondary = statusOf(chia, fiatAmount = BigDecimal(5)) + val cardanoStatus = statusOf(cardano, fiatAmount = BigDecimal(30)) + val mainAccount = mainAccount(walletId1) + val secondaryAccount = secondaryAccount(walletId1, derivationIndex = 1) + val wallet = wallet(walletId1) + + stub( + wallets = listOf(wallet), + statusLists = listOf( + statusList( + walletId = walletId1, + cryptoPortfolio(mainAccount, listOf(chiaStatusMain, cardanoStatus)), + cryptoPortfolio(secondaryAccount, listOf(chiaStatusSecondary)), + ), + ), + ) + + // Act — single wallet, but more than one account enables aggregation + val actual = search(query = "c") + + // Assert + assertThat(actual).containsExactly( + UserAssetSearchItem.Single(entry(wallet, mainAccount, cardanoStatus)), + UserAssetSearchItem.Grouped( + tokenName = chia.name, + tokenSymbol = chia.symbol, + tokenIconUrl = chia.iconUrl, + entries = listOf( + entry(wallet, mainAccount, chiaStatusMain), + entry(wallet, secondaryAccount, chiaStatusSecondary), + ), + ), + ).inOrder() + } + + @Test + fun `GIVEN custom token without backend id in two wallets WHEN search THEN grouped by name and symbol`() = + runTest { + // Arrange — a custom token has no rawCurrencyId, so the merge falls back to name + symbol + val customToken = customToken(name = "MyToken", symbol = "MTK") + val statusW1 = statusOf(customToken, fiatAmount = BigDecimal(7)) + val statusW2 = statusOf(customToken, fiatAmount = BigDecimal(3)) + val mainAccount1 = mainAccount(walletId1) + val mainAccount2 = mainAccount(walletId2) + val firstWallet = wallet(walletId1) + val secondWallet = wallet(walletId2) + + stub( + wallets = listOf(firstWallet, secondWallet), + statusLists = listOf( + statusList(walletId1, cryptoPortfolio(mainAccount1, listOf(statusW1))), + statusList(walletId2, cryptoPortfolio(mainAccount2, listOf(statusW2))), + ), + ) + + // Act + val actual = search(query = "myto") + + // Assert + assertThat(actual).containsExactly( + UserAssetSearchItem.Grouped( + tokenName = "MyToken", + tokenSymbol = "MTK", + tokenIconUrl = customToken.iconUrl, + entries = listOf( + entry(firstWallet, mainAccount1, statusW1), + entry(secondWallet, mainAccount2, statusW2), + ), + ), + ) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class WalletFiltering { + + @Test + fun `GIVEN locked wallet WHEN search THEN its assets are excluded`() = runTest { + // Arrange + val cardanoStatus = statusOf(cardano, fiatAmount = BigDecimal(10)) + val chiaStatus = statusOf(chia, fiatAmount = BigDecimal(20)) + val mainAccount1 = mainAccount(walletId1) + val mainAccount2 = mainAccount(walletId2) + val unlockedWallet = wallet(walletId1, locked = false) + val lockedWallet = wallet(walletId2, locked = true) + + stub( + wallets = listOf(unlockedWallet, lockedWallet), + statusLists = listOf( + statusList(walletId1, cryptoPortfolio(mainAccount1, listOf(cardanoStatus))), + statusList(walletId2, cryptoPortfolio(mainAccount2, listOf(chiaStatus))), + ), + ) + + // Act + val actual = search(query = "c") + + // Assert — only the single unlocked wallet remains, so no grouping and chia is gone + assertThat(actual).containsExactly( + UserAssetSearchItem.Single(entry(unlockedWallet, mainAccount1, cardanoStatus)), + ) + } + + @Test + fun `GIVEN all wallets locked WHEN search THEN user assets are empty`() = runTest { + // Arrange + val cardanoStatus = statusOf(cardano, fiatAmount = BigDecimal(10)) + stub( + wallets = listOf(wallet(walletId1, locked = true)), + statusLists = listOf( + statusList(walletId1, cryptoPortfolio(mainAccount(walletId1), listOf(cardanoStatus))), + ), + ) + + // Act + val actual = search(query = "c") + + // Assert + assertThat(actual).isEmpty() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Matching { + + @Test + fun `GIVEN upper case query matching symbol WHEN search THEN match is case insensitive`() = runTest { + // Arrange + val ethereumStatus = statusOf(ethereum, fiatAmount = BigDecimal(10)) + val mainAccount = mainAccount(walletId1) + val wallet = wallet(walletId1) + stub( + wallets = listOf(wallet), + statusLists = listOf(statusList(walletId1, cryptoPortfolio(mainAccount, listOf(ethereumStatus)))), + ) + + // Act + val actual = search(query = "ETH") + + // Assert + assertThat(actual).containsExactly( + UserAssetSearchItem.Single(entry(wallet, mainAccount, ethereumStatus)), + ) + } + + @Test + fun `GIVEN query matching nothing WHEN search THEN user assets are empty`() = runTest { + // Arrange + val cardanoStatus = statusOf(cardano, fiatAmount = BigDecimal(10)) + val mainAccount = mainAccount(walletId1) + stub( + wallets = listOf(wallet(walletId1)), + statusLists = listOf(statusList(walletId1, cryptoPortfolio(mainAccount, listOf(cardanoStatus)))), + ) + + // Act + val actual = search(query = "zzz") + + // Assert + assertThat(actual).isEmpty() + } + } + + // region helpers + + private fun stub(wallets: List, statusLists: List) { + every { multiAccountStatusListSupplier() } returns flowOf(statusLists) + every { userWalletsListRepository.userWallets } returns MutableStateFlow(wallets) + } + + private fun TestScope.search(query: String): List = + getEmittedValues(useCase(query)).last().userAssets + + private fun wallet(id: UserWalletId, locked: Boolean = false): UserWallet = mockk { + every { walletId } returns id + every { name } returns "Wallet ${id.stringValue}" + every { isLocked } returns locked + } + + private fun mainAccount(walletId: UserWalletId): Account.CryptoPortfolio = + Account.CryptoPortfolio.createMainAccount(userWalletId = walletId) + + private fun secondaryAccount(walletId: UserWalletId, derivationIndex: Int): Account.CryptoPortfolio { + val index = DerivationIndex(derivationIndex).getOrNull()!! + return Account.CryptoPortfolio( + accountId = AccountId.forCryptoPortfolio(userWalletId = walletId, derivationIndex = index), + accountName = AccountName("Account #$derivationIndex").getOrNull()!!, + icon = CryptoPortfolioIcon.ofDefaultCustomAccount(), + derivationIndex = index, + cryptoCurrencies = emptyList(), + ) + } + + private fun statusOf(currency: CryptoCurrency, fiatAmount: BigDecimal?): CryptoCurrencyStatus { + val statusValue = mockk { + every { this@mockk.fiatAmount } returns fiatAmount + } + return CryptoCurrencyStatus(currency = currency, value = statusValue) + } + + private fun cryptoPortfolio( + account: Account.CryptoPortfolio, + statuses: List, + ): AccountStatus.CryptoPortfolio = AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = statuses, + ), + priceChangeLce = lceLoading(), + ) + + private fun statusList(walletId: UserWalletId, vararg accounts: AccountStatus.CryptoPortfolio): AccountStatusList = + AccountStatusList( + userWalletId = walletId, + accountStatuses = accounts.toList(), + totalAccounts = accounts.size, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + + private fun entry( + wallet: UserWallet, + account: Account.CryptoPortfolio, + status: CryptoCurrencyStatus, + ): UserAssetEntry = UserAssetEntry( + userWalletId = wallet.walletId, + userWalletName = wallet.name, + accountId = account.accountId, + accountName = account.accountName, + accountIcon = account.icon, + currencyStatus = status, + ) + + private fun customToken(name: String, symbol: String): CryptoCurrency.Token { + val network = ethereum.network + val contractAddress = "0xCUSTOM_$symbol" + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawId = "custom-network"), + suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = contractAddress), + ), + network = network, + name = name, + symbol = symbol, + decimals = 8, + iconUrl = null, + isCustom = true, + contractAddress = contractAddress, + ) + } + + // endregion +} \ No newline at end of file