diff --git a/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt index 5f86c429c3..a216096699 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt @@ -1,10 +1,13 @@ package com.tangem.common.test.domain.wallet +import com.tangem.common.card.WalletData import com.tangem.common.test.domain.card.MockScanResponseFactory import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.configs.GenericCardConfig +import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.builder.UserWalletIdBuilder /** @@ -29,4 +32,29 @@ object MockUserWalletFactory { hasBackupError = false, ) } + + fun createSingleWalletWithToken(): UserWallet.Cold { + return UserWallet.Cold( + name = "NODL", + walletId = UserWalletId("011"), + cardsInWallet = setOf(), + isMultiCurrency = false, + scanResponse = MockScanResponseFactory.create( + cardConfig = GenericCardConfig(maxWalletCount = 2), + derivedKeys = emptyMap(), + ).copy( + productType = ProductType.Note, + walletData = WalletData( + blockchain = "ETH", + token = WalletData.Token( + name = "Ethereum", + symbol = "ETH", + contractAddress = "0x", + decimals = 8, + ), + ), + ), + hasBackupError = false, + ) + } } \ No newline at end of file diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index ace90489e6..d5c297866a 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { // region Project - Domain api(projects.domain.account) + api(projects.domain.card) api(projects.domain.models) // endregion diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListProducerFactoryModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListProducerFactoryModule.kt new file mode 100644 index 0000000000..cfa81cfba4 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListProducerFactoryModule.kt @@ -0,0 +1,28 @@ +package com.tangem.data.account.di + +import com.tangem.data.account.producer.DefaultMultiAccountListProducer +import com.tangem.data.account.producer.DefaultSingleAccountListProducer +import com.tangem.domain.account.producer.MultiAccountListProducer +import com.tangem.domain.account.producer.SingleAccountListProducer +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface AccountListProducerFactoryModule { + + @Binds + @Singleton + fun bindSingleAccountListProducerFactory( + impl: DefaultSingleAccountListProducer.Factory, + ): SingleAccountListProducer.Factory + + @Binds + @Singleton + fun bindMultiAccountListProducerFactory( + impl: DefaultMultiAccountListProducer.Factory, + ): MultiAccountListProducer.Factory +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListSupplierModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListSupplierModule.kt new file mode 100644 index 0000000000..c60b4bd0bb --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountListSupplierModule.kt @@ -0,0 +1,34 @@ +package com.tangem.data.account.di + +import com.tangem.domain.account.producer.MultiAccountListProducer +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object AccountListSupplierModule { + + @Provides + @Singleton + fun provideSingleAccountListSupplier(factory: SingleAccountListProducer.Factory): SingleAccountListSupplier { + return object : SingleAccountListSupplier( + factory = factory, + keyCreator = { "single_account_list_${it.userWalletId.stringValue}" }, + ) {} + } + + @Provides + @Singleton + fun provideMultiNetworkStatusSupplier(factory: MultiAccountListProducer.Factory): MultiAccountListSupplier { + return object : MultiAccountListSupplier( + factory = factory, + keyCreator = { "multi_networks_statuses" }, + ) {} + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt new file mode 100644 index 0000000000..da4e0e7853 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultMultiAccountListProducer.kt @@ -0,0 +1,53 @@ +package com.tangem.data.account.producer + +import arrow.core.Option +import arrow.core.some +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.MultiAccountListProducer +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.* + +/** + * Default implementation of [MultiAccountListProducer]. + * Produces a list of [AccountList]s for all user wallets. + * + * @property params params + * @property userWalletsStore store that provides user wallets + * @property walletAccountListFlowFactory builder to create flows of [AccountList] for each wallet + * @property dispatchers coroutine dispatchers provider + * +[REDACTED_AUTHOR] + */ +internal class DefaultMultiAccountListProducer @AssistedInject constructor( + @Assisted val params: Unit, + private val userWalletsStore: UserWalletsStore, + private val walletAccountListFlowFactory: WalletAccountListFlowFactory, + private val dispatchers: CoroutineDispatcherProvider, +) : MultiAccountListProducer { + + override val fallback: Option> = emptyList().some() + + @OptIn(ExperimentalCoroutinesApi::class) + override fun produce(): Flow> { + return userWalletsStore.userWallets + .distinctUntilChanged() + .flatMapLatest { userWallets -> + combine( + flows = userWallets.map(walletAccountListFlowFactory::create), + transform = ::listOf, + ) + } + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : MultiAccountListProducer.Factory { + override fun create(params: Unit): DefaultMultiAccountListProducer + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt new file mode 100644 index 0000000000..43d0745ea2 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt @@ -0,0 +1,52 @@ +package com.tangem.data.account.producer + +import arrow.core.Option +import arrow.core.none +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.SingleAccountListProducer +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.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull + +/** + * Default implementation of [SingleAccountListProducer]. + * Produces a list of [AccountList] for a specific user wallet. + * + * @property params params containing the user wallet ID + * @property userWalletsStore store that provides user wallets + * @property walletAccountListFlowFactory builder to create flows of [AccountList] for each wallet + * @property dispatchers coroutine dispatchers provider + * +[REDACTED_AUTHOR] + */ +internal class DefaultSingleAccountListProducer @AssistedInject constructor( + @Assisted val params: SingleAccountListProducer.Params, + private val userWalletsStore: UserWalletsStore, + private val walletAccountListFlowFactory: WalletAccountListFlowFactory, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleAccountListProducer { + + override val fallback: Option = none() + + @OptIn(ExperimentalCoroutinesApi::class) + override fun produce(): Flow { + return userWalletsStore.userWallets + .mapNotNull { userWallets -> + userWallets.firstOrNull { it.walletId == params.userWalletId } + } + .flatMapLatest(walletAccountListFlowFactory::create) + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : SingleAccountListProducer.Factory { + override fun create(params: SingleAccountListProducer.Params): DefaultSingleAccountListProducer + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt new file mode 100644 index 0000000000..1f8f47d3c9 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/WalletAccountListFlowFactory.kt @@ -0,0 +1,58 @@ +package com.tangem.data.account.producer + +import com.tangem.data.account.converter.AccountListConverter +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.models.wallet.requireColdWallet +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +/** + * Factory that creates a flow of [AccountList] for a specific [UserWallet] + * + * @property accountsResponseStoreFactory factory to create [AccountsResponseStore] + * @property accountListConverterFactory factory to create [AccountListConverter] + * @property cardCryptoCurrencyFactory factory to create supported crypto currencies for a card + * +[REDACTED_AUTHOR] + */ +internal class WalletAccountListFlowFactory @Inject constructor( + private val accountsResponseStoreFactory: AccountsResponseStoreFactory, + private val accountListConverterFactory: AccountListConverter.Factory, + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, +) { + + fun create(userWallet: UserWallet): Flow { + return if (userWallet.isMultiCurrency) { + createForMultiWallet(userWallet) + } else { + flowOf(createForSingleWallet(userWallet)) + } + } + + private fun createForMultiWallet(userWallet: UserWallet): Flow { + val converter by lazy { accountListConverterFactory.create(userWallet) } + + return accountsResponseStoreFactory.create(userWallet.walletId).data + .filterNotNull() + .distinctUntilChanged() + .map(converter::convert) + } + + private fun createForSingleWallet(userWallet: UserWallet): AccountList { + val isSingleWalletWithToken = userWallet.requireColdWallet().cardTypesResolver.isSingleWalletWithToken() + + val currencies = if (isSingleWalletWithToken) { + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = userWallet).toSet() + } else { + cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet = userWallet).let(::setOf) + } + + return AccountList.empty(userWallet = userWallet, cryptoCurrencies = currencies) + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt new file mode 100644 index 0000000000..3ca26b0198 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultMultiAccountListProducerTest.kt @@ -0,0 +1,222 @@ +package com.tangem.data.account.producer + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.TokensSortType +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.emptyFlow +import kotlinx.coroutines.flow.filterNotNull +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 + +/** +[REDACTED_AUTHOR] + */ +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultMultiAccountListProducerTest { + + private val userWalletsStore: UserWalletsStore = mockk() + private val walletAccountListFlowFactory: WalletAccountListFlowFactory = mockk() + + private val producer = DefaultMultiAccountListProducer( + params = Unit, + userWalletsStore = userWalletsStore, + walletAccountListFlowFactory = walletAccountListFlowFactory, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("011") + private val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + @AfterEach + fun tearDownEach() { + clearMocks(userWalletsStore, walletAccountListFlowFactory) + } + + @Test + fun produce() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = listOf(accountList) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow will updated if factoryFlow is updated`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.NONE) + val factoryFlow = MutableStateFlow(null) + + every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + + // Act (first emission) + factoryFlow.value = accountList + val firstEmission = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + Truth.assertThat(firstEmission).containsExactly(listOf(accountList)) + + // Act (second emission) + factoryFlow.value = updatedAccountList + val secondEmission = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + Truth.assertThat(secondEmission).containsExactly(listOf(updatedAccountList)) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is filtered the same response`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + val factoryFlow = MutableStateFlow(null) + + every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + + // Act (first emission) + factoryFlow.value = accountList + val firstEmission = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + Truth.assertThat(firstEmission).containsExactly(listOf(accountList)) + + // Act (second emission) - the same status + factoryFlow.value = accountList + val secondEmission = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + Truth.assertThat(secondEmission).containsExactly(listOf(accountList)) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow returns empty list if factory throws exception`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val exception = RuntimeException("Converter error") + every { walletAccountListFlowFactory.create(userWallet) } throws exception + + // Act + val actual = producer.produceWithFallback().let(::getEmittedValues) + + // Assert + val expected = emptyList() + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is empty if userWalletsFlow returns empty flow`() = runTest { + // Arrange + val userWalletsFlow = emptyFlow>() + every { userWalletsStore.userWallets } returns userWalletsFlow + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(exactly = 1) { userWalletsStore.userWallets } + coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) } + } + + @Test + fun `flow is empty if factory returns empty flow`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + every { walletAccountListFlowFactory.create(userWallet) } returns emptyFlow() + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is empty if one of factoryFlow is empty`() = runTest { + // Arrange + val userWalletId2 = UserWalletId("012") + val userWallet2 = mockk { + every { this@mockk.walletId } returns userWalletId2 + } + + val userWalletsFlow = MutableStateFlow(listOf(userWallet, userWallet2)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) + every { walletAccountListFlowFactory.create(userWallet2) } returns emptyFlow() + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + walletAccountListFlowFactory.create(userWallet2) + } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt new file mode 100644 index 0000000000..846e46bfea --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt @@ -0,0 +1,198 @@ +package com.tangem.data.account.producer + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.models.TokensSortType +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.emptyFlow +import kotlinx.coroutines.flow.filterNotNull +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 + +/** +[REDACTED_AUTHOR] + */ +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultSingleAccountListProducerTest { + + private val userWalletsStore: UserWalletsStore = mockk() + private val walletAccountListFlowFactory: WalletAccountListFlowFactory = mockk() + + private val userWalletId = UserWalletId("011") + private val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + } + + private val producer = DefaultSingleAccountListProducer( + params = SingleAccountListProducer.Params(userWalletId = userWalletId), + userWalletsStore = userWalletsStore, + walletAccountListFlowFactory = walletAccountListFlowFactory, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @AfterEach + fun tearDownEach() { + clearMocks(userWalletsStore, walletAccountListFlowFactory) + } + + @Test + fun produce() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList) + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = accountList + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow will updated if factoryFlow is updated`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.NONE) + val factoryFlow = MutableStateFlow(null) + + every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + + // Act (first emission) + factoryFlow.value = accountList + val firstEmission = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + Truth.assertThat(firstEmission).containsExactly(accountList) + + // Act (second emission) + factoryFlow.value = updatedAccountList + val secondEmission = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + Truth.assertThat(secondEmission).containsExactly(updatedAccountList) + + coVerifyOrder { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is filtered the same response`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val accountList = AccountList.empty(userWallet) + val factoryFlow = MutableStateFlow(null) + + every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull() + + // Act (first emission) + factoryFlow.value = accountList + val firstEmission = producer.produce().let(::getEmittedValues) + + // Assert (first emission) + Truth.assertThat(firstEmission).containsExactly(accountList) + + // Act (second emission) - the same status + factoryFlow.value = accountList + val secondEmission = producer.produce().let(::getEmittedValues) + + // Assert (second emission) + Truth.assertThat(secondEmission).containsExactly(accountList) + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is empty if factory throws exception`() = runTest { + // Arrange + val userWalletsFlow = MutableStateFlow(value = listOf(userWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + val exception = RuntimeException("Converter error") + every { walletAccountListFlowFactory.create(userWallet) } throws exception + + // Act + val actual = producer.produceWithFallback().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + walletAccountListFlowFactory.create(userWallet) + } + } + + @Test + fun `flow is empty if userWalletsFlow returns empty flow`() = runTest { + // Arrange + val userWalletsFlow = emptyFlow>() + every { userWalletsStore.userWallets } returns userWalletsFlow + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(exactly = 1) { userWalletsStore.userWallets } + coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) } + } + + @Test + fun `flow is empty if userWalletsFlow doesn't contains userWalletId from params`() = runTest { + // Arrange + val unknownId = UserWalletId("012") + val unknownWallet = mockk { + every { this@mockk.walletId } returns unknownId + } + + val userWalletsFlow = MutableStateFlow(listOf(unknownWallet)) + every { userWalletsStore.userWallets } returns userWalletsFlow + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + Truth.assertThat(actual).isEmpty() // no emissions + + coVerify(ordering = Ordering.SEQUENCE) { + userWalletsStore.userWallets + } + + coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) } + } +} \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt new file mode 100644 index 0000000000..c94ef86564 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/producer/WalletAccountListFlowFactoryTest.kt @@ -0,0 +1,148 @@ +package com.tangem.data.account.producer + +import com.google.common.truth.Truth +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.account.converter.AccountListConverter +import com.tangem.data.account.converter.createGetWalletAccountsResponse +import com.tangem.data.account.store.AccountsResponseStore +import com.tangem.data.account.store.AccountsResponseStoreFactory +import com.tangem.data.common.currency.CardCryptoCurrencyFactory +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@Suppress("UnusedFlow") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class WalletAccountListFlowFactoryTest { + + private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk() + private val accountsResponseStore: AccountsResponseStore = mockk() + private val accountsResponseStoreFlow = MutableStateFlow(value = null) + + private val accountListConverterFactory: AccountListConverter.Factory = mockk() + private val accountListConverter: AccountListConverter = mockk() + + private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() + + private val factory = WalletAccountListFlowFactory( + accountsResponseStoreFactory = accountsResponseStoreFactory, + accountListConverterFactory = accountListConverterFactory, + cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, + ) + + private val userWalletId = UserWalletId("011") + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + + @AfterEach + fun tearDownEach() { + clearMocks(accountListConverter) + + accountsResponseStoreFlow.value = null + } + + @Test + fun `create for multi wallet`() = runTest { + // Arrange + val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.isMultiCurrency } returns true + } + + val accountsResponse = createGetWalletAccountsResponse(userWalletId) + every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore + every { accountsResponseStore.data } returns accountsResponseStoreFlow + accountsResponseStoreFlow.value = accountsResponse + + val accountList = AccountList.empty(userWallet) + every { accountListConverterFactory.create(userWallet) } returns accountListConverter + every { accountListConverter.convert(accountsResponse) } returns accountList + + // Act + val actual = factory.create(userWallet).let(::getEmittedValues) + + // Assert + val expected = accountList + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + accountsResponseStoreFactory.create(userWalletId) + accountsResponseStore.data + accountListConverterFactory.create(userWallet) + accountListConverter.convert(accountsResponse) + } + + coVerify(inverse = true) { + cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(any()) + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(any()) + } + } + + @Test + fun `create for single wallet`() = runTest { + val userWallet = MockUserWalletFactory.create().copy(isMultiCurrency = false) + + val currency = cryptoCurrencyFactory.ethereum + every { cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet) } returns currency + + // Act + val actual = factory.create(userWallet).let(::getEmittedValues) + + // Assert + val expected = AccountList.empty(userWallet = userWallet, cryptoCurrencies = setOf(currency)) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet) + } + + coVerify(inverse = true) { + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = any()) + accountsResponseStoreFactory.create(any()) + accountsResponseStore.data + accountListConverterFactory.create(any()) + accountListConverter.convert(any()) + } + } + + @Test + fun `flow is created for single wallet with token`() = runTest { + val nodl = MockUserWalletFactory.createSingleWalletWithToken() + + val currencies = cryptoCurrencyFactory.ethereumAndStellar.toSet() + every { + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = nodl) + } returns currencies.toList() + + // Act + val actual = factory.create(nodl).let(::getEmittedValues) + + // Assert + val expected = AccountList.empty(userWallet = nodl, cryptoCurrencies = currencies) + Truth.assertThat(actual).containsExactly(expected) + + coVerify(ordering = Ordering.SEQUENCE) { + cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = nodl) + } + + coVerify(inverse = true) { + cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(any()) + accountsResponseStoreFactory.create(any()) + accountsResponseStore.data + accountListConverterFactory.create(any()) + accountListConverter.convert(any()) + } + } +} \ No newline at end of file diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt index b5b8ad9269..ceb7c2fc1c 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactoryTest.kt @@ -7,13 +7,14 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.card.WalletData import com.tangem.common.test.domain.card.MockScanResponseFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.domain.wallet.MockUserWalletFactory import com.tangem.common.test.utils.ProvideTestModels import com.tangem.data.common.network.NetworkFactory import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.domain.card.configs.GenericCardConfig import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.card.configs.GenericCardConfig import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -159,7 +160,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest { model: CreateTestModel.SingleWalletWithToken, ) = runTest { // Arrange - val userWallet = createSingleWalletWithToken() + val userWallet = MockUserWalletFactory.createSingleWalletWithToken() coEvery { userWalletsStore.getSyncStrict(key = userWallet.walletId) } returns userWallet @@ -284,7 +285,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest { expected = Result.failure(IllegalArgumentException("It isn't multi-currency wallet")), ), CreateCurrenciesForMultiWalletModel( - multiWallet = createSingleWalletWithToken(), + multiWallet = MockUserWalletFactory.createSingleWalletWithToken(), userTokensResponse = null, expected = Result.failure(IllegalArgumentException("It isn't multi-currency wallet")), ), @@ -456,7 +457,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest { expected = Result.failure(IllegalArgumentException("Coin for the single currency card cannot be null")), ), CreateForSingleWalletWithTokenModel( - singleWalletWithToken = createSingleWalletWithToken(), + singleWalletWithToken = MockUserWalletFactory.createSingleWalletWithToken(), isPrimaryTokenExpected = true, expected = Result.success(listOf(ethereum)), ), @@ -522,33 +523,8 @@ internal class DefaultCardCryptoCurrencyFactoryTest { ) } - private fun createSingleWalletWithToken(): UserWallet.Cold { - return UserWallet.Cold( - name = "NODL", - walletId = UserWalletId("011"), - cardsInWallet = setOf(), - isMultiCurrency = false, - scanResponse = MockScanResponseFactory.create( - cardConfig = GenericCardConfig(maxWalletCount = 2), - derivedKeys = emptyMap(), - ).copy( - productType = ProductType.Note, - walletData = WalletData( - blockchain = "ETH", - token = WalletData.Token( - name = "Ethereum", - symbol = "ETH", - contractAddress = "0x", - decimals = 8, - ), - ), - ), - hasBackupError = false, - ) - } - private fun createPrimaryToken(blockchain: Blockchain): CryptoCurrency.Token { - val userWallet = createSingleWalletWithToken() + val userWallet = MockUserWalletFactory.createSingleWalletWithToken() return CryptoCurrencyFactory(excludedBlockchains = ExcludedBlockchains()).createToken( sdkToken = userWallet.scanResponse.cardTypesResolver.getPrimaryToken()!!, diff --git a/domain/account/src/main/java/com/tangem/domain/account/producer/MultiAccountListProducer.kt b/domain/account/src/main/java/com/tangem/domain/account/producer/MultiAccountListProducer.kt new file mode 100644 index 0000000000..345e6897bc --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/producer/MultiAccountListProducer.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.account.producer + +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.core.flow.FlowProducer + +/** + * Produces a list of [AccountList]s for all user wallets. + * +[REDACTED_AUTHOR] + */ +interface MultiAccountListProducer : FlowProducer> { + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountListProducer.kt b/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountListProducer.kt new file mode 100644 index 0000000000..8d85130007 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/producer/SingleAccountListProducer.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.account.producer + +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Produces a list of [AccountList] for a specific user wallet. + * +[REDACTED_AUTHOR] + */ +interface SingleAccountListProducer : FlowProducer { + + data class Params(val userWalletId: UserWalletId) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/supplier/MultiAccountListSupplier.kt b/domain/account/src/main/java/com/tangem/domain/account/supplier/MultiAccountListSupplier.kt new file mode 100644 index 0000000000..45989af478 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/supplier/MultiAccountListSupplier.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.account.supplier + +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.MultiAccountListProducer +import com.tangem.domain.core.flow.FlowCachingSupplier +import kotlinx.coroutines.flow.Flow + +/** + * Supplier that provides a list of [AccountList]s for all user wallets. + * +[REDACTED_AUTHOR] + */ +abstract class MultiAccountListSupplier( + override val factory: MultiAccountListProducer.Factory, + override val keyCreator: (Unit) -> String, +) : FlowCachingSupplier>() { + + operator fun invoke(): Flow> { + return super.invoke(params = Unit) + } +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountListSupplier.kt b/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountListSupplier.kt new file mode 100644 index 0000000000..cddb1d9182 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/supplier/SingleAccountListSupplier.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.account.supplier + +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.core.flow.FlowCachingSupplier + +/** + * Supplier that provides a single [AccountList] for a specific user wallet. + * +[REDACTED_AUTHOR] + */ +abstract class SingleAccountListSupplier( + override val factory: SingleAccountListProducer.Factory, + override val keyCreator: (SingleAccountListProducer.Params) -> String, +) : FlowCachingSupplier() \ No newline at end of file