diff --git a/common/test/build.gradle.kts b/common/test/build.gradle.kts index 3013d947d3..52931ce5f3 100644 --- a/common/test/build.gradle.kts +++ b/common/test/build.gradle.kts @@ -31,4 +31,6 @@ dependencies { implementation(tangemDeps.blockchain) implementation(tangemDeps.card.core) + + implementation(deps.test.junit5) } \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt index 6a1da92250..84cdecffc6 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt @@ -14,20 +14,25 @@ object MockNetworkStatusFactory { private val defaultNetwork = MockCryptoCurrencyFactory().ethereum.network - fun createVerified(network: Network = defaultNetwork, source: StatusSource = StatusSource.ACTUAL): NetworkStatus { + fun createVerified( + network: Network = defaultNetwork, + source: StatusSource = StatusSource.ACTUAL, + transform: (NetworkStatus.Verified) -> NetworkStatus.Verified = { it }, + ): NetworkStatus { return NetworkStatus( network = network, value = NetworkStatus.Verified( address = NetworkAddress.Single( defaultAddress = NetworkAddress.Address( - value = "0x123", + value = "0x1", type = NetworkAddress.Address.Type.Primary, ), ), amounts = mapOf(), pendingTransactions = mapOf(), source = source, - ), + ) + .let(transform), ) } @@ -37,7 +42,7 @@ object MockNetworkStatusFactory { value = NetworkStatus.NoAccount( address = NetworkAddress.Single( defaultAddress = NetworkAddress.Address( - value = "0x123", + value = "0x1", type = NetworkAddress.Address.Type.Primary, ), ), diff --git a/common/test/src/main/java/com/tangem/common/test/domain/walletmanager/MockUpdateWalletManagerResultFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/walletmanager/MockUpdateWalletManagerResultFactory.kt index b7684db809..e7ae9b4214 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/walletmanager/MockUpdateWalletManagerResultFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/walletmanager/MockUpdateWalletManagerResultFactory.kt @@ -27,7 +27,7 @@ class MockUpdateWalletManagerResultFactory { return UpdateWalletManagerResult.NoAccount( selectedAddress = "0x1", addresses = setOf(Address(value = "0x1", type = Address.Type.Primary)), - amountToCreateAccount = BigDecimal.ZERO, + amountToCreateAccount = BigDecimal.ONE, errorMessage = "", ) } diff --git a/common/test/src/main/java/com/tangem/common/test/utils/ProvideTestModels.kt b/common/test/src/main/java/com/tangem/common/test/utils/ProvideTestModels.kt new file mode 100644 index 0000000000..a0e0882940 --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/utils/ProvideTestModels.kt @@ -0,0 +1,11 @@ +package com.tangem.common.test.utils + +import org.junit.jupiter.params.provider.MethodSource + +/** +[REDACTED_AUTHOR] + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@MethodSource("provideTestModels") +annotation class ProvideTestModels \ No newline at end of file diff --git a/data/common/build.gradle.kts b/data/common/build.gradle.kts index 05ffe54f76..7d10aec65e 100644 --- a/data/common/build.gradle.kts +++ b/data/common/build.gradle.kts @@ -9,6 +9,10 @@ android { namespace = "com.tangem.data.common" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /* Core */ implementation(projects.core.datasource) @@ -39,7 +43,8 @@ dependencies { /* Test */ testImplementation(projects.common.test) testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) testImplementation(deps.test.mockk) testImplementation(deps.test.truth) } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/CardCryptoCurrencyFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/CardCryptoCurrencyFactory.kt index fca4ffba1a..1a2df50544 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/CardCryptoCurrencyFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/CardCryptoCurrencyFactory.kt @@ -3,6 +3,7 @@ package com.tangem.data.common.currency import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId /** @@ -21,6 +22,18 @@ interface CardCryptoCurrencyFactory { @Throws suspend fun create(userWalletId: UserWalletId, network: Network): List + /** + * Create currencies for multi currency card + * + * @param userWallet user wallet + * @param networks networks + */ + @Throws + suspend fun createCurrenciesForMultiCurrencyCard( + userWallet: UserWallet, + networks: Set, + ): Map> + /** * Create default coins for multi currency card * diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt index c4da63c87c..186e0da14f 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt @@ -37,7 +37,9 @@ internal class DefaultCardCryptoCurrencyFactory( val blockchain = Blockchain.fromNetworkId(networkId = network.backendId) // multi-currency wallet - if (userWallet.isMultiCurrency) return getMultiWalletCurrencies(userWallet = userWallet, network = network) + if (userWallet.isMultiCurrency) { + return getMultiWalletCurrencies(userWallet = userWallet, networks = setOf(network))[network].orEmpty() + } // check if the blockchain of single-currency wallet is the same as network val cardBlockchain = userWallet.scanResponse.cardTypesResolver.getBlockchain() @@ -52,7 +54,18 @@ internal class DefaultCardCryptoCurrencyFactory( return createPrimaryCurrencyForSingleCurrencyCard(userWallet.scanResponse).let(::listOf) } + override suspend fun createCurrenciesForMultiCurrencyCard( + userWallet: UserWallet, + networks: Set, + ): Map> { + require(userWallet.isMultiCurrency) { "It isn't multi-currency wallet" } + + return getMultiWalletCurrencies(userWallet = userWallet, networks = networks) + } + override fun createDefaultCoinsForMultiCurrencyCard(scanResponse: ScanResponse): List { + require(scanResponse.cardTypesResolver.isMultiwalletAllowed()) { "It isn't multi-currency wallet" } + val card = scanResponse.card var blockchains = if (demoConfig.isDemoCardId(card.cardId)) { @@ -75,29 +88,39 @@ internal class DefaultCardCryptoCurrencyFactory( } override fun createPrimaryCurrencyForSingleCurrencyCard(scanResponse: ScanResponse): CryptoCurrency { + require(scanResponse.cardTypesResolver.isSingleWallet()) { "It isn't single-currency wallet" } + return with(getSingleWalletCurrencies(scanResponse)) { primaryToken ?: coin } } override fun createCurrenciesForSingleCurrencyCardWithToken(scanResponse: ScanResponse): List { + require(scanResponse.cardTypesResolver.isSingleWalletWithToken()) { + "It isn't single-currency wallet with token" + } + return with(getSingleWalletCurrencies(scanResponse)) { listOfNotNull(coin, primaryToken) } } - private suspend fun getMultiWalletCurrencies(userWallet: UserWallet, network: Network): List { + private suspend fun getMultiWalletCurrencies( + userWallet: UserWallet, + networks: Set, + ): Map> { val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId) - ?: return emptyList() + ?: return emptyMap() val responseCurrenciesFactory = ResponseCryptoCurrenciesFactory(excludedBlockchains) return responseCurrenciesFactory.createCurrencies( - tokens = response.tokens.filter { - it.networkId == network.backendId && it.derivationPath == network.derivationPath.value + tokens = response.tokens.filter { token -> + networks.any { it.backendId == token.networkId && it.derivationPath.value == token.derivationPath } }, scanResponse = userWallet.scanResponse, ) + .groupBy(CryptoCurrency::network) } private fun getSingleWalletCurrencies(scanResponse: ScanResponse): SingleWalletCurrencies { 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 e6b868dc39..21ae9dd504 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,6 +7,8 @@ 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.utils.ProvideTestModels +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 @@ -14,16 +16,20 @@ import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.demo.DemoConfig import com.tangem.domain.models.scan.ProductType import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import io.mockk.* import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest /** [REDACTED_AUTHOR] */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class DefaultCardCryptoCurrencyFactoryTest { private val userWalletsStore: UserWalletsStore = mockk() @@ -36,325 +42,427 @@ internal class DefaultCardCryptoCurrencyFactoryTest { userTokensResponseStore = userTokensResponseStore, ) - @Before - fun setup() { + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + private val ethereum = cryptoCurrencyFactory.ethereum.setCanHandleTokens(value = true) + private val bitcoin = cryptoCurrencyFactory.createCoin(blockchain = Blockchain.Bitcoin) + + private val userTokensResponseFactory = UserTokensResponseFactory() + private val iconUri: Uri = mockk() + + @BeforeEach + fun init() { + clearMocks(userWalletsStore, userTokensResponseStore, iconUri) + mockkStatic(Uri::class) - every { Uri.parse(any()) } returns mockk() + every { Uri.parse(any()) } returns iconUri } - @Test - fun `test create if userTokensResponse is not empty`() = runTest { - val multiWallet = createMultiWallet() + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class CreateMultiWallet { - val userTokensResponse = UserTokensResponseFactory().createUserTokensResponse( - currencies = listOf(ethereum), - isGroupedByNetwork = false, - isSortedByBalance = false, + @ParameterizedTest + @ProvideTestModels + fun `create currencies in ETH for multi-currency wallet`(model: CreateTestModel.MultiWallet) = runTest { + // Arrange + val userWallet = createMultiWallet() + val userTokensResponse = model.userTokensResponse + val network = ethereum.network + + coEvery { userWalletsStore.getSyncStrict(key = userWallet.walletId) } returns userWallet + coEvery { userTokensResponseStore.getSyncOrNull(userWallet.walletId) } returns userTokensResponse + + // Act + val actual = factory.create(userWalletId = userWallet.walletId, network = network) + + // Assert + val expected = model.expected + + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + userWalletsStore.getSyncStrict(key = userWallet.walletId) + userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId) + } + } + + private fun provideTestModels() = listOf( + CreateTestModel.MultiWallet(userTokensResponse = null, expected = emptyList()), + CreateTestModel.MultiWallet(userTokensResponse = createUserTokensResponse(), expected = emptyList()), + CreateTestModel.MultiWallet( + userTokensResponse = createUserTokensResponse(currencies = listOf(ethereum)), + expected = listOf(ethereum), + ), + CreateTestModel.MultiWallet( + userTokensResponse = createUserTokensResponse(listOf(element = bitcoin)), + expected = emptyList(), + ), ) - coEvery { userWalletsStore.getSyncStrict(key = multiWallet.walletId) } returns multiWallet - coEvery { userTokensResponseStore.getSyncOrNull(multiWallet.walletId) } returns userTokensResponse - - val actual = factory.create(userWalletId = multiWallet.walletId, network = ethereum.network) - - coVerifyOrder { - userWalletsStore.getSyncStrict(key = multiWallet.walletId) - userTokensResponseStore.getSyncOrNull(multiWallet.walletId) - } - - val expected = listOf(ethereum) - - Truth.assertThat(actual).isEqualTo(expected) - } - - @Test - fun `test create if userTokensResponse is empty`() = runTest { - val multiWallet = createMultiWallet() - - val userTokensResponse = UserTokensResponseFactory().createUserTokensResponse( - currencies = listOf(), - isGroupedByNetwork = false, - isSortedByBalance = false, - ) - - coEvery { userWalletsStore.getSyncStrict(key = multiWallet.walletId) } returns multiWallet - coEvery { userTokensResponseStore.getSyncOrNull(multiWallet.walletId) } returns userTokensResponse - - val actual = factory.create(userWalletId = multiWallet.walletId, network = ethereum.network) - - coVerifyOrder { - userWalletsStore.getSyncStrict(key = multiWallet.walletId) - userTokensResponseStore.getSyncOrNull(multiWallet.walletId) - } - - val expected = emptyList() - - Truth.assertThat(actual).isEqualTo(expected) - } - - @Test - fun `test create if userTokensResponse is null`() = runTest { - val multiWallet = createMultiWallet() - - coEvery { userWalletsStore.getSyncStrict(key = multiWallet.walletId) } returns multiWallet - coEvery { userTokensResponseStore.getSyncOrNull(multiWallet.walletId) } returns null - - val actual = factory.create(userWalletId = multiWallet.walletId, network = ethereum.network) - - coVerifyOrder { - userWalletsStore.getSyncStrict(key = multiWallet.walletId) - userTokensResponseStore.getSyncOrNull(multiWallet.walletId) - } - - val expected = emptyList() - - Truth.assertThat(actual).isEqualTo(expected) - } - - @Test - fun `test create if userTokensResponse does not contain currency of selected network`() = runTest { - val multiWallet = createMultiWallet() - - val userTokensResponse = UserTokensResponseFactory().createUserTokensResponse( - currencies = listOf(bitcoin), - isGroupedByNetwork = false, - isSortedByBalance = false, - ) - - coEvery { userWalletsStore.getSyncStrict(key = multiWallet.walletId) } returns multiWallet - coEvery { userTokensResponseStore.getSyncOrNull(multiWallet.walletId) } returns userTokensResponse - - val actual = factory.create(userWalletId = multiWallet.walletId, network = ethereum.network) - - coVerifyOrder { - userWalletsStore.getSyncStrict(key = multiWallet.walletId) - userTokensResponseStore.getSyncOrNull(multiWallet.walletId) - } - - val expected = emptyList() - - Truth.assertThat(actual).isEqualTo(expected) - } - - @Test - fun `test create if single wallet has another primary network`() = runTest { - val singleWallet = createSingleWallet() - - coEvery { userWalletsStore.getSyncStrict(key = singleWallet.walletId) } returns singleWallet - - val actual = factory.create(userWalletId = singleWallet.walletId, network = bitcoin.network) - - coVerifyOrder { - userWalletsStore.getSyncStrict(key = singleWallet.walletId) - singleWallet.scanResponse.cardTypesResolver.getBlockchain() - } - - val expected = emptyList() - - Truth.assertThat(actual).isEqualTo(expected) - } - - @Test - fun `test create if card is single wallet`() = runTest { - val singleWallet = createSingleWallet() - - coEvery { userWalletsStore.getSyncStrict(key = singleWallet.walletId) } returns singleWallet - - val actual = factory.create(userWalletId = singleWallet.walletId, network = ethereum.network) - - coVerifyOrder { - userWalletsStore.getSyncStrict(key = singleWallet.walletId) - singleWallet.scanResponse.cardTypesResolver.getBlockchain() - } - - val expected = listOf(ethereum) - - Truth.assertThat(actual).isEqualTo(expected) - } - - @Test - fun `test create if card is single wallet with token`() = runTest { - val singleWallet = createSingleWalletWithToken() - - coEvery { userWalletsStore.getSyncStrict(key = singleWallet.walletId) } returns singleWallet - - val actual = factory.create(userWalletId = singleWallet.walletId, network = ethereum.network) - - val token = CryptoCurrencyFactory(excludedBlockchains = ExcludedBlockchains()).createToken( - sdkToken = singleWallet.scanResponse.cardTypesResolver.getPrimaryToken()!!, - blockchain = Blockchain.Ethereum, - extraDerivationPath = null, - scanResponse = singleWallet.scanResponse, - ) - val expected = listOf(ethereum, token) - - Truth.assertThat(actual).isEqualTo(expected) - } - - @Test - fun `test createDefaultCoinsForMultiCurrencyCard if card is prod`() = runTest { - val multiWallet = createMultiWallet() - - val actual = factory.createDefaultCoinsForMultiCurrencyCard(scanResponse = multiWallet.scanResponse) - - val expected = listOf(bitcoin, ethereum) - - Truth.assertThat(actual).isEqualTo(expected) - } - - @Test - fun `test createDefaultCoinsForMultiCurrencyCard if card is test`() = runTest { - val multiWallet = createMultiWallet().let { - it.copy( - scanResponse = it.scanResponse.copy( - card = it.scanResponse.card.copy(cardId = "FF99", batchId = "99FF"), - ), + private fun createUserTokensResponse(currencies: List = emptyList()): UserTokensResponse { + return userTokensResponseFactory.createUserTokensResponse( + currencies = currencies, + isGroupedByNetwork = false, + isSortedByBalance = false, ) } - - val actual = factory.createDefaultCoinsForMultiCurrencyCard(scanResponse = multiWallet.scanResponse) - - val expected = listOf( - cryptoCurrencyFactory.createCoin(blockchain = Blockchain.BitcoinTestnet), - cryptoCurrencyFactory.createCoin(blockchain = Blockchain.EthereumTestnet).setCanHandleTokens(true), - ) - - Truth.assertThat(actual).isEqualTo(expected) } - @Test - fun `test createDefaultCoinsForMultiCurrencyCard if card is demo`() = runTest { - val multiWallet = createMultiWallet().let { - it.copy( - scanResponse = it.scanResponse.copy( - card = it.scanResponse.card.copy(cardId = "AC01000000041225"), + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class CreateSingleWallet { + + @ParameterizedTest + @ProvideTestModels + fun `create currencies for single-currency wallet (ETH)`(model: CreateTestModel.SingleWallet) = runTest { + // Arrange + val userWallet = createSingleWallet() + + coEvery { userWalletsStore.getSyncStrict(key = userWallet.walletId) } returns userWallet + + // Act + val actual = factory.create(userWalletId = userWallet.walletId, network = model.network) + + // Assert + val expected = model.expected + + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + userWalletsStore.getSyncStrict(key = userWallet.walletId) + userWallet.scanResponse.cardTypesResolver.getBlockchain() + } + + coVerify(inverse = true) { + userTokensResponseStore.getSyncOrNull(userWalletId = any()) + } + } + + private fun provideTestModels() = listOf( + CreateTestModel.SingleWallet(network = ethereum.network, expected = listOf(ethereum)), + CreateTestModel.SingleWallet(network = bitcoin.network, expected = emptyList()), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class CreateSingleWalletWithToken { + + @ParameterizedTest + @ProvideTestModels + fun `create currencies for single-currency wallet with token (ETH)`( + model: CreateTestModel.SingleWalletWithToken, + ) = runTest { + // Arrange + val userWallet = createSingleWalletWithToken() + + coEvery { userWalletsStore.getSyncStrict(key = userWallet.walletId) } returns userWallet + + // Act + val actual = factory.create(userWalletId = userWallet.walletId, network = model.network) + + // Assert + val primaryToken = if (model.isPrimaryTokenExpected) { + createPrimaryToken(blockchain = Blockchain.Ethereum) + } else { + null + } + + val expected = listOfNotNull(*model.expected.toTypedArray(), primaryToken) + + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + userWalletsStore.getSyncStrict(key = userWallet.walletId) + userWallet.scanResponse.cardTypesResolver.getBlockchain() + } + + coVerify(inverse = true) { + userTokensResponseStore.getSyncOrNull(userWalletId = any()) + } + } + + private fun provideTestModels() = listOf( + CreateTestModel.SingleWalletWithToken( + network = ethereum.network, + isPrimaryTokenExpected = true, + expected = listOf(ethereum), + ), + CreateTestModel.SingleWalletWithToken( + network = bitcoin.network, + isPrimaryTokenExpected = false, + expected = emptyList(), + ), + ) + } + + sealed interface CreateTestModel { + + val expected: List + + data class MultiWallet( + val userTokensResponse: UserTokensResponse?, + override val expected: List, + ) : CreateTestModel + + data class SingleWallet( + val network: Network, + override val expected: List, + ) : CreateTestModel + + data class SingleWalletWithToken( + val network: Network, + val isPrimaryTokenExpected: Boolean, + override val expected: List, + ) : CreateTestModel + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class CreateCurrenciesForMultiCurrencyCard { + + @ParameterizedTest + @ProvideTestModels + fun `create currencies in ETH and BTC for multi-currency card`(model: CreateCurrenciesForMultiWalletModel) = + runTest { + // Arrange + val userWallet = model.multiWallet + val networks = setOf(ethereum.network, bitcoin.network) + val userTokensResponse = model.userTokensResponse + + coEvery { userTokensResponseStore.getSyncOrNull(userWallet.walletId) } returns userTokensResponse + + // Act + val actual = runCatching { + factory.createCurrenciesForMultiCurrencyCard(userWallet = userWallet, networks = networks) + } + + // Assert + actual + .onSuccess { + val expected = model.expected.getOrNull()!! + + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val exception = model.expected.exceptionOrNull()!! + + Truth.assertThat(it).isInstanceOf(exception::class.java) + Truth.assertThat(it).hasMessageThat().isEqualTo(exception.message) + } + } + + private fun provideTestModels() = listOf( + CreateCurrenciesForMultiWalletModel( + multiWallet = createMultiWallet(), + userTokensResponse = null, + expected = Result.success(emptyMap()), + ), + CreateCurrenciesForMultiWalletModel( + multiWallet = createMultiWallet(), + userTokensResponse = createUserTokensResponse(), + expected = Result.success(emptyMap()), + ), + CreateCurrenciesForMultiWalletModel( + multiWallet = createMultiWallet(), + userTokensResponse = createUserTokensResponse(currencies = listOf(bitcoin, ethereum)), + expected = mapOf( + bitcoin.network to listOf(bitcoin), + ethereum.network to listOf(ethereum), + ).let(Result.Companion::success), + ), + CreateCurrenciesForMultiWalletModel( + multiWallet = createSingleWallet(), + userTokensResponse = null, + expected = Result.failure(IllegalArgumentException("It isn't multi-currency wallet")), + ), + CreateCurrenciesForMultiWalletModel( + multiWallet = createSingleWalletWithToken(), + userTokensResponse = null, + expected = Result.failure(IllegalArgumentException("It isn't multi-currency wallet")), + ), + ) + } + + data class CreateCurrenciesForMultiWalletModel( + val multiWallet: UserWallet, + val userTokensResponse: UserTokensResponse?, + val expected: Result>>, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class CreateDefaultCoinsForMultiCurrencyCard { + + @ParameterizedTest + @ProvideTestModels + fun `create default coins for multi currency card`(model: CreateDefaultForMultiWalletModel) = runTest { + // Arrange + val multiWallet = model.multiWallet + + // Act + val actual = factory.createDefaultCoinsForMultiCurrencyCard(scanResponse = multiWallet.scanResponse) + + // Assert + val expected = model.expected + + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun provideTestModels() = listOf( + // PROD card + CreateDefaultForMultiWalletModel(multiWallet = createMultiWallet(), expected = listOf(bitcoin, ethereum)), + // TEST card + CreateDefaultForMultiWalletModel( + multiWallet = createMultiWallet(cardId = "FF99", batchId = "99FF"), + expected = listOf( + cryptoCurrencyFactory.createCoin(blockchain = Blockchain.BitcoinTestnet), + cryptoCurrencyFactory.createCoin(blockchain = Blockchain.EthereumTestnet).setCanHandleTokens(true), ), - ) - } - - val actual = factory.createDefaultCoinsForMultiCurrencyCard(scanResponse = multiWallet.scanResponse) - - val expected = listOf( - bitcoin, - ethereum, - cryptoCurrencyFactory.createCoin(blockchain = Blockchain.Dogecoin), - cryptoCurrencyFactory.createCoin(blockchain = Blockchain.Solana), - ) - - Truth.assertThat(actual).isEqualTo(expected) - } - - @Test - fun `test createPrimaryCurrencyForSingleCurrencyCard if unable to create token`() = runTest { - val singleWallet = UserWallet( - name = "Note", - walletId = UserWalletId("011"), - cardsInWallet = setOf(), - isMultiCurrency = false, - scanResponse = MockScanResponseFactory.create( - cardConfig = GenericCardConfig(maxWalletCount = 2), - derivedKeys = emptyMap(), ), - hasBackupError = false, - ) - - val actual = runCatching { - factory.createPrimaryCurrencyForSingleCurrencyCard(scanResponse = singleWallet.scanResponse) - } - - val exception = IllegalArgumentException("Coin for the single currency card cannot be null") - - Truth.assertThat(actual.isFailure).isTrue() - Truth.assertThat(actual.exceptionOrNull()).isInstanceOf(exception::class.java) - Truth.assertThat(actual.exceptionOrNull()).hasMessageThat().isEqualTo(exception.message) - } - - @Test - fun `test createPrimaryCurrencyForSingleCurrencyCard if primaryToken is null`() = runTest { - val singleWallet = createSingleWallet() - - val actual = factory.createPrimaryCurrencyForSingleCurrencyCard(scanResponse = singleWallet.scanResponse) - - val expected = ethereum - - Truth.assertThat(actual).isEqualTo(expected) - } - - @Test - fun `test createPrimaryCurrencyForSingleCurrencyCard if primaryToken is not null`() = runTest { - val singleWallet = createSingleWalletWithToken() - - val actual = factory.createPrimaryCurrencyForSingleCurrencyCard(scanResponse = singleWallet.scanResponse) - - val expected = CryptoCurrencyFactory(excludedBlockchains = ExcludedBlockchains()).createToken( - sdkToken = singleWallet.scanResponse.cardTypesResolver.getPrimaryToken()!!, - blockchain = Blockchain.Ethereum, - extraDerivationPath = null, - scanResponse = singleWallet.scanResponse, - ) - - Truth.assertThat(actual).isEqualTo(expected) - } - - @Test - fun `test createCurrenciesForSingleCurrencyCardWithToken if unable to create token`() = runTest { - val singleWalletWithToken = UserWallet( - name = "Note", - walletId = UserWalletId("011"), - cardsInWallet = setOf(), - isMultiCurrency = false, - scanResponse = MockScanResponseFactory.create( - cardConfig = GenericCardConfig(maxWalletCount = 2), - derivedKeys = emptyMap(), + // // DEMO card + CreateDefaultForMultiWalletModel( + multiWallet = createMultiWallet(cardId = "AC01000000041225"), + expected = listOf( + bitcoin, + ethereum, + cryptoCurrencyFactory.createCoin(blockchain = Blockchain.Dogecoin), + cryptoCurrencyFactory.createCoin(blockchain = Blockchain.Solana), + ), ), - hasBackupError = false, ) - - val actual = runCatching { - factory.createCurrenciesForSingleCurrencyCardWithToken(scanResponse = singleWalletWithToken.scanResponse) - } - - val exception = IllegalArgumentException("Coin for the single currency card cannot be null") - - Truth.assertThat(actual.isFailure).isTrue() - Truth.assertThat(actual.exceptionOrNull()).isInstanceOf(exception::class.java) - Truth.assertThat(actual.exceptionOrNull()).hasMessageThat().isEqualTo(exception.message) } - @Test - fun `test createCurrenciesForSingleCurrencyCardWithToken if primaryToken is null`() = runTest { - val singleWalletWithToken = createSingleWallet() + data class CreateDefaultForMultiWalletModel(val multiWallet: UserWallet, val expected: List) - val actual = factory.createCurrenciesForSingleCurrencyCardWithToken( - scanResponse = singleWalletWithToken.scanResponse, + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class CreatePrimaryCurrencyForSingleCurrencyCard { + + @ParameterizedTest + @ProvideTestModels + fun `create primary currency for single currency card`(model: CreatePrimaryCurrencyForSingleWalletModel) = + runTest { + // Arrange + val singleWallet = model.singleWallet + + // Act + val actual = runCatching { + factory.createPrimaryCurrencyForSingleCurrencyCard(scanResponse = singleWallet.scanResponse) + } + + // Assert + actual + .onSuccess { + Truth.assertThat(actual).isEqualTo(model.expected) + } + .onFailure { + val exception = model.expected.exceptionOrNull()!! + + Truth.assertThat(actual.exceptionOrNull()).isInstanceOf(exception::class.java) + Truth.assertThat(actual.exceptionOrNull()).hasMessageThat().isEqualTo(exception.message) + } + } + + private fun provideTestModels() = listOf( + CreatePrimaryCurrencyForSingleWalletModel( + singleWallet = createSingleWallet(), + expected = Result.success(ethereum), + ), + CreatePrimaryCurrencyForSingleWalletModel( + singleWallet = createSingleWallet(batchId = ""), + expected = Result.failure(IllegalArgumentException("Coin for the single currency card cannot be null")), + ), + CreatePrimaryCurrencyForSingleWalletModel( + singleWallet = createSingleWallet(addWalletData = true), + expected = Result.failure(IllegalArgumentException("It isn't single-currency wallet")), + ), ) - - val expected = listOf(ethereum) - - Truth.assertThat(actual).isEqualTo(expected) } - @Test - fun `test createCurrenciesForSingleCurrencyCardWithToken if primaryToken is not null`() = runTest { - val singleWalletWithToken = createSingleWalletWithToken() + data class CreatePrimaryCurrencyForSingleWalletModel( + val singleWallet: UserWallet, + val expected: Result, + ) - val actual = factory.createCurrenciesForSingleCurrencyCardWithToken( - scanResponse = singleWalletWithToken.scanResponse, + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class CreateCurrenciesForSingleCurrencyCardWithToken { + + @ParameterizedTest + @ProvideTestModels + fun `create currencies for single currency card with token`(model: CreateForSingleWalletWithTokenModel) = + runTest { + // Arrange + val userWallet = model.singleWalletWithToken + + // Act + val actual = runCatching { + factory.createCurrenciesForSingleCurrencyCardWithToken(scanResponse = userWallet.scanResponse) + } + + // Assert + actual + .onSuccess { + val primaryToken = if (model.isPrimaryTokenExpected) { + createPrimaryToken(blockchain = Blockchain.Ethereum) + } else { + null + } + + val expected = listOfNotNull(*model.expected.getOrNull()!!.toTypedArray(), primaryToken) + + Truth.assertThat(it).isEqualTo(expected) + } + .onFailure { + val exception = model.expected.exceptionOrNull()!! + + Truth.assertThat(it).isInstanceOf(exception::class.java) + Truth.assertThat(it).hasMessageThat().isEqualTo(exception.message) + } + } + + private fun provideTestModels() = listOf( + CreateForSingleWalletWithTokenModel( + singleWalletWithToken = UserWallet( + 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 = "", + token = WalletData.Token( + name = "Ethereum", + symbol = "ETH", + contractAddress = "0x", + decimals = 8, + ), + ), + ), + hasBackupError = false, + ), + expected = Result.failure(IllegalArgumentException("Coin for the single currency card cannot be null")), + ), + CreateForSingleWalletWithTokenModel( + singleWalletWithToken = createSingleWalletWithToken(), + isPrimaryTokenExpected = true, + expected = Result.success(listOf(ethereum)), + ), ) - - val token = CryptoCurrencyFactory(excludedBlockchains = ExcludedBlockchains()).createToken( - sdkToken = singleWalletWithToken.scanResponse.cardTypesResolver.getPrimaryToken()!!, - blockchain = Blockchain.Ethereum, - extraDerivationPath = null, - scanResponse = singleWalletWithToken.scanResponse, - ) - - val expected = listOf(ethereum, token) - - Truth.assertThat(actual).isEqualTo(expected) } - private fun createMultiWallet(): UserWallet { + data class CreateForSingleWalletWithTokenModel( + val singleWalletWithToken: UserWallet, + val isPrimaryTokenExpected: Boolean = false, + val expected: Result>, + ) + + private fun createMultiWallet(cardId: String? = null, batchId: String? = null): UserWallet { return UserWallet( name = "Wallet 1", walletId = UserWalletId("011"), @@ -363,12 +471,19 @@ internal class DefaultCardCryptoCurrencyFactoryTest { scanResponse = MockScanResponseFactory.create( cardConfig = GenericCardConfig(maxWalletCount = 2), derivedKeys = emptyMap(), - ), + ).let { + it.copy( + card = it.card.copy( + cardId = cardId ?: it.card.cardId, + batchId = batchId ?: it.card.batchId, + ), + ) + }, hasBackupError = false, ) } - private fun createSingleWallet(): UserWallet { + private fun createSingleWallet(batchId: String? = null, addWalletData: Boolean = false): UserWallet { return UserWallet( name = "Note", walletId = UserWalletId("011"), @@ -379,8 +494,21 @@ internal class DefaultCardCryptoCurrencyFactoryTest { derivedKeys = emptyMap(), ).let { it.copy( - card = it.card.copy(batchId = "AB10"), + card = it.card.copy(batchId = batchId ?: "AB10"), productType = ProductType.Note, + walletData = if (addWalletData) { + WalletData( + blockchain = "ETH", + token = WalletData.Token( + name = "Ethereum", + symbol = "ETH", + contractAddress = "0x", + decimals = 8, + ), + ) + } else { + it.walletData + }, ) }, hasBackupError = false, @@ -412,14 +540,27 @@ internal class DefaultCardCryptoCurrencyFactoryTest { ) } + private fun createPrimaryToken(blockchain: Blockchain): CryptoCurrency.Token { + val userWallet = createSingleWalletWithToken() + + return CryptoCurrencyFactory(excludedBlockchains = ExcludedBlockchains()).createToken( + sdkToken = userWallet.scanResponse.cardTypesResolver.getPrimaryToken()!!, + blockchain = blockchain, + extraDerivationPath = null, + scanResponse = userWallet.scanResponse, + )!! + } + + private fun createUserTokensResponse(currencies: List = emptyList()): UserTokensResponse { + return userTokensResponseFactory.createUserTokensResponse( + currencies = currencies, + isGroupedByNetwork = false, + isSortedByBalance = false, + ) + } + private companion object { - val cryptoCurrencyFactory = MockCryptoCurrencyFactory() - - val ethereum = cryptoCurrencyFactory.ethereum.setCanHandleTokens(value = true) - - val bitcoin = cryptoCurrencyFactory.createCoin(blockchain = Blockchain.Bitcoin) - fun CryptoCurrency.setCanHandleTokens(value: Boolean): CryptoCurrency { return when (this) { is CryptoCurrency.Coin -> copy(network = network.copy(canHandleTokens = value)) diff --git a/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt new file mode 100644 index 0000000000..ba28c59281 --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt @@ -0,0 +1,69 @@ +package com.tangem.data.networks.fetcher + +import arrow.core.Either +import com.tangem.data.networks.store.NetworksStatusesStoreV2 +import com.tangem.data.networks.store.setSourceAsOnlyCache +import com.tangem.data.networks.store.storeStatus +import com.tangem.data.networks.utils.NetworkStatusFactory +import com.tangem.domain.core.utils.catchOn +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +/** + * Common implementation of network status fetcher + * + * @property walletManagersFacade wallet managers facade + * @property networksStatusesStore networks statuses store + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class CommonNetworkStatusFetcher @Inject constructor( + private val walletManagersFacade: WalletManagersFacade, + private val networksStatusesStore: NetworksStatusesStoreV2, + private val dispatchers: CoroutineDispatcherProvider, +) { + + /** + * Fetch + * + * @param userWalletId user wallet id + * @param network network + * @param networkCurrencies network currencies + */ + suspend fun fetch( + userWalletId: UserWalletId, + network: Network, + networkCurrencies: Set, + ): Either { + return Either.catchOn(dispatchers.default) { + val result = withContext(dispatchers.io) { + walletManagersFacade.update( + userWalletId = userWalletId, + network = network, + extraTokens = networkCurrencies + .filterIsInstance() + .toSet(), + ) + } + + val status = NetworkStatusFactory.create( + network = network, + updatingResult = result, + addedCurrencies = networkCurrencies, + ) + + networksStatusesStore.storeStatus(userWalletId = userWalletId, status = status) + } + .onLeft { + Timber.e("Failed to fetch network status for $userWalletId [${network.id.value}]: $it") + networksStatusesStore.setSourceAsOnlyCache(userWalletId = userWalletId, network = network) + } + } +} \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt new file mode 100644 index 0000000000..da1191c0f2 --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt @@ -0,0 +1,161 @@ +package com.tangem.data.networks.fetcher + +import arrow.core.Either +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.network.MockNetworkStatusFactory +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.domain.walletmanager.MockUpdateWalletManagerResultFactory +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.data.networks.store.NetworksStatusesStoreV2 +import com.tangem.data.networks.store.setSourceAsOnlyCache +import com.tangem.data.networks.store.storeStatus +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyAmountStatus +import com.tangem.domain.tokens.model.NetworkAddress +import com.tangem.domain.tokens.model.NetworkStatus +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class CommonNetworkStatusFetcherTest { + + private val walletManagersFacade: WalletManagersFacade = mockk() + private val networksStatusesStore: NetworksStatusesStoreV2 = mockk(relaxUnitFun = true) + + private val fetcher = CommonNetworkStatusFetcher( + walletManagersFacade = walletManagersFacade, + networksStatusesStore = networksStatusesStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() + + @BeforeEach + fun resetMocks() { + clearMocks(walletManagersFacade, networksStatusesStore) + } + + @Test + fun `fetch failure if walletManagersFacade throws exception`() = runTest { + // Arrange + val userWalletId = UserWalletId("011") + val network = cryptoCurrencyFactory.ethereum.network + val extraTokens = setOf( + cryptoCurrencyFactory.createToken(Blockchain.Ethereum) as CryptoCurrency.Token, + ) + val updateException = IllegalStateException() + + coEvery { walletManagersFacade.update(userWalletId, network, extraTokens) } throws updateException + + // Act + val actual = fetcher.fetch(userWalletId = userWalletId, network = network, networkCurrencies = extraTokens) + + // Assert + val expected = Either.Left(updateException) + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected.leftOrNull()!!::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.leftOrNull()!!.message) + + coVerifyOrder { + walletManagersFacade.update(userWalletId, network, extraTokens) + } + } + + @ParameterizedTest + @ProvideTestModels + fun `fetch successfully for any result of walletManagersFacade`(model: SuccessTestModel) = runTest { + // Arrange + val userWalletId = UserWalletId("011") + val network = cryptoCurrencyFactory.ethereum.network + val extraTokens = setOf( + cryptoCurrencyFactory.createToken(Blockchain.Ethereum) as CryptoCurrency.Token, + ) + val updateResult = model.updateResult + val status = model.status + + coEvery { walletManagersFacade.update(userWalletId, network, extraTokens) } returns updateResult + coEvery { networksStatusesStore.storeStatus(userWalletId = userWalletId, status = status) } returns Unit + + // Act + val actual = fetcher.fetch(userWalletId = userWalletId, network = network, networkCurrencies = extraTokens) + + // Assert + val expected = Either.Right(Unit) + + Truth.assertThat(actual).isEqualTo(expected) + + coVerifyOrder { + walletManagersFacade.update(userWalletId, network, extraTokens) + networksStatusesStore.storeStatus(userWalletId = userWalletId, status = status) + } + + coVerify(inverse = true) { + networksStatusesStore.setSourceAsOnlyCache(userWalletId = any(), network = any()) + } + } + + private fun provideTestModels() = listOf( + SuccessTestModel( + updateResult = UpdateWalletManagerResult.MissedDerivation, + status = MockNetworkStatusFactory.createMissedDerivation(), + ), + SuccessTestModel( + updateResult = MockUpdateWalletManagerResultFactory().createUnreachable(), + status = NetworkStatus( + network = cryptoCurrencyFactory.ethereum.network, + value = NetworkStatus.Unreachable(address = null), + ), + ), + SuccessTestModel( + updateResult = MockUpdateWalletManagerResultFactory().createUnreachableWithAddress(), + status = NetworkStatus( + network = cryptoCurrencyFactory.ethereum.network, + value = NetworkStatus.Unreachable( + address = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x1", + type = NetworkAddress.Address.Type.Primary, + ), + ), + ), + ), + ), + SuccessTestModel( + updateResult = MockUpdateWalletManagerResultFactory().createNoAccount(), + status = MockNetworkStatusFactory.createNoAccount(), + ), + SuccessTestModel( + updateResult = MockUpdateWalletManagerResultFactory().createVerified(), + status = MockNetworkStatusFactory.createVerified(cryptoCurrencyFactory.ethereum.network) { + it.copy( + amounts = mapOf( + CryptoCurrency.ID.fromValue( + value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND", + ) to CryptoCurrencyAmountStatus.NotFound, + ), + pendingTransactions = mapOf( + CryptoCurrency.ID.fromValue(value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND") to emptySet(), + ), + ) + }, + ), + ) + + data class SuccessTestModel( + val updateResult: UpdateWalletManagerResult, + val status: NetworkStatus, + ) +} \ No newline at end of file