diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducer.kt index fc16964a1e..29a36b58cb 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducer.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducer.kt @@ -5,23 +5,28 @@ import arrow.core.some import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.* /** + * Produces a flow of [AccountStatusList] for multiple user wallets. + * + * @property params Parameters for the producer (currently unused). + * @property userWalletsListRepository Repository to get the list of user wallets. + * @property singleAccountStatusListSupplier Supplier to get the account status list for a single user wallet. + * @property dispatchers Coroutine dispatcher provider for managing threading. + * [REDACTED_AUTHOR] */ -// TODO: Finalize [REDACTED_JIRA] internal class DefaultMultiAccountStatusListProducer @AssistedInject constructor( @Assisted val params: Unit, private val userWalletsListRepository: UserWalletsListRepository, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val dispatchers: CoroutineDispatcherProvider, ) : MultiAccountStatusListProducer { override val fallback: Option> = emptyList().some() @@ -39,6 +44,7 @@ internal class DefaultMultiAccountStatusListProducer @AssistedInject constructor combine(flows) { it.toList() } } + .flowOn(dispatchers.default) } @AssistedFactory diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt index ebb05f18cb..9cc97e0760 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt @@ -3,45 +3,121 @@ package com.tangem.domain.account.status.producer import arrow.core.Option import arrow.core.none import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusesFlowFactory import com.tangem.domain.account.supplier.SingleAccountListSupplier -import com.tangem.domain.models.network.NetworkStatus -import com.tangem.domain.models.quote.QuoteStatus -import com.tangem.domain.models.staking.YieldBalance -import com.tangem.domain.networks.single.SingleNetworkStatusSupplier -import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier -import com.tangem.domain.staking.StakingIdFactory -import com.tangem.domain.staking.single.SingleYieldBalanceSupplier +import com.tangem.domain.core.utils.lceContent +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.quote.PriceChange +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.operations.PriceChangeCalculator +import com.tangem.domain.tokens.operations.TokenListFactory +import com.tangem.domain.tokens.operations.TotalFiatBalanceCalculator +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.* +import java.math.BigDecimal /** + * Produces a flow of [AccountStatusList] for a single user wallet. + * + * @property params Parameters containing the user wallet ID. + * @property singleAccountListSupplier Supplier to get the list of accounts for the user wallet. + * @property cryptoCurrencyStatusesFlowFactory Factory to create flows of cryptocurrency statuses. + * @property dispatchers Coroutine dispatcher provider for managing threading. + * [REDACTED_AUTHOR] */ -// TODO: Implement [REDACTED_JIRA] -@Suppress("UnusedPrivateProperty", "UnusedPrivateClass") @OptIn(ExperimentalCoroutinesApi::class) internal class DefaultSingleAccountStatusListProducer @AssistedInject constructor( @Assisted private val params: SingleAccountStatusListProducer.Params, private val singleAccountListSupplier: SingleAccountListSupplier, - private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, - private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier, - private val singleYieldBalanceSupplier: SingleYieldBalanceSupplier, - private val stakingIdFactory: StakingIdFactory, + private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory, + private val dispatchers: CoroutineDispatcherProvider, ) : SingleAccountStatusListProducer { override val fallback: Option = none() - override fun produce(): Flow = emptyFlow() + override fun produce(): Flow { + val accountListFlow = singleAccountListSupplier( + params = SingleAccountListProducer.Params(params.userWalletId), + ) - private data class CryptoCurrencyStatusSources( - val networkStatus: NetworkStatus, - val yieldBalance: YieldBalance?, - val quoteStatus: QuoteStatus?, - ) + return accountListFlow.flatMapLatest { accountList -> + val accountStatusFlows = accountList.accounts + .filterIsInstance() + .map { account -> + if (account.cryptoCurrencies.isEmpty()) { + createEmptyAccountStatusFlow(account) + } else { + getAccountStatusFlow( + userWallet = accountList.userWallet, + account = account, + groupType = accountList.groupType, + sortType = accountList.sortType, + ) + } + } + + combine(accountStatusFlows) { accountStatuses -> + val balances = accountStatuses.map { it.tokenList.totalFiatBalance } + + AccountStatusList( + userWallet = accountList.userWallet, + accountStatuses = accountStatuses.toSet(), + totalAccounts = accountList.totalAccounts, + totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances), + ) + } + } + .flowOn(dispatchers.default) + } + + private fun createEmptyAccountStatusFlow(account: Account.CryptoPortfolio): Flow { + return flowOf( + AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Empty, + priceChangeLce = PriceChange( + value = BigDecimal.ZERO.movePointLeft(2), + source = StatusSource.ACTUAL, + ).lceContent(), + ), + ) + } + + private fun getAccountStatusFlow( + userWallet: UserWallet, + account: Account.CryptoPortfolio, + groupType: TokensGroupType, + sortType: TokensSortType, + ): Flow { + val statusesFlows = account.cryptoCurrencies.map { currency -> + cryptoCurrencyStatusesFlowFactory.create(userWallet = userWallet, currency = currency) + } + + return combine(statusesFlows) { statuses -> + val statusList = statuses.toList() + + AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenListFactory.create( + statuses = statusList, + groupType = groupType, + sortType = sortType, + ), + priceChangeLce = PriceChangeCalculator.calculate(statuses = statusList), + ) + } + } @AssistedFactory interface Factory : SingleAccountStatusListProducer.Factory { diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactory.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactory.kt new file mode 100644 index 0000000000..bd7df0e9f4 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactory.kt @@ -0,0 +1,162 @@ +package com.tangem.domain.account.status.utils + +import arrow.core.some +import arrow.core.toOption +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.network.getAddress +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.models.staking.YieldBalance +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.networks.single.SingleNetworkStatusProducer +import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.quotes.single.SingleQuoteStatusProducer +import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier +import com.tangem.domain.staking.StakingIdFactory +import com.tangem.domain.staking.single.SingleYieldBalanceProducer +import com.tangem.domain.staking.single.SingleYieldBalanceSupplier +import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +/** + * Factory that creates a flow of [CryptoCurrencyStatus] for a given [CryptoCurrency] in a [UserWallet]. + * + * @property singleNetworkStatusSupplier Supplier for obtaining network status. + * @property singleQuoteStatusSupplier Supplier for obtaining quote status. + * @property singleYieldBalanceSupplier Supplier for obtaining yield balance. + * @property stakingIdFactory Factory for creating staking IDs. + * +[REDACTED_AUTHOR] + */ +internal class CryptoCurrencyStatusesFlowFactory @Inject constructor( + private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, + private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier, + private val singleYieldBalanceSupplier: SingleYieldBalanceSupplier, + private val stakingIdFactory: StakingIdFactory, +) { + + /** + * Creates a flow of [CryptoCurrencyStatus] for the specified [userWallet] and [currency]. + * + * @param userWallet The user wallet containing the currency. + + */ + fun create(userWallet: UserWallet, currency: CryptoCurrency): Flow { + return getCryptoCurrencyStatusSourcesFlow(userWallet = userWallet, currency = currency) + .map { statusSources -> + CryptoCurrencyStatusFactory.create( + currency = currency, + maybeNetworkStatus = statusSources.networkStatus.some(), + maybeQuoteStatus = statusSources.quoteStatus.toOption(), + maybeYieldBalance = statusSources.yieldBalance.toOption(), + ) + } + .onEmpty { + emit( + CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading), + ) + } + .conflate() + .distinctUntilChanged() + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun getCryptoCurrencyStatusSourcesFlow( + userWallet: UserWallet, + currency: CryptoCurrency, + ): Flow { + val networkStatusFlow = getNetworkStatusFlow(userWalletId = userWallet.walletId, network = currency.network) + + val yieldBalanceFlow = if (userWallet.isMultiCurrency) { + networkStatusFlow.flatMapLatest { networkStatus -> + getYieldBalanceFlow( + userWalletId = userWallet.walletId, + currencyId = currency.id, + networkStatus = networkStatus, + ) + } + } else { + null + } + + val quoteStatusFlow = currency.id.rawCurrencyId?.let(::getQuoteStatusFlow) + + return combine(networkStatusFlow, yieldBalanceFlow, quoteStatusFlow) + } + + private fun combine( + networkStatusFlow: Flow, + yieldBalanceFlow: Flow?, + quoteStatusFlow: Flow?, + ): Flow { + return when { + yieldBalanceFlow != null && quoteStatusFlow != null -> { + combine( + flow = networkStatusFlow, + flow2 = yieldBalanceFlow, + flow3 = quoteStatusFlow, + transform = ::CryptoCurrencyStatusSources, + ) + } + yieldBalanceFlow != null -> { + combine(flow = networkStatusFlow, flow2 = yieldBalanceFlow, transform = ::CryptoCurrencyStatusSources) + } + quoteStatusFlow != null -> { + combine(flow = networkStatusFlow, flow2 = quoteStatusFlow) { networkStatus, quoteStatus -> + CryptoCurrencyStatusSources(networkStatus = networkStatus, quoteStatus = quoteStatus) + } + } + else -> networkStatusFlow.map(::CryptoCurrencyStatusSources) + } + } + + private fun getNetworkStatusFlow(userWalletId: UserWalletId, network: Network): Flow { + return singleNetworkStatusSupplier( + params = SingleNetworkStatusProducer.Params(userWalletId = userWalletId, network = network), + ) + .conflate() + .distinctUntilChanged() + } + + private fun getQuoteStatusFlow(rawCurrencyId: CryptoCurrency.RawID): Flow { + return singleQuoteStatusSupplier( + params = SingleQuoteStatusProducer.Params(rawCurrencyId = rawCurrencyId), + ) + .conflate() + .distinctUntilChanged() + } + + private fun getYieldBalanceFlow( + userWalletId: UserWalletId, + currencyId: CryptoCurrency.ID, + networkStatus: NetworkStatus, + ): Flow { + val stakingId = stakingIdFactory.create( + currencyId = currencyId, + defaultAddress = networkStatus.getAddress(), + ) + .getOrNull() + + return if (stakingId != null) { + singleYieldBalanceSupplier( + params = SingleYieldBalanceProducer.Params(userWalletId = userWalletId, stakingId = stakingId), + ) + .conflate() + .distinctUntilChanged() + } else { + flowOf(null) + } + } + + private data class CryptoCurrencyStatusSources( + val networkStatus: NetworkStatus, + val yieldBalance: YieldBalance? = null, + val quoteStatus: QuoteStatus? = null, + ) +} \ No newline at end of file diff --git a/domain/account/status/src/test/java/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducerTest.kt b/domain/account/status/src/test/java/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducerTest.kt new file mode 100644 index 0000000000..a3becc8abf --- /dev/null +++ b/domain/account/status/src/test/java/com/tangem/domain/account/status/producer/DefaultMultiAccountStatusListProducerTest.kt @@ -0,0 +1,198 @@ +package com.tangem.domain.account.status.producer + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultMultiAccountStatusListProducerTest { + + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk() + private val dispatchers = TestingCoroutineDispatcherProvider() + + private val userWalletId1 = UserWalletId("001") + private val userWallet1 = mockk { + every { walletId } returns userWalletId1 + } + + private val userWalletId2 = UserWalletId("002") + private val userWallet2 = mockk { + every { walletId } returns userWalletId2 + } + + private val producer = DefaultMultiAccountStatusListProducer( + params = Unit, + userWalletsListRepository = userWalletsListRepository, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + dispatchers = dispatchers, + ) + + @AfterEach + fun tearDown() { + clearMocks(userWalletsListRepository, singleAccountStatusListSupplier) + } + + @Test + fun `produce returns status lists for all user wallets`() = runTest { + // Arrange + val wallets = listOf(userWallet1, userWallet2) + val walletsFlow = MutableStateFlow(wallets) + + every { userWalletsListRepository.userWallets } returns walletsFlow + val accountStatusList1 = mockk() + val accountStatusList2 = mockk() + + every { + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId1), + ) + } returns flowOf(accountStatusList1) + + every { + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId2), + ) + } returns flowOf(accountStatusList2) + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = listOf(accountStatusList1, accountStatusList2) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsListRepository.userWallets + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId1), + ) + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId2), + ) + } + } + + @Test + fun `produce returns empty flow if userWallets is empty list`() = runTest { + // Arrange + val walletsFlow = MutableStateFlow>(emptyList()) + every { userWalletsListRepository.userWallets } returns walletsFlow + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsListRepository.userWallets + } + } + + @Test + fun `produce returns empty flow if userWallets is null`() = runTest { + // Arrange + val walletsFlow = MutableStateFlow?>(null) + every { userWalletsListRepository.userWallets } returns walletsFlow + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsListRepository.userWallets + } + } + + @Test + fun `flow will updated if userWallets are updated`() = runTest { + // Arrange + val userWalletId3 = UserWalletId("003") + val userWallet3 = mockk { every { walletId } returns userWalletId3 } + + val walletsFlow = MutableStateFlow(listOf(userWallet1, userWallet2)) + every { userWalletsListRepository.userWallets } returns walletsFlow + + val accountStatusList1 = mockk() + val accountStatusList2 = mockk() + val accountStatusList3 = mockk() + + every { + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId1), + ) + } returns flowOf(accountStatusList1) + + every { + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId2), + ) + } returns flowOf(accountStatusList2) + + every { + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId3), + ) + } returns flowOf(accountStatusList3) + + // Act (first emission) + val actual1 = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + val expected1 = listOf(accountStatusList1, accountStatusList2) + Truth.assertThat(actual1).containsExactly(expected1) + + // Act (second emission) + walletsFlow.value = listOf(userWallet1, userWallet2, userWallet3) + val actual2 = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + val expected2 = listOf(accountStatusList1, accountStatusList2, accountStatusList3) + Truth.assertThat(actual2).containsExactly(expected2) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsListRepository.userWallets + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId1), + ) + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId2), + ) + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId1), + ) + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId2), + ) + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId3), + ) + userWalletsListRepository.userWallets + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId1), + ) + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId2), + ) + singleAccountStatusListSupplier( + params = SingleAccountStatusListProducer.Params(userWalletId3), + ) + } + } +} \ No newline at end of file diff --git a/domain/account/status/src/test/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt b/domain/account/status/src/test/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt new file mode 100644 index 0000000000..ca151e1478 --- /dev/null +++ b/domain/account/status/src/test/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducerTest.kt @@ -0,0 +1,244 @@ +package com.tangem.domain.account.status.producer + +import arrow.core.nonEmptyListOf +import com.google.common.truth.Truth +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusesFlowFactory +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.core.utils.lceContent +import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.quote.PriceChange +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultSingleAccountStatusListProducerTest { + + private val singleAccountListSupplier: SingleAccountListSupplier = mockk() + private val cryptoCurrencyStatusesFlowFactory: CryptoCurrencyStatusesFlowFactory = mockk() + + private val userWalletId = UserWalletId("011") + private val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + private val producer = DefaultSingleAccountStatusListProducer( + params = SingleAccountStatusListProducer.Params(userWalletId), + singleAccountListSupplier = singleAccountListSupplier, + cryptoCurrencyStatusesFlowFactory = cryptoCurrencyStatusesFlowFactory, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @AfterEach + fun tearDown() { + clearMocks(singleAccountListSupplier, cryptoCurrencyStatusesFlowFactory) + } + + @Test + fun `flow is mapped for user wallet id from params`() = runTest { + // Arrange + val accountList = AccountList.empty(userWallet) + + every { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } returns flowOf(accountList) + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = AccountStatusList( + userWallet = userWallet, + accountStatuses = setOf( + AccountStatus.CryptoPortfolio( + account = accountList.mainAccount, + tokenList = TokenList.Empty, + priceChangeLce = PriceChange(value = BigDecimal("0.00"), source = StatusSource.ACTUAL).lceContent(), + ), + ), + totalAccounts = 1, + totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + ) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } + } + + @Test + fun `flow will updated if balances are updated`() = runTest { + // Arrange + val accountList = AccountList.empty(userWallet) + val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.BALANCE) + + val accountListFlow = MutableStateFlow(value = accountList) + + every { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } returns accountListFlow + + // Act (first emission) + val actual1 = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + val expected = AccountStatusList( + userWallet = userWallet, + accountStatuses = setOf( + AccountStatus.CryptoPortfolio( + account = accountList.mainAccount, + tokenList = TokenList.Empty, + priceChangeLce = PriceChange(value = BigDecimal("0.00"), source = StatusSource.ACTUAL).lceContent(), + ), + ), + totalAccounts = 1, + totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + ) + Truth.assertThat(actual1).containsExactly(expected) + + // Act (second emission) + accountListFlow.value = updatedAccountList + val actual2 = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + val expected2 = AccountStatusList( + userWallet = userWallet, + accountStatuses = setOf( + AccountStatus.CryptoPortfolio( + account = updatedAccountList.mainAccount, + tokenList = TokenList.Empty, + priceChangeLce = PriceChange(value = BigDecimal("0.00"), source = StatusSource.ACTUAL).lceContent(), + ), + ), + totalAccounts = 1, + totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + ) + Truth.assertThat(actual2).containsExactly(expected2) + + coVerify(ordering = Ordering.SEQUENCE) { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } + } + + @Test + fun `flow is filtered the same balance`() = runTest { + // Arrange + val accountList = AccountList.empty(userWallet) + val accountListFlow = MutableStateFlow(value = accountList) + + every { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } returns accountListFlow + + val expected = AccountStatusList( + userWallet = userWallet, + accountStatuses = setOf( + AccountStatus.CryptoPortfolio( + account = accountList.mainAccount, + tokenList = TokenList.Empty, + priceChangeLce = PriceChange(value = BigDecimal("0.00"), source = StatusSource.ACTUAL).lceContent(), + ), + ), + totalAccounts = 1, + totalFiatBalance = TotalFiatBalance.Loaded(amount = BigDecimal.ZERO, source = StatusSource.ACTUAL), + ) + + // Act (first emission) + val actual1 = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + Truth.assertThat(actual1).containsExactly(expected) + + // Act (second emission) + accountListFlow.value = accountList + val actual2 = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + Truth.assertThat(actual2).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } + } + + @Test + fun `flow is produced for account with non empty crypto currencies`() = runTest { + // Arrange + val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + val accountList = AccountList.empty( + userWallet = userWallet, + cryptoCurrencies = cryptoCurrencyFactory.ethereumAndStellar.toSet(), + ) + + every { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } returns flowOf(accountList) + + val ethereumStatus = CryptoCurrencyStatus( + currency = cryptoCurrencyFactory.ethereum, + value = CryptoCurrencyStatus.Loading, + ) + every { + cryptoCurrencyStatusesFlowFactory.create(userWallet = userWallet, currency = cryptoCurrencyFactory.ethereum) + } returns flowOf(ethereumStatus) + + val stellarStatus = CryptoCurrencyStatus( + currency = cryptoCurrencyFactory.stellar, + value = CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null), + ) + every { + cryptoCurrencyStatusesFlowFactory.create(userWallet = userWallet, currency = cryptoCurrencyFactory.stellar) + } returns flowOf(stellarStatus) + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = AccountStatusList( + userWallet = userWallet, + accountStatuses = setOf( + AccountStatus.CryptoPortfolio( + account = accountList.mainAccount, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loading, + sortedBy = TokensSortType.NONE, + currencies = nonEmptyListOf(ethereumStatus, stellarStatus), + ), + priceChangeLce = lceLoading(), + ), + ), + totalAccounts = 1, + totalFiatBalance = TotalFiatBalance.Loading, + ) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleAccountListSupplier(params = SingleAccountListProducer.Params(userWalletId)) + } + } +} \ No newline at end of file diff --git a/domain/account/status/src/test/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactoryTest.kt b/domain/account/status/src/test/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactoryTest.kt new file mode 100644 index 0000000000..0036579daf --- /dev/null +++ b/domain/account/status/src/test/java/com/tangem/domain/account/status/utils/CryptoCurrencyStatusesFlowFactoryTest.kt @@ -0,0 +1,308 @@ +package com.tangem.domain.account.status.utils + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.models.staking.StakingID +import com.tangem.domain.models.staking.YieldBalance +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.networks.single.SingleNetworkStatusProducer +import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.quotes.single.SingleQuoteStatusProducer +import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier +import com.tangem.domain.staking.StakingIdFactory +import com.tangem.domain.staking.single.SingleYieldBalanceProducer +import com.tangem.domain.staking.single.SingleYieldBalanceSupplier +import io.mockk.* +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CryptoCurrencyStatusesFlowFactoryTest { + + private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier = mockk() + private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk() + private val singleYieldBalanceSupplier: SingleYieldBalanceSupplier = mockk() + private val stakingIdFactory: StakingIdFactory = mockk() + + private val factory = CryptoCurrencyStatusesFlowFactory( + singleNetworkStatusSupplier = singleNetworkStatusSupplier, + singleQuoteStatusSupplier = singleQuoteStatusSupplier, + singleYieldBalanceSupplier = singleYieldBalanceSupplier, + stakingIdFactory = stakingIdFactory, + ) + + private val userWalletId = UserWalletId("011") + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + + private val networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address(value = "0x1", type = NetworkAddress.Address.Type.Primary), + ) + + @AfterEach + fun tearDown() { + clearMocks( + singleNetworkStatusSupplier, + singleQuoteStatusSupplier, + singleYieldBalanceSupplier, + stakingIdFactory, + ) + } + + @Test + fun `if rawCurrencyId is null, there will be no subscription to the quote status`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns true + } + + val currency = cryptoCurrencyFactory.ethereum.copy( + id = cryptoCurrencyFactory.ethereum.id.copy( + suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = "0x12345"), + ), + ) + + val networkStatus = NetworkStatus( + network = currency.network, + value = NetworkStatus.Unreachable(address = networkAddress), + ) + val networkStatusFlow = flowOf(networkStatus) + every { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + } returns networkStatusFlow + + val stakingId = StakingID(integrationId = "id", address = networkAddress.defaultAddress.value) + coEvery { + stakingIdFactory.create(currencyId = currency.id, defaultAddress = networkAddress.defaultAddress.value) + } returns stakingId.right() + + val yieldBalance = YieldBalance.Empty(stakingId = stakingId, source = StatusSource.ACTUAL) + val yieldBalanceFlow = flowOf(yieldBalance) + every { + singleYieldBalanceSupplier( + params = SingleYieldBalanceProducer.Params(userWalletId = userWalletId, stakingId = stakingId), + ) + } returns yieldBalanceFlow + + // Act + val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues) + + // Assert + val expected = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Unreachable( + priceChange = null, + fiatRate = null, + networkAddress = networkAddress, + ), + ) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + stakingIdFactory.create(currencyId = currency.id, defaultAddress = networkAddress.defaultAddress.value) + singleYieldBalanceSupplier(params = SingleYieldBalanceProducer.Params(userWalletId, stakingId)) + } + } + + @Test + fun `if userWallet is not multi-currency, there will be no subscription to the yield balance`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns false + } + + val currency = cryptoCurrencyFactory.ethereum + + val networkStatus = NetworkStatus( + network = currency.network, + value = NetworkStatus.Unreachable(address = networkAddress), + ) + val networkStatusFlow = flowOf(networkStatus) + every { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + } returns networkStatusFlow + + val quoteStatus = QuoteStatus( + rawCurrencyId = currency.id.rawCurrencyId!!, + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ONE, + ), + ) + val quoteStatusFlow = flowOf(quoteStatus) + every { + singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!)) + } returns quoteStatusFlow + + // Act + val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues) + + // Assert + val expected = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Unreachable( + priceChange = BigDecimal.ONE, + fiatRate = BigDecimal.ONE, + networkAddress = networkAddress, + ), + ) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!)) + } + } + + @Test + fun `no subscription to the quote status and yield balance`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns false + } + + val currency = cryptoCurrencyFactory.ethereum.copy( + id = cryptoCurrencyFactory.ethereum.id.copy( + suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = "0x12345"), + ), + ) + + val networkStatus = NetworkStatus( + network = currency.network, + value = NetworkStatus.Unreachable(address = networkAddress), + ) + val networkStatusFlow = flowOf(networkStatus) + every { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + } returns networkStatusFlow + + // Act + val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues) + + // Assert + val expected = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Unreachable( + priceChange = null, + fiatRate = null, + networkAddress = networkAddress, + ), + ) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + } + } + + @Test + fun `if stakingId is not supported, yield balance will be null`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns true + } + + val currency = cryptoCurrencyFactory.ethereum + + val networkStatus = NetworkStatus( + network = currency.network, + value = NetworkStatus.Unreachable(address = networkAddress), + ) + val networkStatusFlow = flowOf(networkStatus) + every { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + } returns networkStatusFlow + + val quoteStatus = QuoteStatus( + rawCurrencyId = currency.id.rawCurrencyId!!, + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ONE, + ), + ) + val quoteStatusFlow = flowOf(quoteStatus) + every { + singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!)) + } returns quoteStatusFlow + + coEvery { + stakingIdFactory.create(currencyId = currency.id, defaultAddress = networkAddress.defaultAddress.value) + } returns StakingIdFactory.Error.UnsupportedCurrency.left() + + // Act + val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues) + + // Assert + val expected = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Unreachable( + priceChange = BigDecimal.ONE, + fiatRate = BigDecimal.ONE, + networkAddress = networkAddress, + ), + ) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!)) + } + } + + @Test + fun `all sources are empty`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns true + } + + val currency = cryptoCurrencyFactory.ethereum + + every { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + } returns emptyFlow() + + every { + singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!)) + } returns emptyFlow() + + // Act + val actual = factory.create(userWallet = userWallet, currency = currency).let(::getEmittedValues) + + // Assert + val expected = CryptoCurrencyStatus(currency = currency, value = CryptoCurrencyStatus.Loading) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + singleNetworkStatusSupplier(params = SingleNetworkStatusProducer.Params(userWalletId, currency.network)) + singleQuoteStatusSupplier(params = SingleQuoteStatusProducer.Params(currency.id.rawCurrencyId!!)) + } + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/quote/PriceChange.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/quote/PriceChange.kt index 83b2239dd2..35b2175cce 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/quote/PriceChange.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/quote/PriceChange.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable /** * Represents the price change of a cryptocurrency asset over a specific time period. * - * @param value The amount of price change. + * @param value The amount of price change (like `0.00`) * @param source The source of the price change information. * [REDACTED_AUTHOR]