diff --git a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt index feca7854ed..7e30b4e8a7 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt @@ -50,7 +50,7 @@ internal class DefaultMultiNetworkStatusProducerTest { @Test fun `flow is mapped for user wallet id from params`() = runTest { - // Assert + // Arrange val statuses = setOf( MockNetworkStatusFactory.createVerified(ethNetwork), MockNetworkStatusFactory.createVerified(cardanoNetwork), diff --git a/data/tokens/build.gradle.kts b/data/tokens/build.gradle.kts index 1a51e2cbc9..ac23666c4d 100644 --- a/data/tokens/build.gradle.kts +++ b/data/tokens/build.gradle.kts @@ -12,6 +12,10 @@ android { namespace = "com.tangem.data.tokens" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { // region Project - Data @@ -62,4 +66,13 @@ dependencies { ksp(deps.moshi.kotlin.codegen) kaptForObfuscatingVariants(deps.retrofit.response.type.keeper) // endregion + + // region Tests + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(projects.common.test) + // endregion } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducer.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducer.kt new file mode 100644 index 0000000000..7f655d07f8 --- /dev/null +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducer.kt @@ -0,0 +1,64 @@ +package com.tangem.data.tokens + +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.datasource.local.token.UserTokensResponseStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.domain.wallets.models.requireColdWallet +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.* + +/** + * Default implementation of [MultiWalletCryptoCurrenciesProducer] + * + * @property params params + * @property userWalletsStore UserWallet's store + * @property userTokensResponseStore store of `UserTokensResponse` + * @property responseCryptoCurrenciesFactory factory for creating [CryptoCurrency] from `UserTokensResponse` + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class DefaultMultiWalletCryptoCurrenciesProducer @AssistedInject constructor( + @Assisted val params: MultiWalletCryptoCurrenciesProducer.Params, + private val userWalletsStore: UserWalletsStore, + private val userTokensResponseStore: UserTokensResponseStore, + private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, + private val dispatchers: CoroutineDispatcherProvider, +) : MultiWalletCryptoCurrenciesProducer { + + override val fallback: Set + get() = emptySet() + + override fun produce(): Flow> { + val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId).requireColdWallet() // TODO [REDACTED_TASK_KEY] + + if (!userWallet.isMultiCurrency) { + error("${this::class.simpleName} supports only multi-currency wallet") + } + + return userTokensResponseStore.get(userWalletId = params.userWalletId) + .distinctUntilChanged() + .map { response -> + if (response == null) return@map emptySet() + + responseCryptoCurrenciesFactory.createCurrencies( + response = response, + scanResponse = userWallet.scanResponse, + ).toSet() + } + .onEmpty { emit(fallback) } + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : MultiWalletCryptoCurrenciesProducer.Factory { + override fun create( + params: MultiWalletCryptoCurrenciesProducer.Params, + ): DefaultMultiWalletCryptoCurrenciesProducer + } +} \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesProducerModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesProducerModule.kt new file mode 100644 index 0000000000..03fa952be2 --- /dev/null +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesProducerModule.kt @@ -0,0 +1,20 @@ +package com.tangem.data.tokens.di + +import com.tangem.data.tokens.DefaultMultiWalletCryptoCurrenciesProducer +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +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 MultiWalletCryptoCurrenciesProducerModule { + + @Singleton + @Binds + fun bindMultiWalletCryptoCurrenciesProducerFactory( + impl: DefaultMultiWalletCryptoCurrenciesProducer.Factory, + ): MultiWalletCryptoCurrenciesProducer.Factory +} \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesSupplierModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesSupplierModule.kt new file mode 100644 index 0000000000..b2bac92e91 --- /dev/null +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/MultiWalletCryptoCurrenciesSupplierModule.kt @@ -0,0 +1,25 @@ +package com.tangem.data.tokens.di + +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal class MultiWalletCryptoCurrenciesSupplierModule { + + @Singleton + @Provides + fun provideMultiWalletCryptoCurrenciesSupplier( + factory: MultiWalletCryptoCurrenciesProducer.Factory, + ): MultiWalletCryptoCurrenciesSupplier { + return object : MultiWalletCryptoCurrenciesSupplier( + factory = factory, + keyCreator = { "multi_crypto_currency_${it.userWalletId}" }, + ) {} + } +} \ No newline at end of file diff --git a/data/tokens/src/test/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducerTest.kt b/data/tokens/src/test/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducerTest.kt new file mode 100644 index 0000000000..a453c8e2b7 --- /dev/null +++ b/data/tokens/src/test/kotlin/com/tangem/data/tokens/DefaultMultiWalletCryptoCurrenciesProducerTest.kt @@ -0,0 +1,351 @@ +package com.tangem.data.tokens + +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +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.getEmittedValues +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +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.common.configs.GenericCardConfig +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.isMultiCurrency +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultMultiWalletCryptoCurrenciesProducerTest { + + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + + private val params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWallet.walletId) + private val userWalletsStore: UserWalletsStore = mockk(relaxUnitFun = true) + private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true) + private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk() + + private val producer = DefaultMultiWalletCryptoCurrenciesProducer( + params = params, + userWalletsStore = userWalletsStore, + userTokensResponseStore = userTokensResponseStore, + responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @BeforeEach + fun resetMocks() { + clearMocks(userWalletsStore, userTokensResponseStore, responseCryptoCurrenciesFactory) + } + + @Test + fun `flow is mapped for user wallet id from params`() = runTest { + // Arrange + val userTokensResponseFlow = flowOf(null) + + every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet + every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = emptySet() + + Truth.assertThat(actual.size).isEqualTo(1) + Truth.assertThat(actual.first()).isEqualTo(expected) + + verifyOrder { + userWalletsStore.getSyncStrict(params.userWalletId) + userTokensResponseStore.get(params.userWalletId) + } + + verify(inverse = true) { + responseCryptoCurrenciesFactory.createCurrencies(response = any(), scanResponse = any()) + } + } + + @Test + fun `flow will updated if UserTokensResponse is updated`() = runTest { + // Arrange + val userTokensResponseFlow = MutableSharedFlow(replay = 2) + + val userTokensResponse = UserTokensResponse( + group = UserTokensResponse.GroupType.TOKEN, + sort = UserTokensResponse.SortType.MARKETCAP, + tokens = emptyList(), + ) + val cryptoCurrencies = emptySet() + + val updatedUserTokensResponse = UserTokensResponse( + group = UserTokensResponse.GroupType.TOKEN, + sort = UserTokensResponse.SortType.MARKETCAP, + tokens = listOf( + UserTokensResponse.Token( + id = null, + networkId = "bitcoin", + derivationPath = null, + name = "Bitcoin", + symbol = "BTC", + decimals = 8, + contractAddress = null, + addresses = listOf(), + ), + ), + ) + val updatedCryptoCurrencies = setOf( + cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin), + ) + + every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet + every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow + + every { + responseCryptoCurrenciesFactory.createCurrencies( + response = userTokensResponse, + scanResponse = userWallet.scanResponse, + ) + } returns cryptoCurrencies.toList() + + every { + responseCryptoCurrenciesFactory.createCurrencies( + response = updatedUserTokensResponse, + scanResponse = userWallet.scanResponse, + ) + } returns updatedCryptoCurrencies.toList() + + val producerFlow = producer.produce() + + // Act 1 (first emit) + userTokensResponseFlow.emit(userTokensResponse) + + val actual1 = getEmittedValues(flow = producerFlow) + + // Assert + val expected1 = cryptoCurrencies + + Truth.assertThat(actual1.size).isEqualTo(1) + Truth.assertThat(actual1.first()).isEqualTo(expected1) + + verifyOrder { + userWalletsStore.getSyncStrict(params.userWalletId) + userTokensResponseStore.get(params.userWalletId) + responseCryptoCurrenciesFactory.createCurrencies( + response = userTokensResponse, + scanResponse = userWallet.scanResponse, + ) + } + + // Act 2 (second emit) + userTokensResponseFlow.emit(updatedUserTokensResponse) + + val actual2 = getEmittedValues(flow = producerFlow) + + // Assert + val expected2 = listOf(cryptoCurrencies, updatedCryptoCurrencies) + + Truth.assertThat(actual2.size).isEqualTo(2) + Truth.assertThat(actual2).isEqualTo(expected2) + + verifyOrder { + responseCryptoCurrenciesFactory.createCurrencies( + response = updatedUserTokensResponse, + scanResponse = userWallet.scanResponse, + ) + } + } + + @Test + fun `flow is filtered the same status`() = runTest { + // Arrange + val userTokensResponseFlow = MutableSharedFlow(replay = 2) + + val userTokensResponse = UserTokensResponse( + group = UserTokensResponse.GroupType.TOKEN, + sort = UserTokensResponse.SortType.MARKETCAP, + tokens = emptyList(), + ) + + val cryptoCurrencies = emptySet() + + every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet + every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow + + every { + responseCryptoCurrenciesFactory.createCurrencies( + response = userTokensResponse, + scanResponse = userWallet.scanResponse, + ) + } returns cryptoCurrencies.toList() + + val producerFlow = producer.produce() + + // Act 1 (first emit) + userTokensResponseFlow.emit(userTokensResponse) + + val actual1 = getEmittedValues(flow = producerFlow) + + // Assert + val expected1 = cryptoCurrencies + + Truth.assertThat(actual1.size).isEqualTo(1) + Truth.assertThat(actual1.first()).isEqualTo(expected1) + + verifyOrder { + userWalletsStore.getSyncStrict(params.userWalletId) + userTokensResponseStore.get(params.userWalletId) + responseCryptoCurrenciesFactory.createCurrencies( + response = userTokensResponse, + scanResponse = userWallet.scanResponse, + ) + } + + // Act 2 (second emit) + userTokensResponseFlow.emit(userTokensResponse) + + val actual2 = getEmittedValues(flow = producerFlow) + + // Assert + val expected2 = expected1 + Truth.assertThat(actual2.size).isEqualTo(1) + Truth.assertThat(actual2.first()).isEqualTo(expected2) + } + + @Test + fun `flow throws exception`() = runTest { + // Arrange + val exception = IllegalStateException() + + val userTokensResponse = UserTokensResponse( + group = UserTokensResponse.GroupType.TOKEN, + sort = UserTokensResponse.SortType.MARKETCAP, + tokens = emptyList(), + ) + + val cryptoCurrencies = emptySet() + + val innerFlow = MutableStateFlow(value = false) + val userTokensResponseFlow = flow { + if (innerFlow.value) { + emit(userTokensResponse) + } else { + throw exception + } + } + .buffer(capacity = 5) + + every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet + every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow + + every { + responseCryptoCurrenciesFactory.createCurrencies( + response = userTokensResponse, + scanResponse = userWallet.scanResponse, + ) + } returns cryptoCurrencies.toList() + + val producerFlow = producer.produceWithFallback() + + // Act 1 (fallback) + val actual1 = getEmittedValues(flow = producerFlow) + + // Assert + val expected1 = producer.fallback + Truth.assertThat(actual1.size).isEqualTo(1) + Truth.assertThat(actual1.first()).isEqualTo(expected1) + + verifyOrder { + userWalletsStore.getSyncStrict(params.userWalletId) + userTokensResponseStore.get(params.userWalletId) + } + + // Act 2 (emit) + innerFlow.emit(value = true) + val actual2 = getEmittedValues(flow = producerFlow) + + // Assert + val expected2 = cryptoCurrencies + Truth.assertThat(actual2.size).isEqualTo(1) + Truth.assertThat(actual2.first()).isEqualTo(expected2) + + verifyOrder { + responseCryptoCurrenciesFactory.createCurrencies( + response = userTokensResponse, + scanResponse = userWallet.scanResponse, + ) + } + } + + @Test + fun `flow is empty if store returns empty flow`() = runTest { + // Arrange + every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet + every { userTokensResponseStore.get(params.userWalletId) } returns emptyFlow() + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = producer.fallback + Truth.assertThat(actual.size).isEqualTo(1) + Truth.assertThat(actual.first()).isEqualTo(expected) + + verifyOrder { + userWalletsStore.getSyncStrict(params.userWalletId) + userTokensResponseStore.get(params.userWalletId) + } + + verify(inverse = true) { + responseCryptoCurrenciesFactory.createCurrencies(response = any(), scanResponse = any()) + } + } + + @Test + fun `produce throws exception if UserWallet isn't multi-currency wallet`() = runTest { + // Arrange + val mockUserWallet = mockk { + every { isMultiCurrency } returns false + } + + every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet + + // Act + val actual = runCatching { producer.produce() }.exceptionOrNull() + + // Assert + val expected = IllegalStateException( + "${DefaultMultiWalletCryptoCurrenciesProducer::class.simpleName} supports only multi-currency wallet", + ) + + Truth.assertThat(actual).isInstanceOf(expected::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) + + verifyOrder { userWalletsStore.getSyncStrict(params.userWalletId) } + + verify(inverse = true) { + userTokensResponseStore.get(any()) + responseCryptoCurrenciesFactory.createCurrencies(response = any(), scanResponse = any()) + } + } + + private companion object { + + val scanResponse = MockScanResponseFactory.create( + cardConfig = GenericCardConfig(2), + derivedKeys = emptyMap(), + ) + + val userWallet = MockUserWalletFactory.create(scanResponse = scanResponse) + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletCryptoCurrenciesProducer.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletCryptoCurrenciesProducer.kt new file mode 100644 index 0000000000..b95ff78178 --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletCryptoCurrenciesProducer.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.tokens + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Producer of all crypto currencies for a multi-currency wallet with specified [UserWalletId] + * +[REDACTED_AUTHOR] + */ +interface MultiWalletCryptoCurrenciesProducer : FlowProducer> { + + data class Params(val userWalletId: UserWalletId) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletCryptoCurrenciesSupplier.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletCryptoCurrenciesSupplier.kt new file mode 100644 index 0000000000..cd6ed8fe2c --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/MultiWalletCryptoCurrenciesSupplier.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.tokens + +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer as Producer + +/** + * Producer of all crypto currencies for a multi-currency wallet with specified [UserWalletId] + * + * @property factory factory for creating [Producer] + * @property keyCreator key creator + * +[REDACTED_AUTHOR] + */ +abstract class MultiWalletCryptoCurrenciesSupplier( + override val factory: FlowProducer.Factory, + override val keyCreator: (Producer.Params) -> String, +) : FlowCachingSupplier>() \ No newline at end of file